27 Commits

Author SHA1 Message Date
Awiteb
5151f5d04d chore: typo in the license description
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-29 08:28:01 +00:00
Awiteb
463010c9f6 refactor: remove dbus C dep
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-29 08:24:04 +00:00
Awiteb
d2adff7882 feat: write patches to the stdout in patch fetch command
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-24 15:49:57 +00:00
Awiteb
ee17c21dbb feat: accept patches from stdin in patch send command
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-24 15:47:57 +00:00
Awiteb
df54a53b7c deps: add io-std feature to tokio
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-24 15:47:03 +00:00
Awiteb
95a46a798f feat(logs): log to stderr and a file
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-24 10:50:10 +00:00
Awiteb
03b3d89f48 chore(tasks): add some check to release task
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-22 07:04:43 +00:00
Awiteb
9122557281 chore: replace justfile with nu script
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-18 12:10:33 +00:00
Awiteb
dfa7467b1e chore: do not check the MSRV
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-18 09:05:38 +00:00
Awiteb
8d8ad5f1e5 feat: add --personal-fork flag to repo announce command
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-16 20:11:59 +00:00
Awiteb
f73245f9c3 chore: improve querying
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-13 09:18:55 +00:00
Awiteb
c3be6248b5 chore: move patch::PatchPrStatus to types::PatchPrStatus
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-12 10:13:01 +00:00
Awiteb
4df73316a0 chore(config): skip serializing nip07 if Option::is_none
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-12 10:13:01 +00:00
Awiteb
be3cdc440d chore: fix docs issues
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-12 10:13:01 +00:00
Awiteb
4bcf104c80 docs: update commands titles and descriptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-09-12 10:12:58 +00:00
Awiteb
4360aa192c feat: support pull requests
Support for pull requests, it's not standard in NIPs yet[1] but there must be
two clients that support it to be included in NIPs, n34 is one of them.

This support:
- Create a new PR `n34 pr new`
- Update PR tip `n34 pr update`
- View a PR `n34 pr view`
- List PRs `n34 pr list`
- Add `PR` and `PR-update` kinds as roots in `NostrClient::find_root`
- Support issuing statuses for PRs
- Support GRASP PR and PR-update
- Update the docs

[1] https://github.com/nostr-protocol/nips/pull/1966
2025-09-12 06:43:23 +00:00
Awiteb
cc3aed0e89 chore: Rename PatchStatus to PatchPrStatus
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-27 22:57:17 +00:00
Awiteb
f996fd351b chore: Move repo::view::format_list to utils::format_iter
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-25 14:51:58 +00:00
Awiteb
518e573467 chore: Move issue-view run block to common_commands::view_pr_issue
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-25 14:51:58 +00:00
Awiteb
1e9e00a4f3 chore: Rename GitIssueUtils trait to GitIssuePrMetadata
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-25 12:12:11 +00:00
Awiteb
c308dc1b42 docs: Update commands help messages
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-24 10:06:55 +00:00
Awiteb
089b62a0ef fix(n34-patch-apply): required the applied_commits
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-21 15:01:38 +00:00
Awiteb
5e76e413ab chore: fix the Breaking Change group
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-18 13:35:47 +00:00
Awiteb
ed1bfaf161 fix(issue)!: make subject mandatory and optional content
BREAKING CHANGE: When the editor contains only one line, it is now
treated as the subject instead of being used as the issue content.

Signed-off-by: Awiteb <a@4rs.nl>
2025-08-18 13:35:18 +00:00
Awiteb
ec68e972f7 fix: check the nip07 signer in CliOptions::ensure_signer
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-18 12:24:59 +00:00
Awiteb
629de42215 chore: Add [script] to deploy recipe
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-13 17:02:22 +00:00
Awiteb
dd05e050cd feat: New command repo state
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-13 16:33:15 +00:00
103 changed files with 4038 additions and 1130 deletions

View File

@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- New command `repo state` - by Awiteb
- Support pull requests - by Awiteb
- Add `--personal-fork` flag to `repo announce` command - by Awiteb
- Log to stderr and a file - by Awiteb
- Accept patches from stdin in `patch send` command - by Awiteb
- Write patches to the stdout in `patch fetch` command - by Awiteb
### Breaking Change
- Make subject mandatory and optional content - by Awiteb
### Dependencies
- Add `io-std` feature to `tokio` - by Awiteb
### Documentation
- Update commands help messages - by Awiteb
- Update commands titles and descriptions - by Awiteb
### Fixed
- Check the nip07 signer in `CliOptions::ensure_signer` - by Awiteb
- Required the `applied_commits` - by Awiteb
### Refactor
- Remove `dbus` C dep - by Awiteb
## [0.4.0] - 2025-08-08
### Added

View File

@@ -37,9 +37,7 @@ as the active development branch.
You can submit issues and patches via any
Nostr-compatible client using the address:
`naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueq
yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00
p68ytnyv4mqwuj6xc`
`naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueqyf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00p68ytnyv4mqwuj6xc`
When using `n34`, there's no need to specify the address, it will automatically
@@ -70,8 +68,8 @@ to their read relays, most tools handle this automatically.
#### Patch Guidelines
- Keep patches small: Focused changes are easier to review and merge.
- Run `just ci` before submitting your patch.
- Update the change log with your patch. Run `just changelog` or `git-cliff > CHANGELOG.md`
- Run `./tasks ci` before submitting your patch.
- Update the change log with your patch. Run `./tasks changelog` or `git-cliff > CHANGELOG.md`
- Add your name to the [AUTHORS](AUTHORS) file if this is your first contribution. (alphabetical order)
- Use [Conventional Commits]: Start the patch subject with one of these types:
- `feat`: New feature

1263
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ categories = ["command-line-utilities"]
rust-version = "1.88.0"
[dependencies]
async-utility = "0.3.1"
chrono = "0.4.41"
convert_case = "0.8.0"
dirs = "6.0.0"
@@ -28,6 +29,16 @@ toml = "0.9.4"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[dependencies.keyring]
features = [
"apple-native",
"windows-native",
"async-secret-service",
"tokio",
"crypto-rust",
]
version = "3.6.3"
[dependencies.clap]
features = ["derive"]
version = "4.5.42"
@@ -46,10 +57,6 @@ tracing-subscriber = "0.3.19"
default-features = false
version = "0.43.0"
[dependencies.nostr-keyring]
default-features = false
version = "0.43.0"
[dependencies.nostr-sdk]
default-features = false
version = "0.43.0"
@@ -64,7 +71,7 @@ tracing-subscriber = "0.3.19"
version = "1.0.219"
[dependencies.tokio]
features = ["macros", "rt-multi-thread"]
features = ["macros", "rt-multi-thread", "io-std"]
version = "1.47.1"
[profile.release]

View File

@@ -20,12 +20,12 @@ Check the documentation at [n34.dev]
## Features
- [X] Repository announcements
- [ ] Repository state announcements
- [X] Repository state announcements
- [X] Patches (Send, fetch and list)
- [X] Issues (Send, view and list)
- [X] Replies
- [X] Issues and patches status
- [ ] Pull requests ([nostr-protocol/nips#1966])
- [X] Pull requests ([nostr-protocol/nips#1966])
- [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])

View File

@@ -55,7 +55,7 @@ filter_unconventional = false
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = '^.*!:.*$', group = "Breaking Change" },
{ message = '^.*!:', group = "Breaking Change" },
{ message = '^feat', group = "Added" },
{ message = '^deprecate', group = "Deprecated" },
{ message = '^remove', group = "Removed" },

View File

@@ -15,20 +15,31 @@
- [Manage Repositories](repo/README.md)
- [Broadcast and Update a Git Repository](repo/announce.md)
- [View Git Repository Details](repo/view.md)
- [Repository State Announcements](repo/state.md)
- [Reply to Issues and Patches](reply.md)
- [Issue Management](issue/README.md)
- [Create an Issue](issue/new.md)
- [View an Issue By ID](issue/view.md)
- [Reopen a Closed Issue](issue/reopen.md)
- [Closes an Open Issue](issue/close.md)
- [Resolves an Issue](issue/resolve.md)
- [List Repositories Issues](issue/list.md)
- [View an Issue](issue/view.md)
- [Reopen an Issue](issue/reopen.md)
- [Close an Issue](issue/close.md)
- [Resolve an Issue](issue/resolve.md)
- [List Issues](issue/list.md)
- [Patch Management](patch/README.md)
- [Send Patches to a Repository](patch/send.md)
- [Fetch a Patch By ID](patch/fetch.md)
- [Reopens a Closed or Drafted Patch](patch/reopen.md)
- [Closes an Open or Drafted Patch](patch/close.md)
- [Draft an Open Patch](patch/draft.md)
- [Apply an Open Patch](patch/apply.md)
- [Merge an Open Patch](patch/merge.md)
- [List Repositories Patches](patch/list.md)
- [Reply to Issues and Patches](reply.md)
- [Fetch a Patch](patch/fetch.md)
- [Close a Patch](patch/close.md)
- [Convert to Draft](patch/draft.md)
- [Reopen a Patch](patch/reopen.md)
- [Mark as Applied](patch/apply.md)
- [Mark as Merged](patch/merge.md)
- [List Patches](patch/list.md)
- [Pull Request Management](pr/README.md)
- [Create a Pull Request](pr/new.md)
- [Update a Pull Request](pr/update.md)
- [View a Pull Request](pr/view.md)
- [List Pull Requests](pr/list.md)
- [Close a Pull Request](pr/close.md)
- [Convert to Draft](pr/draft.md)
- [Reopen a Pull Request](pr/reopen.md)
- [Mark as Applied](pr/apply.md)
- [Mark as Merged](pr/merge.md)

View File

@@ -1,5 +1,17 @@
# Command-Line Usage
## Philosophy
`n34` emphasizes simplicity by exclusively handling Nostr tasks, requiring
users to manage Git operations themselves. For instance, it won't merge or
rebase patches into a branch but will prompt you to specify where to write them,
allowing you to merge them using `git-am`. Similarly, when sending patches, it
won't ask for the commits but will request the patch files instead.
This approach enhances simplicity and freedom, giving you full control over
your workflow. Additionally, it simplifies software maintenance by avoiding
unnecessary complexity.
## Options
The `n34` command-line tool accepts the following options:
@@ -19,8 +31,8 @@ The `n34` command-line tool accepts the following options:
- `-v`, `--verbose...`: Increases the logging verbosity. Can be used multiple
times for more detail (e.g., `-v`, `-vv`).
**Note:** The `--secret-key` and `--bunker-url` options are mutually exclusive.
You must provide exactly one signing method.
**Note:** The `--secret-key`, `--nip07` and `--bunker-url` options are mutually
exclusive. You must provide exactly one signing method.
## Multiple Repositories

View File

@@ -355,8 +355,8 @@
<span class="feature-text">Repository announcements</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Repository state announcements</span>
<span class="feature-check completed"></span>
<span class="feature-text">Repository state announcements</span>
</li>
<li>
<span class="feature-check completed"></span>
@@ -375,8 +375,8 @@
<span class="feature-text">Issues and patches status</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Pull requests (<a href="https://github.com/nostr-protocol/nips/pull/1966" target="_blank" rel="noreferrer">nostr-protocol/nips#1966</a>)</span>
<span class="feature-check completed"></span>
<span class="feature-text">Pull requests (<a href="https://github.com/nostr-protocol/nips/pull/1966" target="_blank" rel="noreferrer">nostr-protocol/nips#1966</a>)</span>
</li>
</ul>
</div>
@@ -400,7 +400,7 @@
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text pending">Signing using <a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noreferrer">NIP-07</a> proxy (<a href="https://crates.io/crates/nostr-browser-signer-proxy" target="_blank" rel="noreferrer">nostr-browser-signer-proxy</a>)</span>
<span class="feature-text">Signing using <a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noreferrer">NIP-07</a> proxy (<a href="https://crates.io/crates/nostr-browser-signer-proxy" target="_blank" rel="noreferrer">nostr-browser-signer-proxy</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>

View File

@@ -1,4 +1,4 @@
# Closes an Open Issue
# Close an Issue
> `n34 issue close` command
@@ -12,7 +12,7 @@ Arguments:
<ISSUE_ID> The open issue id to close it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified issue. The issue have to

View File

@@ -1,4 +1,4 @@
# List Repositories Issues
# List Issues
> `n34 issue list` command
@@ -9,7 +9,7 @@ List the repositories issues
Usage: n34 issue list [OPTIONS] [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
[NADDR-NIP05-OR-SET]... Repository addresses
Options:
--limit <LIMIT> Maximum number of issues to list [default: 15]

View File

@@ -9,7 +9,7 @@ Create a new repository issue
Usage: n34 issue new [OPTIONS] <--content <CONTENT>|--editor>
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
-c, --content <CONTENT> Markdown content for the issue. Cannot be used together with the `--editor` flag
-e, --editor Opens the user's default editor to write issue content. The first line will be used as the issue subject
--subject <SUBJECT> The issue subject. Cannot be used together with the `--editor` flag

View File

@@ -1,4 +1,4 @@
# Reopen a Closed Issue
# Reopen an Issue
> `n34 issue reopen` command
@@ -12,7 +12,7 @@ Arguments:
<ISSUE_ID> The ID of the closed issue to reopen
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1630` (Open status) for the specified issue. The issue have to

View File

@@ -1,4 +1,4 @@
# Resolves an Issue
# Resolve an Issue
> `n34 issue resolve` command
@@ -12,7 +12,7 @@ Arguments:
<ISSUE_ID> The issue id to resolve it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1631` (Resolved status) event for the specified issue.

View File

@@ -1,4 +1,4 @@
# View an Issue By ID
# View an Issue
> `n34 issue view` command
@@ -12,7 +12,7 @@ Arguments:
<ISSUE_ID> The issue id to view it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Simply provide the issue ID in `note` or `nevent` format to retrieve and display

View File

@@ -1,4 +1,4 @@
# Apply an Open Patch
# Mark as Applied
> `n34 patch apply` command
@@ -10,10 +10,10 @@ Usage: n34 patch apply [OPTIONS] <PATCH_ID> [APPLIED_COMMITS]...
Arguments:
<PATCH_ID> The open patch id to apply it. Must be orignal root patch or revision root
[APPLIED_COMMITS]... The applied commits
<APPLIED_COMMITS>... The applied commits
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
--patches <PATCH-EVENT-ID> Patches that have been applied. Use this when only some patches have been applied, not all
```

View File

@@ -1,4 +1,4 @@
# Closes an Open or Drafted Patch
# Close a Patch
> `n34 patch close` command
@@ -12,7 +12,7 @@ Arguments:
<PATCH_ID> The open/drafted patch id to close it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified patch. The patch have to

View File

@@ -1,4 +1,4 @@
# Draft an Open Patch
# Convert to Draft
> `n34 patch draft` command
@@ -12,7 +12,7 @@ Arguments:
<PATCH_ID> The open patch id to draft it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1633` (Draft status) for the specified patch. The patch have to

View File

@@ -1,4 +1,4 @@
# Fetch a Patch By ID
# Fetch a Patch
> `n34 patch fetch` command
@@ -12,10 +12,20 @@ Arguments:
<PATCH_ID> The patch id to fetch it
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
-o, --output <PATH> Output directory for the patches. Default to the current directory
--repo <NADDR-NIP05-OR-SET> Repository addresses
-o, --output <PATH> Directory for saving patches. Defaults to the current directory. Use `-` for stdout output
```
Fetches patches using their original patch ID. All fetched patches will be saved
to the specified output directory (current directory by default). You can then
apply or merge these patches into your branch as needed.
You can also write patches to stdout for direct application with tools like
`git-am`. For instance, to fetch and apply patches use:
```bash
n34 patch fetch -o '-' <note1...> | git am --empty=drop
```
The `--empty=drop` option ensures empty commits, such as cover letters, are
omitted.

View File

@@ -1,4 +1,4 @@
# List Repositories Patches
# List Patches
> `n34 patch list` command
@@ -9,7 +9,7 @@ List the repositories patches
Usage: n34 patch list [OPTIONS] [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
[NADDR-NIP05-OR-SET]... Repository addresses
Options:
--limit <LIMIT> Maximum number of patches to list [default: 15]

View File

@@ -1,4 +1,4 @@
# Merge an Open Patch
# Mark as Merged
> `n34 patch merge` command
@@ -13,7 +13,7 @@ Arguments:
<MERGE_COMMIT> The merge commit id
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
--patches <PATCH-EVENT-ID> Patches that have been merged. Use this when only some patches have been merged, not all
```

View File

@@ -1,4 +1,4 @@
# Reopens a Closed or Drafted Patch
# Reopen a Patch
> `n34 patch reopen` command
@@ -12,9 +12,9 @@ Arguments:
<PATCH_ID> The closed/drafted patch id to reopen it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified patch. The patch have to
Issue a kind `1630` (Open status) for the specified patch. The patch have to
be closed or drafted.

View File

@@ -9,10 +9,10 @@ Send one or more patches to a repository
Usage: n34 patch send [OPTIONS] <PATCH-PATH>...
Arguments:
<PATCH-PATH>... List of patch files to send (space separated)
<PATCH-PATH>... Space-separated list of patch files to send. Use `-` to read from stdin.
Options:
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
--original-patch <EVENT-ID> Original patch ID if this is a revision of it
```
@@ -20,3 +20,10 @@ Send your generated patches to the repositories specified using the `--repo`
option or retrieved from the `nostr-address` file. When submitting a revision
of an existing patch, include the original patch ID to ensure its correctly
referenced in your revision patch event.
You can also pass patches from stdin, retrieved from a website or directly from
`git-format-patch`. Simply use `-` as the patch path. For example:
```bash
git format-patch --stdout --base master master..HEAD | n34 patch send -
```

43
docs/pr/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Pull Request Management
Pull requests are a new addition to [NIP-34], proposed by [DanConwayDev] in
[nostr-protocol/nips#1966]. The concept is straightforward: it involves an event
that notifies you of changes on a Git server, which can be any Git server. This
event contains two key pieces of information, the Git repository URL and the
commit tip. You can add this remote to your project, check out the specified
commit, and then review or merge/rebase the changes as needed. Additionally,
there is a `PR-update` event that informs you when the previous commit tip is no
longer the head of the changes, such as when new changes are added or a rebase
occurs.
## Why Use the Commit Tip?
Using the commit tip is preferable to relying on a branch name. A branch name
simply points to a commit, so checking out a branch is effectively the same
as checking out a specific commit. However, if we use the branch name instead
of the commit, the changes can be altered by anyone, not just the pull request
author. By checking out a specific commit ID, we ensure that the changes are
exactly as intended by the pull request author and have not been tampered with
by a malicious party.
## Does `n34` Handle Git Operations?
No, `n34` does not perform any Git operations. You can use the `n34 pr view`
command to retrieve the pull request clone URL and the commit tip, allowing
you to fetch the changes manually. Similarly, `n34 pr new` and `n34 pr update`
do not interact with Git directly. They only prompt you for the clone URL and
commit tip to construct the event. For more details on the philosophy behind
`n34`, refer to the [Philosophy] section.
## Why use pull requests instead of patches?
Pull requests are useful for large changes. Most relays limit events to 60KB,
so if your changes exceed that limit (per event, not in total), consider using
pull requests. For [GRASP] servers, the limit is usually higher. Before using pull
requests, check the relays, patches are more decentralized.
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md
[DanConwayDev]: https://danconwaydev.com
[nostr-protocol/nips#1966]: https://github.com/nostr-protocol/nips/pull/1966
[Philosophy]: https://n34.dev/commands.html#philosophy
[GRASP]: https://ngit.dev/grasp

24
docs/pr/apply.md Normal file
View File

@@ -0,0 +1,24 @@
# Mark as Applied
> `n34 pr apply` command
**Usage:**
```
Usage: n34 pr apply [OPTIONS] <PR_ID> <APPLIED_COMMITS>...
Arguments:
<PR_ID> The open PR id to apply it
<APPLIED_COMMITS>... The applied commits
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Creates a kind `1631` event (Applied/Merged status) for the specified PR. The PR
must be in open status.
The `APPLIED_COMMITS` field serves to inform clients about the status of
specific commits, whether they have been applied or not. If you need to retrieve
the list of commits from a specific point (such as the tip of the master branch)
up to the `HEAD`, you can use the following Git command: `git log --pretty=%H
'origin/master..HEAD'`.

18
docs/pr/close.md Normal file
View File

@@ -0,0 +1,18 @@
# Close a Pull Request
> `n34 pr close` command
**Usage:**
```
Usage: n34 pr close [OPTIONS] <PR_ID>
Arguments:
<PR_ID> The open/draft PR id to close it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified PR. The PR have to be open
or drafted.

17
docs/pr/draft.md Normal file
View File

@@ -0,0 +1,17 @@
# Convert to Draft
> `n34 pr draft` command
**Usage:**
```
Usage: n34 pr draft [OPTIONS] <PR_ID>
Arguments:
<PR_ID> The open PR id to draft it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1633` (Draft status) for the specified PR. The PR have to
be open.

17
docs/pr/list.md Normal file
View File

@@ -0,0 +1,17 @@
# List Pull Requests
> `n34 pr list` command
**Usage:**
```
Usage: n34 pr list [OPTIONS] [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository addresses
Options:
--limit <LIMIT> Maximum number of patches to list [default: 15]
```
List the repositories pull requests. By default `n34` will look for
`nostr-address` file and extract the repositories from it.

22
docs/pr/merge.md Normal file
View File

@@ -0,0 +1,22 @@
# Mark as Merged
> `n34 pr merge` command
**Usage:**
```
Usage: n34 pr merge [OPTIONS] <PR_ID> <MERGE_COMMIT>
Arguments:
<PR_ID> The open PR id to merge it
<MERGE_COMMIT> The merge commit id
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Creates a kind `1631` event (Applied/Merged status) for the specified PR. The
PR must be in open status.
You can get the `MERGE_COMMIT` commit using `git rev-parse HEAD` command if
the merge commit in the `HEAD` or use `HEAD~n` where the `n` is the number of
commits the merge commit before the HEAD

30
docs/pr/new.md Normal file
View File

@@ -0,0 +1,30 @@
# Create a Pull Request
> `n34 pr new` command
**Usage:**
```
Usage: n34 pr new [OPTIONS] <--subject <SUBJECT>|--editor> <COMMIT> <CLONES>...
Arguments:
<COMMIT> The SHA-1 hash of the commit at the tip of the PR branch
<CLONES>... Repositories to clone for the pull request, separated by commas
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
--body <BODY> The body content of the pull request. Cannot be used together with the `--editor` flag
--subject <SUBJECT> The subject or title of the pull request. Cannot be used together with the `--editor` flag
-e, --editor Opens the user's default editor to write PR subject and body
--labels <LABELS> Labels to associate with the pull request, separated by commas
--branch <BRANCH> The branch name for the pull request
--grasp Push the pull request to the repository GRASP server
```
Submit a pull request to the repositories specified using the `--repo` option or
obtained from the `nostr-address` file.
Utilize the `--grasp` option if you intend to send the pull request to the
[GRASP] servers. Note that `n34` will not automatically send the pull request
for you but will indicate where to push your changes.
[GRASP]: https://ngit.dev/grasp

17
docs/pr/reopen.md Normal file
View File

@@ -0,0 +1,17 @@
# Reopen a Pull Request
> `n34 pr reopen` command
**Usage:**
```
Usage: n34 pr reopen [OPTIONS] <PR_ID>
Arguments:
<PR_ID> The closed/drafted patch id to reopen it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1630` (Open status) for the specified PR. The PR have to
be closed or drafted.

25
docs/pr/update.md Normal file
View File

@@ -0,0 +1,25 @@
# Update a Pull Request
> `n34 pr update` command
**Usage:**
```
Usage: n34 pr update [OPTIONS] <EVENT-ID> <COMMIT> <CLONES>...
Arguments:
<EVENT-ID> Original PR ID
<COMMIT> The SHA-1 hash of the commit at the tip of the PR branch
<CLONES>... Repositories to clone for the pull request, separated by commas
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
--grasp Push the pull request update to the repository GRASP server
```
Update an existing pull request with the new changes.
Utilize the `--grasp` option if you intend to send the pull request to the
[GRASP] servers. Note that `n34` will not automatically send the pull request
for you but will indicate where to push your changes.
[GRASP]: https://ngit.dev/grasp

18
docs/pr/view.md Normal file
View File

@@ -0,0 +1,18 @@
# View a Pull Request
> `n34 pr view` command
**Usage:**
```
Usage: n34 pr view [OPTIONS] <EVENT-ID>
Arguments:
<EVENT-ID> Pull request ID
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
View a specific pull request. This includes the pull request title, labels,
description, clone URLs, and the latest commit tip. Use this information to
fetch the changes.

View File

@@ -13,7 +13,7 @@ Arguments:
Options:
--quote-to Quote the replied-to event in the editor
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
-c, --comment <COMMENT> The comment (cannot be used with --editor)
-e, --editor Open editor to write comment (cannot be used with --content)
```

View File

@@ -16,6 +16,7 @@ Options:
-c, --clone <CLONE> URLs for cloning the repository
-m, --maintainers <MAINTAINERS> Additional maintainers of the repository (besides yourself)
-l, --label <LABEL> Labels to categorize the repository. Can be specified multiple times
--personal-fork Indicates whether the announcement is a personal fork
--force-id Skip kebab-case validation for the repository ID
--address-file If set, creates a `nostr-address` file to enable automatic address discovery by n34
```

29
docs/repo/state.md Normal file
View File

@@ -0,0 +1,29 @@
# Repository State Announcements
> `n34 repo state` command
**Usage:**
```
Repository state announcements
Usage: n34 repo state [OPTIONS] <HEAD>
Arguments:
<HEAD> Name of the repository's primary branch, such as 'master' or 'main'
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
--tags <TAGS> Tags to announce a state for, in the format `<tag-name>=<commit-id>`. Separated by comma
--branches <BRANCHES> Branches to announce a state for, in the format `<branch-name>=<commit-id>`. Separated by comma
```
This command allows you to announce your repository state, which is useful for
pushing to permissionless git repositories like the [GRASP] relay. The relay
will verify your repository state and permit pushing commits only if they match
the announced state.
To get the commit ID that a branch or tag points to, use `git rev-parse
<tag-or-branch-name>`. You can automate this process by creating a script to
generate the required input for this command.
[GRASP]: https://ngit.dev/grasp

View File

@@ -9,7 +9,7 @@ View details of a nostr git repository
Usage: n34 repo view [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
[NADDR-NIP05-OR-SET]... Repository addresses
```
This command prints repository details to standard output. If no arguments

View File

@@ -13,7 +13,7 @@ Arguments:
Options:
--set-relay <RELAYS> Optional relay to add it to the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Each set requires a unique name, provided as the final argument to the command.

View File

@@ -13,7 +13,7 @@ Arguments:
Options:
--set-relay <RELAYS> Specific relay to remove it from the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Removes an entire set, or specific repositories and relays from it.

View File

@@ -13,7 +13,7 @@ Arguments:
Options:
--set-relay <RELAYS> Add relay to the set, either as URL or set name to extract its relays. [aliases: `--sr`]
--repo <NADDR-NIP05-OR-SET> Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
--repo <NADDR-NIP05-OR-SET> Repository addresses
--override Replace existing relays/repositories instead of adding to them
```

View File

@@ -20,15 +20,12 @@
in
with pkgs;
{
# n34 development environment
devShells.default = mkShell {
packages = [
cargo-msrv
dbus
git-cliff
just
mdbook
nushell
pkg-config
taplo
];
@@ -39,6 +36,7 @@
];
};
# Build n34
packages.default =
let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
@@ -50,14 +48,6 @@
cargoLock.lockFile = ./Cargo.lock;
src = lib.cleanSource ./.;
nativeBuildInputs = [
pkg-config
];
buildInputs = [
dbus
];
meta = {
inherit (manifest) description homepage;
license = lib.licenses.gpl3Plus;

104
justfile
View File

@@ -1,104 +0,0 @@
# This justfile is for the contrbutors of this project, not for the end user.
#
# Requirements for this justfile:
# - Linux distribution
# - just (Of course) <https://github.com/casey/just>
# - cargo (For the build and tests) <https://doc.rust-lang.org/cargo/getting-started/installation.html>
# - mdbook (<https://rust-lang.github.io/mdBook>)
# - git-cliff (<https://git-cliff.org>)
# - taplo (<https://taplo.tamasfe.dev/>)
# - cargo-msrv (<https://github.com/foresterre/cargo-msrv>)
# - nushell (<https://nushell.sh>)
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "nu"]
JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n"
BOOK_DEST_DIR := "dest"
tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
{{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - by {{ commit.author.name}}{% endfor %}{% endfor %}
'''
export TZ := "UTC"
_default:
@{{JUST_EXECUTABLE}} --list-heading "{{header}}" --list
# Run the CI
ci: && msrv _done_ci
echo "🔨 Building n34..."
cargo build -q
echo "🔍 Checking code formatting..."
cargo fmt -q -- --check
RUST_LOG=none taplo fmt --check --config "./.taplo.toml" || (echo "❌Toml files is not properly formatted" && exit 1)
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
echo "🧪 Running tests..."
cargo test -q
# Check that the current MSRV is correct
msrv:
echo "🔧 Verifying MSRV..."
cargo-msrv verify
echo "✅ MSRV verification passed"
_done_ci:
echo "🎉 CI pipeline completed successfully"
# Update the changelog
[script]
changelog:
def get_hash [] { open "./CHANGELOG.md" | hash sha256 }
let old_hash = get_hash
git-cliff out> "CHANGELOG.md"
if old_hash != get_hash {
git add "CHANGELOG.md"
git commit -m "chore(changelog): Update the changelog"
print "The changes have been added to the changelog file and committed"
} else {
print "No changes have been added to the changelog"
}
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
let tag_msg = "Version {{ version }}" + (git-cliff --strip all --unreleased --body '{{ tag_change_body }}')
mut cargo_file = open "Cargo.toml"
$cargo_file.package.version = "{{ version }}"
$cargo_file | save -f "Cargo.toml"
RUST_LOG=none taplo fmt --config "./.taplo.toml"
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" out> "./CHANGELOG.md"
git add .
git commit -m "chore: Bump the version to `v{{ version }}`"
git tag -s -m $tag_msg "v{{ version }}"
git push origin master --tags
cargo publish
# Deploy the book to Github Pages
deploy:
mdbook build --dest-dir {{ BOOK_DEST_DIR }}
cd {{ BOOK_DEST_DIR }}
git init .
git checkout -B gh-pages
touch ".nojekyll"
echo "n34.dev" > "CNAME"
git add .
git commit -m "Deploy the book to github pages"
git remote add origin "git@github.com:TheAwiteb/n34-book"
git push origin gh-pages -f
cd ..
rm -fr {{ BOOK_DEST_DIR }}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -17,8 +17,9 @@
use clap::{ArgGroup, Args};
use crate::{
cli::{Cli, CliOptions, traits::CommandRunner},
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
nostr_keyring,
};
#[derive(Args, Debug)]
@@ -46,8 +47,6 @@ impl CommandRunner for KeyringArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let keyring = nostr_keyring::NostrKeyring::new(Cli::N34_KEYRING_SERVICE_NAME);
if self.enable {
options.config.keyring_secret_key = true;
} else if self.disable {
@@ -55,7 +54,7 @@ impl CommandRunner for KeyringArgs {
}
if self.reset || self.disable {
let _ = keyring.delete(Cli::USER_KEY_PAIR_ENTRY);
let _ = nostr_keyring::user::delete().await;
}
options.config.dump()

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -28,11 +28,17 @@ use crate::{
#[derive(Debug, Args)]
pub struct CloseArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open issue id to close it
issue_id: NostrEvent,

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -19,17 +19,24 @@ use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
cli::{
CliOptions,
common_commands,
traits::CommandRunner,
types::{EntityType, NaddrOrSet},
},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
#[arg(value_name = "NADDR-NIP05-OR-SET", value_delimiter = ',')]
naddrs: Option<Vec<NaddrOrSet>>,
/// Maximum number of issues to list
#[arg(long, default_value = "15")]
@@ -40,7 +47,11 @@ impl CommandRunner for ListArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
common_commands::list_patches_and_issues(options, self.naddrs, false, self.limit.into())
common_commands::list_pr_patches_and_issues::<{ EntityType::Issue as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -46,17 +46,17 @@ pub const ISSUE_ALT_PREFIX: &str = "git issue: ";
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
/// Create an issue
New(NewArgs),
/// View an issue by its ID
/// View an issue
View(ViewArgs),
/// Reopens a closed issue.
/// Reopen an issue.
Reopen(ReopenArgs),
/// Closes an open issue.
/// Closes an issue.
Close(CloseArgs),
/// Resolves an issue.
/// Resolve an issue.
Resolve(ResolveArgs),
/// List the repositories issues.
/// List issues.
List(ListArgs),
}
@@ -72,9 +72,19 @@ pub enum IssueStatus {
}
impl IssueStatus {
/// Returns all issue statuses as kinds
#[inline]
pub const fn all_kinds() -> [Kind; 3] {
[
Self::Open.kind(),
Self::Resolved.kind(),
Self::Closed.kind(),
]
}
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
pub const fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::Resolved => Kind::GitStatusApplied,

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -37,33 +37,34 @@ use crate::{
/// Arguments for the `issue new` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("issue-content")
.args(["content", "editor"])
.required(true)
),
group(
ArgGroup::new("issue-subject")
.args(["editor", "subject"])
.required(true)
)
)]
pub struct NewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[arg(short, long)]
#[arg(short, long, group = "issue-content")]
content: Option<String>,
/// Opens the user's default editor to write issue content. The first line
/// will be used as the issue subject.
#[arg(short, long)]
#[arg(short, long, group = "issue-subject", group = "issue-content")]
editor: bool,
/// The issue subject. Cannot be used together with the `--editor` flag.
#[arg(long)]
#[arg(long, group = "issue-subject")]
subject: Option<String>,
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
/// in content (#bug).
@@ -71,29 +72,6 @@ pub struct NewArgs {
label: Vec<String>,
}
impl NewArgs {
/// Returns the subject and the content of the issue. (subject, content)
pub fn issue_content(&self) -> N34Result<(Option<String>, String)> {
if let Some(content) = self.content.as_ref() {
if let Some(subject) = self.subject.as_ref() {
return Ok((Some(subject.trim().to_owned()), content.trim().to_owned()));
}
return Ok((None, content.trim().to_owned()));
}
// If the `self.content` is `None` then the `self.editor` is `true`
let file_content = utils::read_editor(None, ".md")?;
if file_content.contains('\n') {
Ok(file_content
.split_once('\n')
.map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned()))
.expect("There is a new line"))
} else {
tracing::info!("File content contains only issue body without a subject line");
Ok((None, file_content))
}
}
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
@@ -113,15 +91,29 @@ impl CommandRunner for NewArgs {
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await;
let (subject, content) = utils::subject_and_body(self.subject, self.content, ".md")?;
let event =
EventBuilder::new_git_issue(coordinates.as_slice(), content, subject, self.label)?
let content_details = if let Some(content) = &content {
Some(client.parse_content(content).await)
} else {
None
};
let event = EventBuilder::new_git_issue(
coordinates.as_slice(),
content.unwrap_or_default(),
Some(subject),
self.label,
)?
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(content_details.clone().into_tags())
.tags(
content_details
.clone()
.map(|c| c.into_tags())
.unwrap_or_default(),
)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
@@ -129,13 +121,12 @@ impl CommandRunner for NewArgs {
relays,
naddrs.extract_relays(),
utils::add_write_relays(relays_list.as_ref()),
client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
repos.extract_relays(),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
content_details.write_relays.clone().into_iter().collect(),
content_details
.map(|c| c.write_relays.into_iter().collect())
.unwrap_or_default(),
]
.concat();

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -28,11 +28,17 @@ use crate::{
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The closed issue id to reopen it
issue_id: NostrEvent,

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -28,11 +28,17 @@ use crate::{
#[derive(Debug, Args)]
pub struct ResolveArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The issue id to resolve it
issue_id: NostrEvent,

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -15,29 +15,29 @@
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use nostr::{event::Kind, filter::Filter};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{GitIssueUtils, NaddrsUtils, ReposUtils},
utils,
},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ViewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The issue id to view it
issue_id: NostrEvent,
@@ -47,47 +47,7 @@ impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
client.add_relays(&self.issue_id.relays).await;
client
.add_relays(
&client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
)
.await;
let issue = client
.fetch_event(
Filter::new()
.id(self.issue_id.event_id)
.kind(Kind::GitIssue),
)
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_subject = utils::smart_wrap(issue.extract_issue_subject(), 70);
let issue_author = client.get_username(issue.pubkey).await;
let mut issue_labels = utils::smart_wrap(&issue.extract_issue_labels(), 70);
if issue_labels.is_empty() {
issue_labels = "\n".to_owned();
} else {
issue_labels = format!("{issue_labels}\n\n")
}
println!(
"{issue_subject} - [by {issue_author}]\n{issue_labels}{}",
utils::smart_wrap(&issue.content, 80)
);
Ok(())
crate::cli::common_commands::view_pr_issue::<false>(options, self.naddrs, self.issue_id)
.await
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -20,6 +20,8 @@ pub mod config;
pub mod issue;
/// `patch` subcommands
pub mod patch;
/// `pr` subcommands
pub mod pr;
/// `reply` command
pub mod reply;
/// `repo` subcommands
@@ -40,6 +42,7 @@ use nostr_connect::client::NostrConnect;
use self::config::ConfigSubcommands;
use self::issue::IssueSubcommands;
use self::patch::PatchSubcommands;
use self::pr::PrSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
@@ -73,8 +76,8 @@ pub struct CliOptions {
#[arg(short = '7', long, group = "signer")]
pub nip07: bool,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// passed.
#[arg(short, long)]
/// passed, separated by commas.
#[arg(short, long, value_delimiter = ',')]
pub relays: Vec<RelayOrSet>,
/// Proof of Work difficulty when creatring events
#[arg(long, value_name = "DIFFICULTY")]
@@ -113,6 +116,11 @@ pub enum Commands {
#[command(subcommand)]
subcommands: PatchSubcommands,
},
/// Manage pull requests
Pr {
#[command(subcommand)]
subcommands: PrSubcommands,
},
/// Manage configuration
Config {
#[command(subcommand)]
@@ -150,7 +158,7 @@ impl CliOptions {
if let Some(ref bunker_url) = self.bunker_url {
let mut nostrconnect = NostrConnect::new(
bunker_url.clone(),
Cli::n34_keypair()?,
Cli::n34_keypair().await?,
BUNKER_TIMEOUT,
None,
)
@@ -172,8 +180,7 @@ impl CliOptions {
/// Returns an error if there are no signers
pub fn ensure_signer(&self) -> N34Result<()> {
if !self.config.keyring_secret_key && self.secret_key.is_none() && self.bunker_url.is_none()
{
if self.secret_key.is_none() && self.bunker_url.is_none() && !self.nip07 {
return Err(N34Error::SignerRequired);
}
Ok(())
@@ -185,6 +192,7 @@ impl fmt::Debug for CliOptions {
f.debug_struct("CliOptions")
.field("secret_key", &self.secret_key.as_ref().map(|_| "*******"))
.field("bunker_url", &self.bunker_url.as_ref().map(|_| "*******"))
.field("nip07", &self.nip07)
.field("relays", &self.relays)
.field("pow", &self.pow)
.field("config", &self.config)
@@ -197,6 +205,6 @@ impl CommandRunner for Commands {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
crate::run_command!(self, options, Repo Issue Sets Patch Config & Reply)
crate::run_command!(self, options, Repo Issue Sets Patch Pr Config & Reply)
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -17,23 +17,28 @@
use clap::Args;
use nostr::hashes::sha1::Hash as Sha1Hash;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ApplyArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to apply it. Must be orignal root patch or
/// revision root
@@ -42,18 +47,18 @@ pub struct ApplyArgs {
/// been applied, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
applied_patches: Vec<NostrEvent>,
/// The applied commits
#[arg(num_args = 1..)]
/// The applied commits.
#[arg(required = true)]
applied_commits: Vec<Sha1Hash>,
}
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
PatchPrStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
self.applied_patches.into_event_ids(),
|patch_status| {
@@ -70,9 +75,7 @@ impl CommandRunner for ApplyArgs {
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't apply a drafted patch".to_owned(),
));
return Err(N34Error::InvalidStatus("Cannot apply a draft".to_owned()));
}
Ok(())
},

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -16,23 +16,28 @@
use clap::Args;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open/drafted patch id to close it. Must be orignal root patch
patch_id: NostrEvent,
@@ -40,11 +45,11 @@ pub struct CloseArgs {
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchStatus::Closed,
PatchPrStatus::Closed,
None,
Vec::new(),
|patch_status| {

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -16,23 +16,28 @@
use clap::Args;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct DraftArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to draft it. Must be orignal root patch
patch_id: NostrEvent,
@@ -40,17 +45,17 @@ pub struct DraftArgs {
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchStatus::Draft,
PatchPrStatus::Draft,
None,
Vec::new(),
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already drafted patch".to_owned(),
"You can't draft an already draft patch".to_owned(),
));
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -14,11 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
fs,
path::{Path, PathBuf},
str::FromStr,
};
use std::{fs, ops::Deref, path::PathBuf, str::FromStr};
use clap::Args;
use nostr::{
@@ -43,13 +39,20 @@ use crate::{
#[derive(Debug, Args)]
pub struct FetchArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Output directory for the patches. Default to the current directory
/// Directory for saving patches. Defaults to the current directory. Use `-`
/// for stdout output.
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
/// The patch id to fetch it
@@ -67,6 +70,10 @@ impl CommandRunner for FetchArgs {
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let output_path = self.output.unwrap_or_default();
let (is_stdout, is_current_dir) = {
let str_path = output_path.to_string_lossy();
(str_path.deref() == "-", str_path.deref().is_empty())
};
client
.add_relays(
@@ -111,7 +118,7 @@ impl CommandRunner for FetchArgs {
.into_iter()
.map(|p| {
let patch = super::GitPatch::from_str(&p.content).map_err(|err| {
N34Error::InvalidEvent(format!(
N34Error::InvalidPatch(format!(
"Failed to parse the patch `{}`: {err}",
p.id.to_bech32().expect("Infallible")
))
@@ -123,14 +130,30 @@ impl CommandRunner for FetchArgs {
patches.sort_unstable_by_key(|p| p.0.clone());
patches.dedup_by_key(|p| p.0.clone());
if output_path.as_path() != Path::new("") && !output_path.exists() {
if !is_stdout && !is_current_dir && !output_path.exists() {
fs::create_dir_all(&output_path)?;
}
if is_stdout {
tracing::info!(
"Writeing {} patch{} to the stdout",
patches.len(),
if patches.len() >= 2 { "es" } else { "" }
);
println!(
"{}",
patches
.into_iter()
.map(|(_, patch)| patch.inner)
.collect::<Vec<String>>()
.join("\n")
);
} else {
for (patch_path, patch) in patches {
tracing::info!("Writeing `{}` in `{}`", patch.subject, patch_path.display());
fs::write(patch_path, patch.inner)?;
}
}
Ok(())
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -19,17 +19,24 @@ use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
cli::{
CliOptions,
common_commands,
traits::CommandRunner,
types::{EntityType, NaddrOrSet},
},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
#[arg(value_name = "NADDR-NIP05-OR-SET", value_delimiter = ',')]
naddrs: Option<Vec<NaddrOrSet>>,
/// Maximum number of patches to list
#[arg(long, default_value = "15")]
@@ -40,7 +47,11 @@ impl CommandRunner for ListArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
common_commands::list_patches_and_issues(options, self.naddrs, true, self.limit.into())
common_commands::list_pr_patches_and_issues::<{ EntityType::Patch as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -17,23 +17,28 @@
use clap::Args;
use nostr::hashes::sha1::Hash as Sha1Hash;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to merge it. Must be orignal root patch or
/// revision root
@@ -48,17 +53,17 @@ pub struct MergeArgs {
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
PatchPrStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
self.merged_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged patch".to_owned(),
"You can't merge an already merged/applied patch".to_owned(),
));
}
@@ -70,7 +75,7 @@ impl CommandRunner for MergeArgs {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a drafted patch".to_owned(),
"You can't merge a draft patch".to_owned(),
));
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -34,14 +34,12 @@ mod send;
mod tests;
use std::{
fmt,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand;
use nostr::event::Kind;
use regex::Regex;
use self::apply::ApplyArgs;
@@ -55,6 +53,11 @@ use self::send::SendArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
/// Regular expression for checking the first line in the patch.
pub static FROM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^From [a-f0-9]{40} \w+ \w+ \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$").unwrap()
});
/// Regular expression for extracting the patch subject.
static SUBJECT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
@@ -79,21 +82,21 @@ pub const LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT: &str = "revision-root";
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
/// Send one or more patches to a repository.
/// Send patches to a repository
Send(SendArgs),
/// Fetches a patch by its id.
/// Fetch a patch
Fetch(FetchArgs),
/// Closes an open or drafted patch.
/// Close a patch
Close(CloseArgs),
/// Converts an open patch to draft state.
/// Convert to draft
Draft(DraftArgs),
/// Reopens a closed or drafted patch.
/// Reopen a patch
Reopen(ReopenArgs),
/// Set an open patch status to applied.
/// Mark as applied
Apply(ApplyArgs),
/// Set an open patch status to merged.
/// Mark as merged
Merge(MergeArgs),
/// List the repositories patches.
/// List patches
List(ListArgs),
}
@@ -108,92 +111,6 @@ pub struct GitPatch {
pub body: String,
}
#[derive(Debug)]
pub enum PatchStatus {
/// The patch is currently open
Open,
/// The patch has been merged/applied
MergedApplied,
/// The patch has been closed
Closed,
/// A patch that has been drafted but not yet applied.
Draft,
}
impl PatchStatus {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::MergedApplied => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
Self::Draft => Kind::GitStatusDraft,
}
}
/// Returns the string representation of the patch status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::MergedApplied => "Merged/Applied",
Self::Closed => "Closed",
Self::Draft => "Draft",
}
}
/// Check if the patch is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the patch is merged/applied.
#[inline]
pub fn is_merged_or_applied(&self) -> bool {
matches!(self, Self::MergedApplied)
}
/// Check if the patch is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
/// Check if the patch is drafted
#[inline]
pub fn is_drafted(&self) -> bool {
matches!(self, Self::Draft)
}
}
impl From<&PatchStatus> for Kind {
fn from(status: &PatchStatus) -> Self {
status.kind()
}
}
impl fmt::Display for PatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for PatchStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::MergedApplied),
Kind::GitStatusClosed => Ok(Self::Closed),
Kind::GitStatusDraft => Ok(Self::Draft),
_ => Err(N34Error::InvalidPatchStatus(kind)),
}
}
}
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
@@ -226,6 +143,14 @@ impl FromStr for GitPatch {
type Err = String;
fn from_str(patch_content: &str) -> Result<Self, Self::Err> {
if !patch_content
.split("\n")
.next()
.is_some_and(|line| FROM_RE.is_match(line))
{
return Err("The first line must start with 'From '.".to_owned());
}
// Regex for subject (handles multi-line subjects)
let subject = SUBJECT_RE
.captures(patch_content)

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -16,23 +16,28 @@
use clap::Args;
use super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The closed/drafted patch id to reopen it. Must be orignal root patch
patch_id: NostrEvent,
@@ -40,11 +45,11 @@ pub struct ReopenArgs {
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchStatus::Open,
PatchPrStatus::Open,
None,
Vec::new(),
|patch_status| {

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -31,7 +31,7 @@ use crate::{
cli::{
CliOptions,
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt, VecPatchesExt},
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
@@ -47,18 +47,24 @@ const PATCH_ALT_PREFIX: &str = "git patch: ";
#[derive(Args, Debug)]
pub struct SendArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// List of patch files to send (space separated).
/// Space-separated list of patch files to send. Use `-` to read from stdin.
///
/// For p-tagging users, include them in the cover letter with
/// For p-tagging users, include them in the cover letter using
/// `nostr:npub1...`.
#[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
patches: Vec<GitPatch>,
patches: Vec<Option<GitPatch>>,
/// Original patch ID if this is a revision of it
#[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>,
@@ -71,6 +77,7 @@ impl CommandRunner for SendArgs {
&utils::nostr_address_path()?,
)?)?;
let patches = self.patches.process_patches().await?;
let repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
@@ -91,7 +98,7 @@ impl CommandRunner for SendArgs {
let (events, events_write_relays) = make_patch_series(
&client,
self.patches,
patches,
self.original_patch.as_ref().map(|e| e.event_id),
repos.extract_relays().first().cloned(),
repo_coordinates,
@@ -139,11 +146,20 @@ impl CommandRunner for SendArgs {
}
}
fn parse_patch_path(patch_path: &str) -> Result<GitPatch, String> {
/// If the patch path is '-', it indicates that patches will be retrieved from
/// stdin. Otherwise, it reads and parses the patch file from the specified
/// path.
fn parse_patch_path(patch_path: &str) -> Result<Option<GitPatch>, String> {
if patch_path == "-" {
tracing::info!("Reading patches from standard input");
return Ok(None);
}
tracing::debug!("Parsing patch file `{patch_path}`");
let patch_content = fs::read_to_string(patch_path)
.map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?;
GitPatch::from_str(&patch_content)
GitPatch::from_str(&patch_content).map(Option::Some)
}
async fn make_patch_series(

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -0,0 +1,82 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use nostr::hashes::sha1::Hash as Sha1Hash;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ApplyArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to apply it.
pr_id: NostrEvent,
/// The applied commits.
#[arg(required = true)]
applied_commits: Vec<Sha1Hash>,
}
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
Vec::new(),
|pr_status| {
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't apply an already applied pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't apply a closed pull request".to_owned(),
));
}
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"Cannot apply a draft pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,72 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open/draft PR id to close it.
pr_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Closed,
None,
Vec::new(),
|pr_status| {
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't close a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,79 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct DraftArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to draft it.
pr_id: NostrEvent,
}
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Draft,
None,
Vec::new(),
|pr_status| {
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already draft pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't draft a closed pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't draft a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,58 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{
CliOptions,
common_commands,
traits::CommandRunner,
types::{EntityType, NaddrOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ListArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", value_delimiter = ',')]
naddrs: Option<Vec<NaddrOrSet>>,
/// Maximum number of patches to list
#[arg(long, default_value = "15")]
limit: NonZeroUsize,
}
impl CommandRunner for ListArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
common_commands::list_pr_patches_and_issues::<{ EntityType::PullRequest as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -0,0 +1,82 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use nostr::hashes::sha1::Hash as Sha1Hash;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to merge it.
pr_id: NostrEvent,
/// The merge commit id
merge_commit: Sha1Hash,
}
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
Vec::new(),
|pr_status| {
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged/applied pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't merge a closed pull request".to_owned(),
));
}
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a draft pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,84 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `pr apply` suubcommand
mod apply;
/// `pr close` subcommand
mod close;
/// `pr draft` subcommand
mod draft;
/// `pr list` subcommand
mod list;
/// `pr merge` subcommand
mod merge;
/// `pr new` subcommand
mod new;
/// `pr reopen` subcommand
mod reopen;
/// `pr update` subcommand
mod update;
/// `pr view` subcommand
mod view;
use clap::Subcommand;
use self::apply::ApplyArgs;
use self::close::CloseArgs;
use self::draft::DraftArgs;
use self::list::ListArgs;
use self::merge::MergeArgs;
use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::update::UpdateArgs;
use self::view::ViewArgs;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
};
/// The kind of the pull request
pub const PR_KIND: nostr::event::Kind = nostr::event::Kind::Custom(1618);
/// The kind of the pull request update
pub const PR_UPDATE_KIND: nostr::event::Kind = nostr::event::Kind::Custom(1619);
#[derive(Subcommand, Debug)]
pub enum PrSubcommands {
/// Create a pull request.
New(NewArgs),
/// Update a pull request.
Update(UpdateArgs),
/// View a pull request.
View(ViewArgs),
/// List pull requests.
List(ListArgs),
/// Close a pull request.
Close(CloseArgs),
/// Convert to draft.
Draft(DraftArgs),
/// Reopen pull request.
Reopen(ReopenArgs),
/// Mark as applied.
Apply(ApplyArgs),
/// Merge a pull request.
Merge(MergeArgs),
}
impl CommandRunner for PrSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & New Update View List Close Draft Reopen Apply Merge)
}
}

194
src/cli/commands/pr/new.rs Normal file
View File

@@ -0,0 +1,194 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::iter;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, Tag, TagKind, TagStandard},
filter::Alphabet,
hashes::sha1::Hash as Sha1Hash,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("pr-subject")
.required(true)
),
group(
ArgGroup::new("clone-or-grasp")
.required(true)
)
)]
pub struct NewArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The body content of the pull request. Cannot be used together with the
/// `--editor` flag.
#[arg(long, group = "pr-body")]
body: Option<String>,
/// The subject or title of the pull request. Cannot be used together with
/// the `--editor` flag.
#[arg(long, group = "pr-subject")]
subject: Option<String>,
/// Opens the user's default editor to write PR subject and body.
///
/// The first line will be used as the issue subject.
#[arg(short, long, group = "pr-subject", group = "pr-body")]
editor: bool,
/// Labels to associate with the pull request, separated by commas.
#[arg(long, value_delimiter = ',')]
labels: Vec<String>,
/// The branch name for the pull request.
#[arg(long)]
branch: Option<String>,
/// Push the pull request to the repository GRASP server.
#[arg(long, group = "clone-or-grasp")]
grasp: bool,
/// The SHA-1 hash of the commit at the tip of the PR branch.
///
/// You can get it using `git rev-parse <branch-name>`
commit: Sha1Hash,
/// Repositories to clone for the pull request, separated by commas.
#[arg(value_delimiter = ',', group = "clone-or-grasp")]
clones: Vec<nostr::Url>,
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let naddrs_relays = naddrs.extract_relays();
client.add_relays(&naddrs_relays).await;
let coordinates = naddrs.into_coordinates();
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
let repos_relays = repos.extract_relays();
client.add_relays(&repos_relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let (subject, body) = utils::subject_and_body(self.subject, self.body, ".md")?;
let body_details = if let Some(body) = &body {
Some(client.parse_content(body).await)
} else {
None
};
let mut event_builder = EventBuilder::new(super::PR_KIND, body.unwrap_or_default())
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, repos_relays.first().cloned())),
)
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(
body_details
.clone()
.map(|c| c.into_tags())
.unwrap_or_default(),
)
.tag(Tag::from_standardized_without_cell(TagStandard::Subject(
subject,
)))
.tags(self.labels.into_iter().map(Tag::hashtag))
.tag(Tag::custom(
TagKind::single_letter(Alphabet::C, false),
iter::once(self.commit.to_string()),
));
if let Some(euc) = repos.extract_euc() {
event_builder = event_builder.tag(Tag::reference(euc.to_string()))
}
if let Some(branch) = self.branch {
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("branch-name"),
iter::once(branch),
));
}
let event = if self.grasp {
utils::build_grasp_event(&repos, user_pubk, event_builder.clone())?
} else {
// Since `grasp` is false, `clones` must be provided
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("clone"),
self.clones.iter().map(ToString::to_string),
));
event_builder.build(user_pubk)
};
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
repos_relays,
naddrs_relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
body_details
.map(|c| c.write_relays.into_iter().collect())
.unwrap_or_default(),
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Pull request created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,73 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent, PatchPrStatus},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The closed/drafted patch id to reopen it.
pr_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Open,
None,
Vec::new(),
|pr_status| {
if pr_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't open an already open pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't open a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,178 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::iter;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, Tag, TagKind, TagStandard, Tags},
filter::{Alphabet, Filter},
hashes::sha1::Hash as Sha1Hash,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("clone-or-grasp")
.required(true)
)
)]
pub struct UpdateArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Original PR ID
#[arg(value_name = "EVENT-ID")]
original_pr: NostrEvent,
/// Push the pull request update to the repository GRASP server.
#[arg(long, group = "clone-or-grasp")]
grasp: bool,
/// The SHA-1 hash of the commit at the tip of the PR branch.
///
/// You can get it using `git rev-parse <branch-name>`
commit: Sha1Hash,
/// Repositories to clone for the pull request, separated by commas.
#[arg(value_delimiter = ',', group = "clone-or-grasp")]
clones: Vec<nostr::Url>,
}
impl CommandRunner for UpdateArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let naddrs_relays = naddrs.extract_relays();
client.add_relays(&naddrs_relays).await;
let coordinates = naddrs.into_coordinates();
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
let repos_relays = repos.extract_relays();
client.add_relays(&repos_relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let Some(orignal_pr) = client
.fetch_event(
Filter::new()
.id(self.original_pr.event_id)
.kind(super::PR_KIND),
)
.await?
else {
tracing::error!("Can't find the original pull request");
return Err(N34Error::EventNotFound);
};
// TODO: Use `CommentTarget` to mention the orignal PR
let mut nip22_orignal_pr = Tags::new();
nip22_orignal_pr.push(Tag::from_standardized_without_cell(TagStandard::Event {
event_id: orignal_pr.id,
relay_url: None,
marker: None,
public_key: Some(orignal_pr.pubkey),
uppercase: true,
}));
nip22_orignal_pr.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: orignal_pr.pubkey,
relay_url: None,
alias: None,
uppercase: true,
},
));
nip22_orignal_pr.push(Tag::from_standardized_without_cell(TagStandard::Kind {
kind: orignal_pr.kind,
uppercase: true,
}));
let mut event_builder = EventBuilder::new(super::PR_UPDATE_KIND, "")
.pow(options.pow.unwrap_or_default())
.tags(nip22_orignal_pr)
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, repos_relays.first().cloned())),
)
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tag(Tag::custom(
TagKind::single_letter(Alphabet::C, false),
iter::once(self.commit.to_string()),
));
let event = if self.grasp {
utils::build_grasp_event(&repos, user_pubk, event_builder.clone())?
} else {
// Since `grasp` is false, `clones` must be provided
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("clone"),
self.clones.iter().map(ToString::to_string),
));
event_builder.build(user_pubk)
};
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
repos_relays,
naddrs_relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("PR update tip created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,54 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Pull request ID
#[arg(value_name = "EVENT-ID")]
pr_id: NostrEvent,
}
impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::view_pr_issue::<true>(options, self.naddrs, self.pr_id).await
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -62,11 +62,17 @@ pub struct ReplyArgs {
/// Quote the replied-to event in the editor
#[arg(long)]
quote_to: bool,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The comment (cannot be used with --editor)
#[arg(short, long)]

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -18,7 +18,11 @@ use std::{fs, io::Write};
use clap::Args;
use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use nostr::{
event::{EventBuilder, Tag},
key::PublicKey,
types::Url,
};
use crate::{
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, traits::RelayOrSetVecExt},
@@ -43,6 +47,7 @@ const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr`
# Empty lines are ignored. Lines starting with "#" are treated as comments.
"##;
const PERSONAL_FORK_HASHTAG: &str = "personal-fork";
/// Arguments for the `repo announce` command
#[derive(Args, Debug)]
@@ -71,6 +76,9 @@ pub struct AnnounceArgs {
/// Skip kebab-case validation for the repository ID
#[arg(long)]
force_id: bool,
/// Indicates whether the announcement is a personal fork.
#[arg(long)]
personal_fork: bool,
/// If set, creates a `nostr-address` file to enable automatic address
/// discovery by n34
#[arg(long)]
@@ -94,7 +102,7 @@ impl CommandRunner for AnnounceArgs {
}
let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
let event = EventBuilder::new_git_repo(
let mut event_builder = EventBuilder::new_git_repo(
self.repo_id,
self.name.map(utils::str_trim),
self.description.map(utils::str_trim),
@@ -106,9 +114,13 @@ impl CommandRunner for AnnounceArgs {
self.force_id,
)?
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.build(user_pubk);
.pow(options.pow.unwrap_or_default());
if self.personal_fork {
event_builder = event_builder.tag(Tag::hashtag(PERSONAL_FORK_HASHTAG));
}
let event = event_builder.build(user_pubk);
if self.address_file {
let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE);

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -16,12 +16,15 @@
/// `repo announce` subcommand
mod announce;
/// `repo state` subcommand
mod state;
/// `repo view` subcommand
mod view;
use clap::Subcommand;
use self::announce::AnnounceArgs;
use self::state::StateArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
@@ -32,10 +35,12 @@ pub enum RepoSubcommands {
View(ViewArgs),
/// Broadcast and update a git repository
Announce(AnnounceArgs),
/// Repository state announcements
State(StateArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & View Announce)
crate::run_command!(self, options, & View Announce State)
}
}

View File

@@ -0,0 +1,155 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::borrow::Cow;
use clap::Args;
use nostr::event::{Kind, Tag, TagKind};
use nostr::{event::EventBuilder, hashes::sha1::Hash as Sha1Hash};
use crate::nostr_utils::traits::ReposUtils;
use crate::{
cli::{
CliOptions,
CommandRunner,
parsers,
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Prefix for branch references in Git.
const HEADS_REFS: &str = "refs/heads/";
/// Prefix for tag references in Git.
const TAGS_REFS: &str = "refs/tags/";
/// Repository state announcements kind
const REPO_STATE_KIND: Kind = Kind::Custom(30618);
/// `HEAD` tag kind
const HEAD_TAG_KIND: TagKind = TagKind::Custom(Cow::Borrowed("HEAD"));
/// Arguments for the `repo state` command
#[derive(Args, Debug)]
pub struct StateArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Tags to announce a state for, in the format `<tag-name>=<commit-id>`.
/// Separated by comma.
///
/// Example: `v0.4.0=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`.
#[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)]
tags: Vec<(String, Sha1Hash)>,
/// Branches to announce a state for, in the format
/// `<branch-name>=<commit-id>`. Separated by comma.
///
/// Example: `master=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`.
#[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)]
branches: Vec<(String, Sha1Hash)>,
/// Name of the repository's primary branch, such as 'master' or 'main'.
head: String,
}
impl CommandRunner for StateArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client.add_relays(&naddrs.extract_relays()).await;
let repos = client
.fetch_repos(&naddrs.clone().into_coordinates())
.await?;
let repos_id = repos
.first()
.cloned()
.expect("It's not empty, checked above")
.id;
let mut event_builder = EventBuilder::new(REPO_STATE_KIND, "")
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tag(Tag::identifier(&repos_id))
.tag(Tag::custom(
HEAD_TAG_KIND,
&[format!("ref: {HEADS_REFS}{}", self.head)],
));
if !self.branches.is_empty() {
event_builder = event_builder.tags(refs_tags::<true>(self.branches));
}
if !self.tags.is_empty() {
event_builder = event_builder.tags(refs_tags::<false>(self.tags));
}
let event = event_builder.build(user_pubk);
let event_id = event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
// Include read relays for each maintainer (if found)
client
.read_relays_from_users(&repos.extract_maintainers())
.await,
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
let naddr = utils::repo_naddr(repos_id, user_pubk, &success)?;
println!("Event created: {nevent}");
println!("State address: {naddr}");
Ok(())
}
}
/// Build the refs tags
#[inline]
fn refs_tags<const IS_HEADS: bool>(refs: Vec<(String, Sha1Hash)>) -> impl IntoIterator<Item = Tag> {
refs.into_iter().map(|(tag, commit)| {
Tag::parse(&[
format!("{}{tag}", if IS_HEADS { HEADS_REFS } else { TAGS_REFS }),
commit.to_string(),
])
.expect("Not an empty tag")
})
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -14,8 +14,6 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::fmt;
use clap::Args;
use nostr::nips::nip19::ToBech32;
@@ -33,11 +31,13 @@ use crate::{
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
#[arg(value_name = "NADDR-NIP05-OR-SET", value_delimiter = ',')]
naddrs: Option<Vec<NaddrOrSet>>,
}
@@ -66,13 +66,16 @@ impl CommandRunner for ViewArgs {
repo_details.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
repo_details.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
repo_details.push_str(&format!("\nWebpages:\n{}", utils::format_iter(repo.web)));
}
if !repo.clone.is_empty() {
repo_details.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
repo_details.push_str(&format!(
"\nClone urls:\n{}",
utils::format_iter(repo.clone)
));
}
if !repo.relays.is_empty() {
repo_details.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
repo_details.push_str(&format!("\nRelays:\n{}", utils::format_iter(repo.relays)));
}
if let Some(euc) = repo.euc {
repo_details.push_str(&format!("\nEarliest unique commit: {euc}"));
@@ -80,7 +83,7 @@ impl CommandRunner for ViewArgs {
if !repo.maintainers.is_empty() {
repo_details.push_str(&format!(
"\nMaintainers:\n{}",
format_list(
utils::format_iter(
repo.maintainers
.iter()
.map(|p| p.to_bech32().expect("Infallible"))
@@ -94,16 +97,3 @@ impl CommandRunner for ViewArgs {
Ok(())
}
}
/// Format a vector to print it
fn format_list<I, T>(iterator: I) -> String
where
I: IntoIterator<Item = T>,
T: fmt::Display,
{
iterator
.into_iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<String>>()
.join("\n")
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -31,12 +31,18 @@ pub struct NewArgs {
/// Unique name for the set
name: String,
/// Optional relay to add it to the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
/// extract its relays, separated by commas. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"), value_delimiter = ',')]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Vec<NaddrOrSet>,
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -30,13 +30,19 @@ pub struct RemoveArgs {
/// Set name to delete
name: String,
/// Specific relay to remove it from the set, either as URL or set name to
/// extract its relays. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
/// extract its relays, separated by commas. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"), value_delimiter = ',')]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Vec<NaddrOrSet>,
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -31,13 +31,19 @@ use crate::{
pub struct UpdateArgs {
/// Name of the set to update
name: String,
/// Add relay to the set, either as URL or set name to extract its relays.
/// [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"))]
/// Add relay to the set, either as URL or set name to extract its relays,
/// separated by commas. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"), value_delimiter = ',')]
relays: Vec<RelayOrSet>,
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Vec<NaddrOrSet>,
/// Replace existing relays/repositories instead of adding to them
#[arg(long = "override")]

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -19,8 +19,8 @@ use std::{iter, str::FromStr, sync::Arc};
use either::Either;
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind},
filter::Filter,
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard},
filter::{Alphabet, Filter, SingleLetterTag},
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
types::RelayUrl,
@@ -28,17 +28,19 @@ use nostr::{
use super::{
issue::IssueStatus,
patch::PatchStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
cli::{CliOptions, patch::GitPatch, types::PatchPrStatus},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssuePrMetadata, GitPatchUtils, ReposUtils},
};
use crate::{
cli::{CliOptions, patch::GitPatch},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssueUtils, GitPatchUtils, ReposUtils},
cli::{
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::EntityType,
},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Updates the issue's status to `new_status` after validating it with
@@ -123,17 +125,20 @@ pub async fn issue_status_command(
Ok(())
}
/// Updates the patch's status to `new_status` after validating it with
/// `check_fn`.
pub async fn patch_status_command(
/// Updates the patch/pr's status to `new_status` after validating it with
/// `check_fn`. The `ENTITY_TYPE` can only be a pull request or a patch
pub async fn patch_pr_status_command<const ENTITY_TYPE: u8>(
options: CliOptions,
patch_id: NostrEvent,
patch_pr_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: PatchStatus,
new_status: PatchPrStatus,
merge_or_applied_commits: Option<Either<Sha1Hash, Vec<Sha1Hash>>>,
merge_or_applied_patches: Vec<EventId>,
check_fn: impl FnOnce(&PatchStatus) -> N34Result<()>,
check_fn: impl FnOnce(&PatchPrStatus) -> N34Result<()>,
) -> N34Result<()> {
EntityType::is_pr_or_patch::<ENTITY_TYPE>();
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
@@ -142,7 +147,7 @@ pub async fn patch_status_command(
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
.add_relays(&[naddrs.extract_relays(), patch_pr_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
@@ -152,9 +157,14 @@ pub async fn patch_status_command(
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let patch_event = client.fetch_patch(patch_id.event_id).await?;
let event = if entity_type.is_patch() {
client.fetch_patch(patch_pr_id.event_id).await?
} else {
client.fetch_pr(patch_pr_id.event_id).await?
};
let authorized_pubkeys = [maintainers.as_slice(), &[event.pubkey], &owners].concat();
if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
if entity_type.is_patch() && event.is_revision_patch() && !new_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
'close', and 'draft' are not supported."
@@ -162,27 +172,31 @@ pub async fn patch_status_command(
));
}
let (root_patch, root_revision) = get_patch_root_revision(&patch_event)?;
let patch_status = client
.fetch_patch_status(
root_patch,
root_revision,
[maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
)
.await?;
let (root_patch_or_pr, root_revision) = if entity_type.is_patch() {
get_patch_root_revision(&event)?
} else {
(event.id, None)
};
let current_status = if entity_type.is_patch() {
client
.fetch_patch_status(root_patch_or_pr, root_revision, authorized_pubkeys.clone())
.await?
} else {
client
.fetch_pr_status(event.id, authorized_pubkeys.clone())
.await?
};
check_fn(&patch_status)?;
check_fn(&current_status)?;
let mut status_builder = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&root_patch,
&root_patch_or_pr,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(patch_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(authorized_pubkeys.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
@@ -211,6 +225,7 @@ pub async fn patch_status_command(
.tags(commits.into_iter().map(Tag::reference));
};
// Patch only
if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
@@ -219,6 +234,7 @@ pub async fn patch_status_command(
));
}
// Patch only
if !merge_or_applied_patches.is_empty() {
status_builder = status_builder.tags(
build_patches_quote(client.clone(), relay_hint.clone(), merge_or_applied_patches)
@@ -228,7 +244,6 @@ pub async fn patch_status_command(
}
let status_event = status_builder.dedup_tags().build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
@@ -236,10 +251,7 @@ pub async fn patch_status_command(
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(patch_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
client.read_relays_from_users(&authorized_pubkeys).await,
]
.concat();
@@ -252,13 +264,14 @@ pub async fn patch_status_command(
Ok(())
}
/// Fetch and display patches and issues for given repositories.
/// If `list_patches` is true, lists patches instead of issues.
/// `limit` controls the maximum number of items to fetch.
pub async fn list_patches_and_issues(
/// Fetches and displays pull requests, patches, and issues for specified
/// repositories. The `limit` parameter sets the maximum number of items to
/// retrieve.
///
/// The `ENTITY_TYPE` const is `[EntityType]` enum as u8.
pub async fn list_pr_patches_and_issues<const ENTITY_TYPE: u8>(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
list_patches: bool,
limit: usize,
) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
@@ -266,6 +279,7 @@ pub async fn list_patches_and_issues(
&utils::nostr_address_path()?,
)?)?;
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
@@ -279,18 +293,12 @@ pub async fn list_patches_and_issues(
.add_relays(&client.read_relays_from_users(&authorized_pubkeys).await)
.await;
let kind = if list_patches {
Kind::GitPatch
} else {
Kind::GitIssue
};
let mut filter = Filter::new()
.coordinates(coordinates.iter())
.kind(kind)
.kind(entity_type.kind())
.limit(limit);
if list_patches {
if entity_type.is_patch() {
filter = filter.hashtag("root");
}
@@ -302,13 +310,21 @@ pub async fn list_patches_and_issues(
arc_client
.fetch_events(filter)
.await?
.into_iter()
.take(limit)
.map(|event| {
let c = arc_client.clone();
let keys = authorized_pubkeys.clone();
async move {
let status = if list_patches {
let status = match entity_type {
EntityType::PullRequest => {
c.fetch_pr_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
EntityType::Patch => {
let (root, root_revision) = get_patch_root_revision(&event)?;
c.fetch_patch_status(
root,
@@ -316,15 +332,18 @@ pub async fn list_patches_and_issues(
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Left)?
} else {
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
EntityType::Issue => {
c.fetch_issue_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Right)?
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
};
N34Result::Ok((event, status))
}
}),
@@ -332,11 +351,11 @@ pub async fn list_patches_and_issues(
.await
.into_iter()
.filter_map(|r| r.ok()),
|(_, status)| status.as_ref().either_into::<Kind>(),
|(_, (_, k))| *k,
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.map(|(event, (status, _))| format_entity::<ENTITY_TYPE>(&event, status))
.collect::<Vec<String>>();
let max_width = lines
@@ -366,11 +385,14 @@ fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option<Ev
}
}
/// Formats an event as either a patch or an issue. For patches, extracts the
/// subject line from the Git patch format. For issues, combines the subject
/// with labels. The output includes status and formatted ID.
fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus>) -> String {
let subject = if status.is_left() {
/// Formats patch, issue or PR. For patches, extracts the
/// subject line from the Git patch format. For issues and PRs, combines the
/// subject with labels. The output includes status and formatted ID.
fn format_entity<const ENTITY_TYPE: u8>(event: &Event, status: &str) -> String {
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
let subject = match entity_type {
EntityType::Patch => {
GitPatch::from_str(&event.content)
.map(|p| p.subject)
.unwrap_or_else(|_| {
@@ -383,16 +405,20 @@ fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus
.trim_start_matches("Subject: ")
.to_owned()
})
} else {
let labels = event.extract_issue_labels();
let subject = event.extract_issue_subject();
}
_ => {
// Issues and PRs
let labels = event.extract_event_labels();
let subject = event.extract_event_subject();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
}
}
};
format!(
"({status}) {}\nID: {}\n",
utils::smart_wrap(&subject, 85),
@@ -436,3 +462,115 @@ async fn build_patches_quote(
}))
.await
}
pub async fn view_pr_issue<const IS_PR: bool>(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
event_id: NostrEvent,
) -> N34Result<()> {
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
client.add_relays(&event_id.relays).await;
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
client.add_relays(&repos.extract_relays()).await;
let event = client
.fetch_event(Filter::new().id(event_id.event_id).kind(
const {
if IS_PR {
crate::cli::pr::PR_KIND
} else {
Kind::GitIssue
}
},
))
.await?
.ok_or(
const {
if IS_PR {
N34Error::CanNotFoundPr
} else {
N34Error::CanNotFoundIssue
}
},
)?;
let authorized_pubkeys = [repos.extract_maintainers().as_slice(), &[event.pubkey]].concat();
let status = if IS_PR {
client
.fetch_pr_status(event.id, authorized_pubkeys)
.await?
.to_string()
} else {
client
.fetch_issue_status(event.id, authorized_pubkeys)
.await?
.to_string()
};
let event_subject = utils::smart_wrap(event.extract_event_subject(), 70);
let event_author = client.get_username(event.pubkey).await;
let mut event_labels = utils::smart_wrap(&event.extract_event_labels(), 70);
if event_labels.is_empty() {
event_labels = "\n".to_owned();
} else {
event_labels = format!("{event_labels}\n\n")
}
let pr_data = if IS_PR {
// Check if there is an update
let pr_update = client
.fetch_event(
Filter::new()
.kind(crate::cli::pr::PR_UPDATE_KIND)
.custom_tag(SingleLetterTag::uppercase(Alphabet::E), event.id)
.author(event.pubkey),
)
.await?;
let commit = pr_update
.as_ref()
.unwrap_or(&event)
.tags
.find(TagKind::single_letter(Alphabet::C, false))
.and_then(|t| t.content())
.unwrap_or("N/A");
let branch = event
.tags
.find(TagKind::custom("branch-name"))
.and_then(|t| t.content())
.unwrap_or("N/A");
let clones = pr_update
.as_ref()
.unwrap_or(&event)
.tags
.iter()
.filter_map(|t| t.as_standardized())
.find_map(|t| {
match t {
TagStandard::GitClone(urls) if !urls.is_empty() => Some(urls.clone()),
_ => None,
}
})
.unwrap_or_default();
format!(
"\nCommit: {commit}\nBranch: {branch}\nClone:\n{}",
utils::format_iter(clones)
)
} else {
String::new()
};
println!(
"({status}) {event_subject} - [by {event_author}]\n{event_labels}{}{pr_data}",
utils::smart_wrap(&event.content, 80)
);
Ok(())
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -77,7 +77,7 @@ pub struct CliConfig {
#[serde(default)]
pub keyring_secret_key: bool,
/// Signs events using the browser's NIP-07 extension.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub nip07: Option<SocketAddr>,
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -32,6 +32,8 @@ pub mod parsers;
pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
/// CLI utils
pub mod utils;
use clap::Parser;
@@ -40,8 +42,6 @@ use nostr::key::Keys;
use nostr::key::SecretKey;
use nostr_browser_signer_proxy::BrowserSignerProxy;
use nostr_browser_signer_proxy::BrowserSignerProxyOptions;
use nostr_keyring::KeyringError;
use nostr_keyring::NostrKeyring;
use types::RelayOrSet;
pub use self::commands::*;
@@ -50,7 +50,7 @@ use self::traits::CommandRunner;
use crate::cli::options_state::BROWSER_SIGNER_PROXY_TIMEOUT;
use crate::error::N34Error;
use crate::error::N34Result;
use crate::nostr_utils::traits::NostrKeyringErrorUtils;
use crate::nostr_keyring;
/// Header message, used in the help message
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <a@4rs.nl>
@@ -84,13 +84,6 @@ pub struct Cli {
impl Cli {
/// Keyring service name of n34
pub const N34_KEYRING_SERVICE_NAME: &str = "n34";
/// Keyring entry name of the n34 keypair
pub const N34_KEY_PAIR_ENTRY: &str = "n34_keypair";
/// Keyring entry name of the user secret key
pub const USER_KEY_PAIR_ENTRY: &str = "user_keypair";
/// Executes the command
pub async fn run(self) -> N34Result<()> {
self.command.run(self.options).await
@@ -98,33 +91,30 @@ impl Cli {
/// Gets the n34 keypair from the keyring or generates and stores a new one
/// if none exists.
pub fn n34_keypair() -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
match keyring.get(Self::N34_KEY_PAIR_ENTRY) {
pub async fn n34_keypair() -> N34Result<Keys> {
match nostr_keyring::n34::get().await {
Ok(keys) => Ok(keys),
Err(nostr_keyring::Error::Keyring(KeyringError::NoEntry)) => {
Err(N34Error::Keyring(keyring::Error::NoEntry)) => {
let new_keys = Keys::generate();
keyring.set(Self::N34_KEY_PAIR_ENTRY, &new_keys)?;
nostr_keyring::n34::set(&new_keys).await?;
Ok(new_keys)
}
Err(err) => Err(N34Error::Keyring(err)),
Err(err) => Err(err),
}
}
/// Retrieves the user's keypair from the keyring. If no key exists and one
/// is provided, stores and returns it. If no key exists and none is
/// provided, returns an error.
pub fn user_keypair(secret_key: Option<SecretKey>) -> N34Result<Keys> {
let keyring = NostrKeyring::new(Self::N34_KEYRING_SERVICE_NAME);
let keyring_key = keyring.get(Self::USER_KEY_PAIR_ENTRY);
pub async fn user_keypair(secret_key: Option<SecretKey>) -> N34Result<Keys> {
let keyring_key = nostr_keyring::user::get().await;
if let Err(ref err) = keyring_key
&& err.is_keyring_no_entry()
&& let Some(secret_key) = secret_key
{
let keypair = Keys::new(secret_key);
keyring.set(Self::USER_KEY_PAIR_ENTRY, &keypair)?;
nostr_keyring::user::set(&keypair).await?;
return Ok(keypair);
}
@@ -132,7 +122,7 @@ impl Cli {
if err.is_keyring_no_entry() {
N34Error::SecretKeyKeyringWithoutEntry
} else {
N34Error::Keyring(err)
err
}
})
}
@@ -140,7 +130,7 @@ impl Cli {
/// Processes the CLI configuration by applying fallback values from config if
/// needed. Returns the processed Cli configuration if successful.
pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
pub async fn post_cli(mut cli: Cli) -> N34Result<Cli> {
cli.options.pow = cli.options.pow.or(cli.options.config.pow);
if cli.options.relays.is_empty()
@@ -167,7 +157,8 @@ pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
cli.options.bunker_url = Some(bunker_url.clone());
} else if cli.options.config.keyring_secret_key {
cli.options.secret_key = Some(
Cli::user_keypair(cli.options.secret_key)?
Cli::user_keypair(cli.options.secret_key)
.await?
.secret_key()
.clone(),
);

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -22,6 +22,7 @@ use std::{
use nostr::{
Kind,
hashes::sha1::Hash as Sha1Hash,
nips::{
nip19::{FromBech32, Nip19Coordinate, ToBech32},
nip46::NostrConnectURI,
@@ -84,6 +85,25 @@ pub fn parse_bunker_url(bunker_url: &str) -> N34Result<NostrConnectURI> {
}
}
/// Parses a string in the format `string=sha1` into a name and SHA1 hash.
/// The input must contain exactly one `=` character. If the format is invalid
/// or the SHA1 hash is incorrect, an error is returned.
pub fn name_and_sha1(value: &str) -> Result<(String, Sha1Hash), String> {
if value.chars().filter(|c| *c == '=').count() != 1 {
return Err("the syntax is `string=SHA1-commit-id`".to_owned());
}
let (name, sha1_hash) = value.split_once('=').expect("There is one `=`");
Ok((
name.trim_matches(['"', '\'']).to_owned(),
sha1_hash
.trim_matches(['"', '\''])
.parse()
.map_err(|_| "Invalid SHA1 commit id".to_owned())?,
))
}
/// Serializes a set of NIP-19 coordinates as a list of bech32 strings.
pub fn ser_naddrs<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
where

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -14,14 +14,19 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::time::Duration;
use nostr::{event::EventId, nips::nip19::Nip19Coordinate, types::RelayUrl};
use tokio::io::AsyncReadExt;
use super::CliOptions;
use crate::{
cli::{
ConfigError,
RepoRelaySet,
patch::GitPatch,
types::{NaddrOrSet, NostrEvent, RelayOrSet},
utils,
},
error::{N34Error, N34Result},
};
@@ -220,6 +225,47 @@ impl &[RepoRelaySet] {
}
}
#[easy_ext::ext(VecPatchesExt)]
impl Vec<Option<GitPatch>> {
/// Processes a collection of `Option<GitPatch>` values.
///
/// This function performs one of two actions:
/// 1. If all elements are `Some`, it extracts the `GitPatch` values and
/// returns them as a `Vec<GitPatch>`.
/// 2. If there is at least one `None`, it reads patches from standard
/// input, parses them as `GitPatch` values from the stdin, and returns
/// them as a `Vec<GitPatch>`.
#[allow(async_fn_in_trait)]
pub async fn process_patches(self) -> N34Result<Vec<GitPatch>> {
if self.iter().all(Option::is_some) {
return Ok(self.into_iter().map(Option::unwrap).collect());
}
let mut patches = String::new();
match tokio::time::timeout(
Duration::from_secs(1),
tokio::io::stdin().read_to_string(&mut patches),
)
.await
{
Ok(Ok(bytes)) => {
tracing::debug!("Received {bytes} bytes from stdin for patch processing");
}
Ok(Err(err)) => return Err(N34Error::from(err)),
Err(_) => return Err(N34Error::EmptyStdin("patches")),
}
let patches = utils::split_patches(patches)?;
if patches.is_empty() {
return Err(N34Error::InvalidPatch("No valid patches".to_owned()));
}
Ok(patches)
}
}
/// Helper function that checks for duplicates in a sorted slice
fn duplicate_in_sorted<T: PartialEq + Clone>(items: &[T]) -> Option<&T> {
items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::str::FromStr;
use std::{fmt, str::FromStr};
use nostr::{
event::{EventId, Kind},
@@ -53,6 +53,18 @@ pub enum RelayOrSet {
Set(String),
}
/// Enum representing the type of entity to handle in common commands.
#[derive(Debug, Copy, Clone)]
#[repr(u8)]
pub enum EntityType {
/// Pull Request
PullRequest,
/// Patch
Patch,
/// Issue
Issue,
}
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
pub struct NostrEvent {
@@ -78,6 +90,62 @@ impl AuthUrlHandler for EchoAuthUrl {
}
}
impl EntityType {
/// Returns true if the entity is a pull request.
#[inline]
pub const fn is_pr(&self) -> bool {
matches!(self, Self::PullRequest)
}
/// Returns true if the entity is a patch.
#[inline]
pub const fn is_patch(&self) -> bool {
matches!(self, Self::Patch)
}
/// Returns true if the entity is an issue.
#[inline]
pub const fn is_issue(&self) -> bool {
matches!(self, Self::Issue)
}
/// Returns the kind of the entity
#[inline]
pub const fn kind(&self) -> Kind {
match self {
Self::PullRequest => crate::cli::pr::PR_KIND,
Self::Patch => Kind::GitPatch,
Self::Issue => Kind::GitIssue,
}
}
/// Converts a [`u8`] value to the corresponding enum variant.
#[inline]
pub const fn from_u8<const NUM: u8>() -> Self {
const {
match NUM {
val if val == Self::PullRequest as u8 => Self::PullRequest,
val if val == Self::Patch as u8 => Self::Patch,
val if val == Self::Issue as u8 => Self::Issue,
_ => {
panic!("No enum with the given numeric value")
}
}
}
}
/// Ensures the entity is either a pull request or a patch. Compilation
/// will fail if the entity is neither.
pub const fn is_pr_or_patch<const ENTITY_TYPE: u8>() {
const {
let entity = EntityType::from_u8::<ENTITY_TYPE>();
if !entity.is_pr() && !entity.is_patch() {
panic!("The entity should be a pull request or a patch")
}
}
}
}
impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found.
@@ -191,6 +259,103 @@ impl FromStr for NostrEvent {
}
}
#[derive(Debug)]
pub enum PatchPrStatus {
/// The patch/pr is currently open
Open,
/// The patch/pr has been merged/applied
MergedApplied,
/// The patch/pr has been closed
Closed,
/// A patch/pr that has been drafted but not yet applied.
Draft,
}
impl PatchPrStatus {
/// Returns all status kinds
#[inline]
pub const fn all_kinds() -> [Kind; 4] {
[
Self::Open.kind(),
Self::MergedApplied.kind(),
Self::Closed.kind(),
Self::Draft.kind(),
]
}
/// Maps the patch/pr status to its corresponding Nostr kind.
#[inline]
pub const fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::MergedApplied => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
Self::Draft => Kind::GitStatusDraft,
}
}
/// Returns the string representation of the patch/pr status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::MergedApplied => "Merged/Applied",
Self::Closed => "Closed",
Self::Draft => "Draft",
}
}
/// Check if the status is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the status is merged/applied.
#[inline]
pub fn is_merged_or_applied(&self) -> bool {
matches!(self, Self::MergedApplied)
}
/// Check if the status is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
/// Check if the status is draft
#[inline]
pub fn is_drafted(&self) -> bool {
matches!(self, Self::Draft)
}
}
impl From<&PatchPrStatus> for Kind {
fn from(status: &PatchPrStatus) -> Self {
status.kind()
}
}
impl fmt::Display for PatchPrStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for PatchPrStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::MergedApplied),
Kind::GitStatusClosed => Ok(Self::Closed),
Kind::GitStatusDraft => Ok(Self::Draft),
_ => Err(N34Error::InvalidPatchStatus(kind)),
}
}
}
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));

134
src/cli/utils.rs Normal file
View File

@@ -0,0 +1,134 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{
fmt::Write as _,
fs,
io::{self, Write},
str::FromStr,
};
use crate::{
cli::patch::{FROM_RE, GitPatch},
error::{N34Error, N34Result},
};
/// Displays the given prompt and reads a line of input from the user.
pub fn read_line(prompt: &str) -> io::Result<String> {
{
let mut stdout = io::stdout().lock();
write!(&mut stdout, "{prompt}: ")?;
_ = stdout.flush();
}
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
Ok(user_input.trim().to_owned())
}
/// Prompts the user with a message and repeatedly asks until they enter a valid
/// boolean response. Recognizes "yes", "y", "true" for `true` and "no", "n",
/// "false" for `false`.
pub fn prompt_bool(prompt: &str) -> io::Result<bool> {
loop {
let user_input = read_line(prompt)?.to_ascii_lowercase();
match user_input.as_str() {
"yes" | "y" | "true" => return Ok(true),
"no" | "n" | "false" => return Ok(false),
_ => continue,
}
}
}
/// Opens the logs file for writing. If the file size exceeds 5MB, it is opened
/// in write mode, otherwise in append mode.
pub fn logs_file() -> N34Result<fs::File> {
const FIVE_MB: u64 = 1024 * 1024 * 5;
let logs_path = dirs::data_local_dir()
.ok_or(N34Error::CanNotFindDataPath)?
.join("n34")
.join("logs.log");
tracing::info!(path = %logs_path.display(), "Logs file");
if let Some(parent) = logs_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
_ = fs::File::create_new(&logs_path);
let is_large = if let Ok(file) = fs::File::open(&logs_path)
&& let Ok(metadata) = file.metadata()
{
metadata.len() >= FIVE_MB
} else {
false
};
fs::OpenOptions::new()
.write(true)
.append(!is_large)
.truncate(is_large)
.open(&logs_path)
.map_err(N34Error::from)
}
/// Splits a string with multiple git patches into separate patches. Each patch
/// begins with a line formatted as "From \<hash\> ...". Ensures the result is
/// never an empty vector.
pub fn split_patches(patches: String) -> N34Result<Vec<GitPatch>> {
let mut patches = patches.split("\n").peekable();
let mut result = Vec::new();
let mut current_patch = String::new();
let push_patch = |patch_str: String| -> N34Result<GitPatch> {
tracing::trace!("patch content: {patch_str}");
let patch = GitPatch::from_str(&patch_str).map_err(N34Error::InvalidPatch)?;
tracing::info!(
"Processing patch from stdin: {}",
patch
.filename("")
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "Unnamed patch".to_owned())
);
Ok(patch)
};
while let Some(line) = patches.next() {
// If we have a non-empty patch and encounter a "From " line followed by a line
// with ":", it indicates a new patch header. Push the current patch and
// clear it for the new one. The "From " line will be added as the first
// line of the new patch after this check.
if !current_patch.is_empty()
&& FROM_RE.is_match(line)
&& patches.peek().is_some_and(|line| line.contains(":"))
{
result.push(push_patch(current_patch.clone())?);
current_patch.clear()
}
_ = writeln!(&mut current_patch, "{line}");
}
if !current_patch.is_empty() {
result.push(push_patch(current_patch)?);
}
Ok(result)
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -51,10 +51,21 @@ pub enum N34Error {
Addr(#[from] AddrParseError),
#[error("Browser Signer Proxy Error: {0}")]
BrowserSignerProxy(#[from] nostr_browser_signer_proxy::Error),
#[error("Keys error: {0}")]
Keys(#[from] nostr::key::Error),
#[error("Keyring error: {0}")]
Keyring(#[from] nostr_keyring::Error),
Keyring(#[from] keyring::Error),
#[error("Join error: {0}")]
Join(#[from] tokio::task::JoinError),
#[error("{0}")]
Config(#[from] ConfigError),
#[error(
"Could not determine the default data path: both `$XDG_CONFIG_HOME` and `$HOME` \
environment variables are missing or unset."
)]
CanNotFindDataPath,
#[error("Empty STDIN: expected '{0}' to be present in the input")]
EmptyStdin(&'static str),
#[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
@@ -71,6 +82,8 @@ pub enum N34Error {
InvalidRepoId,
#[error("Invalid event: {0}")]
InvalidEvent(String),
#[error("Invalid patch: {0}")]
InvalidPatch(String),
#[error("Bech32 error: {0}")]
Bech32(#[from] nostr::nips::nip19::Error),
#[error("Event error: {0}")]
@@ -123,9 +136,14 @@ pub enum N34Error {
)]
CanNotFoundIssue,
#[error(
"Patch not found, make sure it is in the relays and make sure that the ID is an patch ID"
"Patch not found, make sure it is in the relays and make sure that the ID is a patch ID"
)]
CanNotFoundPatch,
#[error(
"Pull request not found, make sure it is in the relays and make sure that the ID is an \
pull request ID"
)]
CanNotFoundPr,
#[error(r#"The given patch id is not a root patch. It must contains `["t", "root"]` tag"#)]
NotRootPatch,
#[error("This status kind can't be set for an issue: {0}")]
@@ -134,7 +152,7 @@ pub enum N34Error {
InvalidPatchStatus(Kind),
#[error("Can't find the root patch of the given patch-revision")]
RevisionRootNotFound,
#[error("Invalid status for the issue/patch: {0}")]
#[error("Invalid status: {0}")]
InvalidStatus(String),
#[error("Not valid bunker URL")]
NotBunkerUrl,
@@ -143,6 +161,13 @@ pub enum N34Error {
keyring is enabled to store it"
)]
SecretKeyKeyringWithoutEntry,
#[error(
"Your push was not accepted by any GRASP server. Try creating a personal fork, pushing to \
your own GRASP servers, and then make a pull request with your GRASP repos as a clone"
)]
PushRejectedByGraspServers,
#[error("The repository doesn't contains any GRASP servers")]
NoGraspServer,
}
impl N34Error {
@@ -166,4 +191,10 @@ impl N34Error {
_ => ExitCode::FAILURE,
}
}
/// Checks if the error indicates a missing keyring entry.
#[inline]
pub fn is_keyring_no_entry(&self) -> bool {
matches!(self, Self::Keyring(keyring::Error::NoEntry))
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -18,17 +18,20 @@
pub mod cli;
/// N34 errors
pub mod error;
/// Nostr keyring
pub mod nostr_keyring;
/// Nostr utils module
pub mod nostr_utils;
use std::{
fs::File,
process::ExitCode,
sync::atomic::{AtomicBool, Ordering},
};
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use tracing::Level;
use tracing::{Level, level_filters::LevelFilter};
use tracing_subscriber::{Layer, filter, layer::SubscriberExt};
use self::cli::Cli;
@@ -37,42 +40,64 @@ use self::cli::Cli;
/// open.
static EDITOR_OPEN: AtomicBool = AtomicBool::new(false);
/// Returns the stderr log layer
fn stderr_log_layer<S>() -> impl Layer<S>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
tracing_subscriber::fmt::layer()
.with_ansi(true)
.with_writer(std::io::stderr)
.without_time()
}
/// Configures the logging level based on the provided verbosity.
///
/// When verbosity is set to TRACE, includes file and line numbers in logs.
fn set_log_level(verbosity: Verbosity) {
let is_trace = verbosity
.tracing_level()
.is_some_and(|l| l == tracing::Level::TRACE);
let logs_filter = filter::dynamic_filter_fn(move |m, _| {
fn set_log_level(verbosity: Verbosity, logs_file: File) {
let editor_filter = filter::dynamic_filter_fn(move |m, _| {
// Disable all logs while editor is open
verbosity.tracing_level().unwrap_or(Level::ERROR) >= *m.level()
&& (m.name().starts_with("event src") || m.name().contains("nostr"))
&& !EDITOR_OPEN.load(Ordering::Relaxed)
});
let logs_layer = tracing_subscriber::fmt::layer()
.with_file(is_trace)
.with_line_number(is_trace)
.without_time();
let subscriber = tracing_subscriber::registry().with(logs_layer.with_filter(logs_filter));
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(logs_file)
.with_file(true)
.with_line_number(true)
.with_filter(LevelFilter::TRACE);
let subscriber = tracing_subscriber::registry()
.with(stderr_log_layer().with_filter(editor_filter))
.with(file_layer);
tracing::subscriber::set_global_default(subscriber).ok();
}
async fn try_main() -> error::N34Result<()> {
// Initialize a thread-local subscriber for logging during CLI parsing and
// post-processing.
let guard =
tracing::subscriber::set_default(tracing_subscriber::registry().with(stderr_log_layer()));
let cli = cli::post_cli(Cli::parse()).await?;
let logs_file = cli::utils::logs_file()?;
// Replace the thread-local subscriber with a global default subscriber based on
// the CLI verbosity level.
drop(guard);
set_log_level(cli.verbosity, logs_file);
cli.run().await?;
Ok(())
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = match cli::post_cli(Cli::parse()) {
Ok(cli) => cli,
Err(err) => {
if let Err(err) = try_main().await {
eprintln!("{err}");
return ExitCode::FAILURE;
}
};
set_log_level(cli.verbosity);
if let Err(err) = cli.run().await {
tracing::error!("{err}");
return err.exit_code();
}

98
src/nostr_keyring.rs Normal file
View File

@@ -0,0 +1,98 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use async_utility::task;
use keyring::Entry;
use nostr::key::{Keys, SecretKey};
use crate::error::{N34Error, N34Result};
/// Keyring service name of n34
const N34_KEYRING_SERVICE_NAME: &str = "n34";
/// Keyring entry name of the n34 keypair
const N34_KEY_PAIR_ENTRY: &str = "n34_keypair";
/// Keyring entry name of the user secret key
const USER_KEY_PAIR_ENTRY: &str = "user_keypair";
/// Retrieves the keypair entry for `n34`.
#[inline]
fn n34_entry() -> keyring::Result<Entry> {
Entry::new(N34_KEYRING_SERVICE_NAME, N34_KEY_PAIR_ENTRY)
}
/// Retrieves the keypair entry for the user.
#[inline]
fn user_entry() -> keyring::Result<Entry> {
Entry::new(N34_KEYRING_SERVICE_NAME, USER_KEY_PAIR_ENTRY)
}
pub mod user {
use super::*;
/// Stores the user's keypair in the system.
pub async fn set(key: &Keys) -> N34Result<()> {
let private_key = key.secret_key().to_secret_bytes();
task::spawn_blocking(move || {
user_entry()?
.set_secret(&private_key)
.map_err(N34Error::from)
})
.await?
}
/// Retrieves the user's keypair from the system.
pub async fn get() -> N34Result<Keys> {
task::spawn_blocking(move || {
Ok(Keys::new(SecretKey::from_slice(
&user_entry()?.get_secret()?,
)?))
})
.await?
}
/// Delete the user's keypair from the system.
pub async fn delete() -> N34Result<()> {
task::spawn_blocking(move || user_entry()?.delete_credential().map_err(N34Error::from))
.await?
}
}
pub mod n34 {
use super::*;
/// Stores the `n34` client's keypair in the system.
pub async fn set(key: &Keys) -> N34Result<()> {
let private_key = key.secret_key().to_secret_bytes();
task::spawn_blocking(move || {
n34_entry()?
.set_secret(&private_key)
.map_err(N34Error::from)
})
.await?
}
/// Retrieves the `n34` client's keypair from the system.
pub async fn get() -> N34Result<Keys> {
task::spawn_blocking(move || {
Ok(Keys::new(SecretKey::from_slice(
&n34_entry()?.get_secret()?,
)?))
})
.await?
}
}

View File

@@ -1,4 +1,4 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// n34 - A CLI to interact with NIP-34 and other stuff related to code in Nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// This program is free software: you can redistribute it and/or modify
@@ -39,8 +39,9 @@ use nostr_sdk::{Client, ClientOptions};
use traits::TokenUtils;
use crate::{
cli::{CliOptions, issue::IssueStatus, patch::PatchStatus},
cli::{CliOptions, issue::IssueStatus, types::PatchPrStatus},
error::{N34Error, N34Result},
nostr_utils::traits::KindExt,
};
/// Timeout duration for the client.
@@ -211,21 +212,17 @@ impl NostrClient {
/// Fetches the first event matching the given filter, or None if no event
/// is found.
pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> {
Ok(self
.client
.fetch_events(filter.limit(1), CLIENT_TIMEOUT)
.await?
.first_owned())
Ok(self.fetch_events(filter).await?.next())
}
/// Fetches the events matching the given filter
pub async fn fetch_events(&self, filter: Filter) -> N34Result<Vec<Event>> {
pub async fn fetch_events(&self, filter: Filter) -> N34Result<impl Iterator<Item = Event>> {
// Multiply timeout by 5 to account for multiple events being fetched
Ok(self
.client
.fetch_events(filter, CLIENT_TIMEOUT * 5)
.await?
.to_vec())
.into_iter())
}
/// Try to fetch the repositories and returns them
@@ -258,6 +255,13 @@ impl NostrClient {
.ok_or(N34Error::CanNotFoundPatch)
}
/// Fetch the pull request by the given id. None if not found
pub async fn fetch_pr(&self, pr_id: EventId) -> N34Result<Event> {
self.fetch_event(Filter::new().id(pr_id).kind(crate::cli::pr::PR_KIND))
.await?
.ok_or(N34Error::CanNotFoundPr)
}
/// Returns the username for a given public key. If no username is found,
/// falls back to a shortened version of the public key.
pub async fn get_username(&self, user: PublicKey) -> String {
@@ -281,23 +285,36 @@ impl NostrClient {
issue_id: EventId,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<IssueStatus> {
self.fetch_events(
self.fetch_event(
Filter::new()
.event(issue_id)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
])
.kinds(IssueStatus::all_kinds())
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| IssueStatus::try_from(status.kind))
.unwrap_or_else(|| Ok(IssueStatus::Open))
}
/// Get the latest status of PR by its ID, only considering status
/// events from authorized_pubkeys. If no valid status event is found,
/// defaults to Open.
pub async fn fetch_pr_status(
&self,
pr_id: EventId,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchPrStatus> {
self.fetch_event(
Filter::new()
.event(pr_id)
.kinds(PatchPrStatus::all_kinds())
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.map(|status| PatchPrStatus::try_from(status.kind))
.unwrap_or_else(|| Ok(PatchPrStatus::Open))
}
/// Gets the status of a patch. If it's a revision patch, checks if it's
/// closed when the root patch is already merged/applied but doesn't
/// reference this revision. Defaults to Open status if no status event
@@ -307,24 +324,17 @@ impl NostrClient {
root_patch: EventId,
root_revision: Option<EventId>,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchStatus> {
) -> N34Result<PatchPrStatus> {
let (root_status, event_tags) = self
.fetch_events(
.fetch_event(
Filter::new()
.event(root_patch)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.kinds(PatchPrStatus::all_kinds())
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| N34Result::Ok((PatchStatus::try_from(status.kind)?, status.tags)))
.unwrap_or_else(|| Ok((PatchStatus::Open, Tags::new())))?;
.map(|status| N34Result::Ok((PatchPrStatus::try_from(status.kind)?, status.tags)))
.unwrap_or_else(|| Ok((PatchPrStatus::Open, Tags::new())))?;
if let Some(revision_id) = root_revision
&& root_status.is_merged_or_applied()
@@ -332,7 +342,7 @@ impl NostrClient {
.filter(TagKind::e())
.any(|t| t.is_reply() && t.content().is_some_and(|c| c == revision_id.to_hex()))
{
return Ok(PatchStatus::Closed);
return Ok(PatchPrStatus::Closed);
}
Ok(root_status)
@@ -351,7 +361,6 @@ impl NostrClient {
.event(root_patch_id),
)
.await?
.into_iter()
.filter(|e| {
e.tags.iter().any(|t| {
t.is_root() && t.content().is_some_and(|c| c == root_patch_id.to_hex())
@@ -360,17 +369,17 @@ impl NostrClient {
.collect())
}
/// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns
/// None if no root can be found.
/// Finds the root issue, PR or patch for a given event. If the event is
/// already a root (issue/patch/PR), returns it directly. For comments,
/// follows parent/root references until finding the root or failing.
/// Returns None if no root can be found.
pub async fn find_root(&self, mut event: Event) -> N34Result<Option<Event>> {
if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) {
if !event.kind.can_reply_to() {
return Err(N34Error::CanNotReplyToEvent);
}
loop {
if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) {
if event.kind.is_root_kind() {
return Ok(Some(event));
}
@@ -380,7 +389,7 @@ impl NostrClient {
self.add_relay_hint(relay_hint.cloned()).await;
let root_event = self.fetch_event(Filter::new().id(*id)).await?;
if let Some(ref root_event) = root_event
&& !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch)
&& !root_event.kind.is_root_kind()
{
return Err(N34Error::CanNotReplyToEvent);
}
@@ -395,7 +404,7 @@ impl NostrClient {
}
}
// Break if: no root/parent tags found, parent/root event fetch failed
// Break if: no root/parent tags found
break;
}
@@ -426,16 +435,20 @@ impl NostrClient {
/// Returns the read relays of the given users if found, otherwise empty
/// vector
pub async fn read_relays_from_users(&self, users: &[PublicKey]) -> Vec<RelayUrl> {
self.fetch_events(
if let Ok(iterator) = self
.fetch_events(
Filter::new()
.kind(nostr::event::Kind::RelayList)
.authors(utils::dedup(users.iter().copied())),
)
.await
.unwrap_or_default()
.into_iter()
{
iterator
.flat_map(|e| utils::add_read_relays(Some(&e)))
.collect()
} else {
Vec::new()
}
}
/// Parse the given content and returns the details that inside it

Some files were not shown because too many files have changed in this diff Show More