154 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
Awiteb
9aa3b62de0 chore: Bump the version to v0.4.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:21:35 +00:00
Awiteb
a8015d618b chore: Use nushell as a script interpreter
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 15:20:20 +00:00
Awiteb
2bcdb29a88 chore: Add nushell to dev shell
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
4a4c0dcd34 chore: Show the lifetime
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-08 14:29:22 +00:00
Awiteb
43ca689190 chore: installation using Nix
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:26:11 +00:00
Awiteb
14ab9b38e2 chore(cargo): Add package.documentation and package.homepage
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
802da70056 chore(flake): Add packages.default output
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
cff0b2f3b2 chore(flake): Update lock file
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
9c55bf8af1 chore: use the existing signer to get the pubkey instead of a new one
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
904d14064f feat: Sign using NIP-07
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-07 23:22:40 +00:00
Awiteb
ad215002e2 chore: backward compatibility with ngit revision patch hashtag
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 09:22:45 +00:00
Awiteb
67339a8a47 chore: verify subscriptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-04 06:28:32 +00:00
Awiteb
1d83e445a4 deps: Upgrade to nostr@0.43.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
8bb4cf0da4 refactor: Move root and root-revision to constants
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:05 +00:00
Awiteb
e17e75e53b refactor: Move the trait extensions to traits.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
67e25da8c0 feat: New flag to patch apply and merge to mention patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 15:24:04 +00:00
Awiteb
bc8c6f3154 deps: Remove url from n34 dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-03 00:17:30 +00:00
Awiteb
0ba58dcbd0 tests: Move the patch tests to tests.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:50:21 +00:00
Awiteb
78113c7402 docs: Fix status command docs
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-02 09:14:21 +00:00
Awiteb
bd3ba1bdbf docs: n34 book
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:32:48 +00:00
Awiteb
2630104e99 chore: Fix a typo in the description
Signed-off-by: Awiteb <a@4rs.nl>
2025-08-01 20:17:42 +00:00
Awiteb
03d5c8020e feat: Keyring the secret key n34 config keyring --enable
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-27 00:55:53 +00:00
Awiteb
c3aef2e0ad chore: Add installation section to README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 22:24:03 +00:00
Awiteb
4e0ecdceaf feat: Support signing using NIP-46 bunker
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:43:50 +00:00
Awiteb
f0c20c3677 deps: Add keyring, nostr-connect, nostr-keyring and url to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-18 21:17:02 +00:00
Awiteb
e6943aefab chore(flake): Add dbus to the packages
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-10 12:05:14 +00:00
Awiteb
353358f0ed chore: Move the naddr parsers to parsers.rs
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-07 12:35:01 +00:00
Awiteb
c029ddf86c chore: Bump the version to v0.3.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:20:19 +00:00
Awiteb
fc7d7baeb6 chore: Bump the MSRV to 1.88.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:16:37 +00:00
Awiteb
8715cd5547 chore: Use cargo-msrv to check the msrv instead of rustup
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:15:53 +00:00
Awiteb
70e76f1699 chore: Update patch filename test case
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 12:03:31 +00:00
Awiteb
9e840be820 chore: Use new stabilized let-chains feature
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 09:34:32 +00:00
Awiteb
3fb7abe62a chore: Fix docs issues
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:48:34 +00:00
Awiteb
3814d1b4ec chore: Inlined format args
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:45:13 +00:00
Awiteb
1c11b8f6bb chore: Add rust-analyzer to flake.nix
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:41:28 +00:00
Awiteb
74d05033f8 chore: Update flake.lock
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:33:13 +00:00
Awiteb
9bbeda8de8 chore: Update rustfmt nightly version
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-05 08:32:59 +00:00
Awiteb
3510b59fbb feat: Improve exit codes and make them more specific
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-04 10:50:24 +00:00
Awiteb
05b4ae30f7 feat: New issue list commands to list the repo issues
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-02 22:07:03 +00:00
Awiteb
387dd32a37 feat: New patch list commands to list the repo patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-02 22:07:03 +00:00
Awiteb
5c5987e07b chore: Enable optimization
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:20:44 +00:00
Awiteb
9e1e5d7c42 chore: Inline small functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:20:30 +00:00
Awiteb
7e479f3c7a chore: issues utils
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 15:07:04 +00:00
Awiteb
4cc01668fd fix: Require a repo in repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 12:47:03 +00:00
Awiteb
7d700602ef fix: Fix a typo in EmptySetRelays error message
Signed-off-by: Awiteb <a@4rs.nl>
2025-07-01 12:45:53 +00:00
DanConwayDev
7c589f1c6c fix: spelling in help content
reposotoies ~> repositories
2025-06-30 12:10:26 +00:00
Awiteb
f3a09d30ea chore: Update SignerRequired error message
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:33:23 +00:00
Awiteb
2874ba8457 remove: Remove --euc flag from patch send command and use the repo euc
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:17:05 +00:00
Awiteb
55e5d86c03 deps: Remove bitcoin_hashes and use nostr::hashes re-export
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-30 11:16:52 +00:00
Awiteb
6467bc3bb5 remove: Remove the --to flag from reply command and make it an argument
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:24:57 +00:00
Awiteb
da284a084c feat: View the repo maintainers as npub
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:20:58 +00:00
Awiteb
45ea7d25f2 remove: Remove the --repo option and make the repo an argument
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-29 20:17:14 +00:00
Awiteb
8b09cff5e3 feat: New patch subcommands apply,close,draft,merge and reopen to manage the patch status
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 13:47:31 +00:00
Awiteb
d6dd63f576 chore: Use bitcoin_hashes::Sha1 instead of bitcoin_hashes::sha1::Hash
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:51:43 +00:00
Awiteb
ae6cff1a1d chore(patch-fetch): Remove the repo coordinate from the filter
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:49:08 +00:00
Awiteb
a38a811cbf deps: Update nostr to 0.42.2
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:47:40 +00:00
Awiteb
93136fb767 deps: Add either@1.15.0 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 12:46:49 +00:00
Awiteb
035e22a6ba chore: Fix typos
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-28 10:29:38 +00:00
Awiteb
043ef6fc96 chore: Add the repository to authorized issue status users
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-24 11:40:48 +00:00
Awiteb
54cae48123 chroe: Check if the naddrs exists for issue new and patch send
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:46:55 +00:00
Awiteb
1651fd5a47 fix: Not to return an error if nostr-address file does not exist
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:45:00 +00:00
Awiteb
ec08a1febc chore: Improve maintainers handling
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 10:36:01 +00:00
Awiteb
a9a2cb2b16 feat: New issue {reopen,close,resolve} commands to manage issue status
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-14 09:38:00 +00:00
Awiteb
63f6c1fb11 chore(cli): Improve run_command macro
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 16:54:33 +00:00
Awiteb
3e6fed8bc6 chore: Remove unnecessary ToString::to_string call
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 10:28:11 +00:00
Awiteb
431bfd2512 chore: Update CONTRIBUTING.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-11 10:18:03 +00:00
Awiteb
364356a573 feat: New patch fetch command to fetch patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 16:23:08 +00:00
Awiteb
2a3e2e09c0 chore: Improve single letter tags
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 08:53:22 +00:00
Awiteb
8870f8ced3 chore: Fix a typo
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-10 08:41:36 +00:00
Awiteb
da96985a40 feat: New issue view command to view an issue
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 22:09:55 +00:00
Awiteb
76b76bc8d6 chore(nostr_utils): Move the username logic to the utils
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 21:12:17 +00:00
Awiteb
983d2f209d chore(NostrClient): Limit the events to 1 in fetch_event function
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 10:29:17 +00:00
Awiteb
7ba98bbbe8 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-09 10:01:35 +00:00
Awiteb
8f70778c26 chore: Improve unreachable panic message
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
9069adbe22 chore: Add the signer to the client if found
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
b1027b3b04 refactor: Improve commands running and make the signer optional for some commands
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-08 20:35:49 +00:00
Awiteb
5dc8e31eb3 feat: New config relays command to set the default fallbacks relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 21:12:34 +00:00
Awiteb
51bd239024 feat: New config pow command to set the default PoW difficulty
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:54:40 +00:00
Awiteb
53a2eb5bda chore: Remove toml prefix from CliConfig::{load,dump}_toml
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:01:53 +00:00
Awiteb
a6a61aedf1 refactor: Store the config in CliOptions instead of its path
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-07 19:00:29 +00:00
Awiteb
30b3056d39 test: Add a new patch parsing unit test
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-06 21:23:27 +00:00
Awiteb
8fe6755d3b chore: Run the tests in the CI
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-06 21:15:33 +00:00
Awiteb
812a06a276 feat: Add description tag to the patch
The tag value is the patch subject

Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:10:10 +00:00
Awiteb
494cced9cb feat: Add alt tag to the git issue
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:05:21 +00:00
Awiteb
16e6300c81 chore(cli): Return Future and make the function sync CommandRunner:run
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 22:04:48 +00:00
Awiteb
ef8d6c1c4f feat: New patch send command to send patches
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 12:45:36 +00:00
Awiteb
01f5fa60e6 chore(issue-new): Add naddrs relays to write relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-04 06:56:18 +00:00
Awiteb
7d9b968f16 chore(cli): Add author read relays to discovery relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-03 14:28:04 +00:00
Awiteb
57fc868b63 chore: Move NostrEvent to crate::cli::types
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-03 09:27:20 +00:00
Awiteb
08505151bd chore: More steps in Patch Guidelines
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-02 17:54:41 +00:00
Awiteb
864f3018f6 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:46:10 +00:00
Awiteb
37128a8c1a chore(justfile): Improve the changelog body
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:45:25 +00:00
Awiteb
ce0068be53 chore: Bump the version to v0.2.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:32:24 +00:00
Awiteb
4c6578ccb1 feat: Support relays and naddrs sets
This commit introduces support for 'sets', a feature that simplifies
referencing multiple relays or naddrs with a single word.

Sets are stored in the config directory under `n34` as `config.toml` in
`TOML` format. The config path follows the `$XDG_CONFIG_HOME` standard,
defaulting to `$HOME/.config` if unset.

The `n34 sets` command allows managing sets, adding, removing, updating,
or displaying them. When using the main `--relays` flag, a set name can
be provided to automatically expand its relays (an error occurs if the
set contains no relays). Similarly, any command accepting naddrs can
reference a set to extract its naddrs.

Sets can also be merged by updating a set while specifying another
set in `--repo` merges their naddrs, while `--set-relays` merges their
relays. Removal follows the same pattern.

Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 12:12:09 +00:00
Awiteb
d6d3f8a52f chore: Add more relays to the repo address
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:49 +00:00
Awiteb
84bfafa2e9 deps: Add serde@1.0.219, dirs@6.0.0 and toml@0.8.22
Signed-off-by: Awiteb <a@4rs.nl>
2025-06-01 11:59:12 +00:00
Awiteb
24e8522268 chore: Move the write relays definition to the end of the commands
To make it clear, we can review exactly what was collected instead of
having it spread throughout the body of the command.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 19:20:42 +00:00
Awiteb
f670859b92 chore: Remove the vector parameter from add_{read,write}_relays
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:35:26 +00:00
Awiteb
bca86f115f perf(NostrClient::send_event_to): Join the event sending
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 12:00:10 +00:00
Awiteb
a9b9a7a02a chore: Remove unused import
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 11:04:58 +00:00
Awiteb
1abb8e3f36 feat: Events and naddrs can starts with nostr:
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Fixes: note1jwgfl095vuhduepxxpwhdqme0dnlhawzvcal0xnl0e6d82wx5jys5z92hj
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 10:48:33 +00:00
Awiteb
15d834dc0f chore: Deduplicate tags
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:45:20 +00:00
Awiteb
ea862dac5f chore: Add a newline after the naddr
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:36 +00:00
Awiteb
55a4868813 fix(utils-repo_naddr): Create a valid naddr string
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:29:13 +00:00
Awiteb
60978f43b5 chore: Add a header to nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 08:02:37 +00:00
Awiteb
37cf601c01 refactor: Support more than one naddr instead of one
This commit supports multiple naddrs for sending issues to and searching
for repositories by extracting the embedded relays, and appending to the
address file instead of overwriting its previous contents. A header has
also been added to the `nostr-address` file to clarify its purpose.

This enhancement accommodates maintainers who distribute issue tracking
across multiple repositories, preventing confusion if the primary
maintainer steps down. Now, issues and patches will reference all
relevant repositories, not just one.

Note that repositories won't automatically include all maintainers'
addresses unless explicitly specified. Maintainers may manage a
repository under a project-specific public key rather than individual
keys. Addresses are only added to issues and patches when explicitly
provided via the command or written to the address file. This prevents
unintended inclusions and ensures clarity.

Suggested-by: DanConwayDev <DanConwayDev@protonmail.com>
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-27 07:05:13 +00:00
Awiteb
87da5035da chore(todo): Some tasks
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 14:08:25 +00:00
Awiteb
ddea5028ee feat: Make the relays list optional
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 12:30:40 +00:00
Awiteb
de68d61c3f feat: Enter repository as nip5
For example, `4rs.nl/n34` and `_@4rs.nl/n34`

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 11:00:37 +00:00
Awiteb
f7e837e3ab deps: Enable nip05 feature of nostr crate
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 10:57:15 +00:00
Awiteb
02070c2868 feat: A --quote-to flag to quote the replied to content in the editor
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:06:32 +00:00
Awiteb
998ef8f4b1 deps: Add chrono@0.4.41 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-24 06:03:17 +00:00
Awiteb
6fdf0dbcf5 feat: Read the nostr-address file in reply command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:38:21 +00:00
Awiteb
15c0eaa5dc chore: Make commands module
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 13:15:37 +00:00
Awiteb
226909ef16 feat: Read the nostr-address file in issue new command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:38:26 +00:00
Awiteb
8ca88804dd feat: Read the nostr-address file in repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-23 12:36:48 +00:00
Awiteb
55f9b9194e chore: Utils to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:45:01 +00:00
Awiteb
a26116702d chore: Errors to work with the nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 20:44:41 +00:00
Awiteb
35d376ca6c chore: Add nostr-address file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:19:27 +00:00
Awiteb
57b48c7368 feat: Add --address-file flag to repo announce command
This flag creates a file in the current working directory and writes
the `naddr` into it. This allows n34 and other tools to recognize the
repository address automatically, eliminating the need for manual input
by the user.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 19:17:07 +00:00
Awiteb
b29937a04e chore(changelog): Update the body
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 15:15:49 +00:00
Awiteb
06374fe1fc feat: add --force-id flag to bypass case validation in repo announce
The new flag allows users to skip the kebkab case ID check when announcing
repositories.

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
2d5c0bf549 chore(git-cliff): Upper first after the trim
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
0a5b4656c3 chore(taplo): Exclude target dir
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
cad2c7faa3 chore: Update CONTRIBUTING.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 08:27:13 +00:00
Awiteb
19cfecb9ea chore(readme): Add Contributions & Changes section
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-22 06:11:28 +00:00
Awiteb
89d0710ce5 chore(justfile): Add a subject to the tag message
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 23:57:16 +00:00
123 changed files with 11216 additions and 1085 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/book

View File

@@ -1,3 +1,5 @@
exclude = ["target/**/*.toml", "Cargo.lock"]
[formatting]
align_entries = true
indent_tables = true

View File

@@ -5,31 +5,163 @@ 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
- Support signing using NIP-46 bunker - by Awiteb
- Keyring the secret key `n34 config keyring --enable` - by Awiteb
- New flag to `patch apply and merge` to mention patches - by Awiteb
- Sign using NIP-07 - by Awiteb
### Dependencies
- Add `keyring`, `nostr-connect`, `nostr-keyring` and `url` to the dependencies - by Awiteb
- Remove `url` from `n34` dependencies - by Awiteb
- Upgrade to `nostr@0.43.0` - by Awiteb
### Documentation
- N34 book - by Awiteb
- Fix status command docs - by Awiteb
### Refactor
- Move the trait extensions to `traits.rs` - by Awiteb
- Move `root` and `root-revision` to constants - by Awiteb
## [0.3.0] - 2025-07-05
### Added
- New `patch send` command to send patches - by Awiteb
- Add `alt` tag to the git issue - by Awiteb
- Add `description` tag to the patch - by Awiteb
- New `config pow` command to set the default PoW difficulty - by Awiteb
- New `config relays` command to set the default fallbacks relays - by Awiteb
- New `issue view` command to view an issue - by Awiteb
- New `patch fetch` command to fetch patches - by Awiteb
- New `issue {reopen,close,resolve}` commands to manage issue status - by Awiteb
- New `patch` subcommands apply,close,draft,merge and reopen to manage the patch status - by Awiteb
- View the repo maintainers as `npub` - by Awiteb
- New `patch list` commands to list the repo patches - by Awiteb
- New `issue list` commands to list the repo issues - by Awiteb
- Improve exit codes and make them more specific - by Awiteb
### Dependencies
- Add `either@1.15.0` to the dependencies - by Awiteb
- Update `nostr` to `0.42.2` - by Awiteb
- Remove `bitcoin_hashes` and use `nostr::hashes` re-export - by Awiteb
### Fixed
- Not to return an error if `nostr-address` file does not exist - by Awiteb
- Spelling in help content - by DanConwayDev
- Fix a typo in `EmptySetRelays` error message - by Awiteb
- Require a repo in `repo view` command - by Awiteb
### Refactor
- Store the config in `CliOptions` instead of its path - by Awiteb
- Improve commands running and make the signer optional for some commands - by Awiteb
### Removed
- Remove the `--repo` option and make the repo an argument - by Awiteb
- Remove the `--to` flag from `reply` command and make it an argument - by Awiteb
- Remove `--euc` flag from `patch send` command and use the repo euc - by Awiteb
## [0.2.0] - 2025-06-01
### Added
- Add `--force-id` flag to bypass case validation in `repo announce` - by Awiteb
- Add `--address-file` flag to `repo announce` command - by Awiteb
- Read the `nostr-address` file in `repo view` command - by Awiteb
- Read the `nostr-address` file in `issue new` command - by Awiteb
- Read the `nostr-address` file in `reply` command - by Awiteb
- A `--quote-to` flag to quote the replied to content in the editor - by Awiteb
- Enter repository as nip5 - by Awiteb
- Make the relays list optional - by Awiteb
- Events and naddrs can starts with `nostr:` - by Awiteb
- Support relays and naddrs sets - by Awiteb
### Dependencies
- Add `chrono@0.4.41` to the dependencies - by Awiteb
- Enable `nip05` feature of `nostr` crate - by Awiteb
- Add `serde@1.0.219`, `dirs@6.0.0` and `toml@0.8.22` - by Awiteb
### Fixed
- Create a valid naddr string - by Awiteb
### Refactor
- Support more than one naddr instead of one - by Awiteb
## [0.1.0] - 2025-05-21
### Added
- Setup the CLI and create `repo view` command - (Awiteb at [d962732](https://git.4rs.nl/awiteb/n34.git/commit/?id=d962732e5251b3af81b6d12327e258b49e3e603e))
- Add `repo announce` command - (Awiteb at [b444aeb](https://git.4rs.nl/awiteb/n34.git/commit/?id=b444aeba0ac6d19cb32715fb70c7dc1da297f8e9))
- Support PoW - (Awiteb at [c0a5e47](https://git.4rs.nl/awiteb/n34.git/commit/?id=c0a5e4779385024c8b65ae7626081a54ccbcb5b7))
- Add `issue new` command - (Awiteb at [54f1c7e](https://git.4rs.nl/awiteb/n34.git/commit/?id=54f1c7e0e68670926a99993651db6f4e15087250))
- New `reply` command - (Awiteb at [9444fc1](https://git.4rs.nl/awiteb/n34.git/commit/?id=9444fc1a8c75dcfb9f74be5e7ea7df6759a5ce6c))
- Setup the CLI and create `repo view` command - by Awiteb
- Add `repo announce` command - by Awiteb
- Support PoW - by Awiteb
- Add `issue new` command - by Awiteb
- New `reply` command - by Awiteb
### Dependencies
- Add nedded dependencies - (Awiteb at [d37c696](https://git.4rs.nl/awiteb/n34.git/commit/?id=d37c6964f7111097550a04d25768085d4c6694d6))
- Add `easy_ext@1.0.2` to the dependencies - (Awiteb at [fb77a8c](https://git.4rs.nl/awiteb/n34.git/commit/?id=fb77a8ca141e885f351ef671680dbc3f54229303))
- Add `convert_case@0.8.0` to the dependencies - (Awiteb at [8a553f4](https://git.4rs.nl/awiteb/n34.git/commit/?id=8a553f4de85891c40965a9100f2853034919b6c8))
- Add `tempfile@3.19.1` to the dependencies - (Awiteb at [ce58f29](https://git.4rs.nl/awiteb/n34.git/commit/?id=ce58f2902246637c8ebd8b66d14cbdf958c09543))
- Add `futures@0.3.31` to the dependencies - (Awiteb at [bd08653](https://git.4rs.nl/awiteb/n34.git/commit/?id=bd086538fdeed1b39d3d8a355fc453722ab04bc2))
- Bump `nostr` and `nostr_sdk` to `0.42.0` - (Awiteb at [724e270](https://git.4rs.nl/awiteb/n34.git/commit/?id=724e2707c69fc8ac16f05331970919439619346e))
- Add nedded dependencies - by Awiteb
- Add `easy_ext@1.0.2` to the dependencies - by Awiteb
- Add `convert_case@0.8.0` to the dependencies - by Awiteb
- Add `tempfile@3.19.1` to the dependencies - by Awiteb
- Add `futures@0.3.31` to the dependencies - by Awiteb
- Bump `nostr` and `nostr_sdk` to `0.42.0` - by Awiteb
## [0.0.0] - 2025-05-01
### Added
- Initialize the project - (Awiteb at [c3594c6](https://git.4rs.nl/awiteb/n34.git/commit/?id=c3594c64671f622386a6dab12cb97b9f44c1611e))
- Initialize the project - by Awiteb
[0.4.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.4.0
[0.3.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.3.0
[0.2.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.2.0
[0.1.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.1.0
[0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0

View File

@@ -8,6 +8,22 @@ Before submitting changes, please read the [Developer Certificate of Origin](DCO
All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge
your agreement with the DCO.
Ensure your Git name and email are correctly configured. While you dont need to
use your real details, avoid leaving them as the default values. To verify your
current settings, run:
```bash
git config user.name
git config user.email
```
If theyre incorrect or unset, update them using:
```bash
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
```
We welcome all contributions, whether it be bug reports, fixes, feature
submissions, feature requests, or improving documentation or testing. Enjoy
collaborating!
@@ -21,9 +37,12 @@ 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
check the `nostr-address` file. Simply submit your issues and patches without
worrying about this detail.
## Contribution Workflow
@@ -49,6 +68,9 @@ to their read relays, most tools handle this automatically.
#### Patch Guidelines
- Keep patches small: Focused changes are easier to review and merge.
- 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
- `fix`: Bug fix
@@ -62,7 +84,12 @@ to their read relays, most tools handle this automatically.
- For all other changes, use `chore`.
- Add `!` to the subject if your patch contains a breacking change, e.g.
`remove!: text` and `fix(reply)!: text`
- Run `just ci` before submitting your patch.
- Use the `--cover-letter` flag to include a cover letter with your patch. Describe the issue youre addressing, whether its a one-line bug fix or a 5000-line new feature.
- Specify the base commit for your patch using the `--base` flag.
- First-time contributors: Review the [Submitting Patches guide](https://www.kernel.org/doc/html/latest/process/submitting-patches.html) before sending your patch.
- If you revise your patch, you should reference all previous revisions (or the
root patch if this is the first revision) and explain the changes made (i.e.,
the differences between this patch and the prior one).
#### Code Style

1946
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,79 @@
[package]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.1.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.85.1"
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to code in Nostr"
version = "0.4.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
documentation = "https://n34.dev/commands.html"
homepage = "https://n34.dev"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.88.0"
[dependencies]
clap = { version = "4.5.37", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
"tracing",
] }
convert_case = "0.8.0"
easy-ext = "1.0.2"
futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser"] }
nostr-sdk = "0.42.0"
tempfile = "3.19.1"
thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-utility = "0.3.1"
chrono = "0.4.41"
convert_case = "0.8.0"
dirs = "6.0.0"
easy-ext = "1.0.2"
either = "1.15.0"
futures = "0.3.31"
nostr-browser-signer-proxy = "0.43.0"
regex = "1.11.1"
tempfile = "3.20.0"
thiserror = "2.0.12"
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"
[dependencies.clap-verbosity-flag]
default-features = false
features = ["tracing"]
version = "3.0.3"
[dependencies.nostr]
default-features = false
features = ["std"]
version = "0.43.0"
[dependencies.nostr-connect]
default-features = false
version = "0.43.0"
[dependencies.nostr-sdk]
default-features = false
version = "0.43.0"
[dependencies.reqwest]
default-features = false
features = ["http2", "json", "rustls-tls"]
version = "0.12.22"
[dependencies.serde]
features = ["derive"]
version = "1.0.219"
[dependencies.tokio]
features = ["macros", "rt-multi-thread", "io-std"]
version = "1.47.1"
[profile.release]
lto = "fat"
opt-level = 3

View File

@@ -1,9 +1,6 @@
!!! warning "Work In Progress"
This project is still work in progress and not completed yet.
# n34
A CLI to interact with NIP-34 and other stuff related to codes in nostr
A CLI to interact with NIP-34 and other stuff related to code in Nostr
## About
@@ -16,19 +13,26 @@ The primary goal of `n34` is to implement [NIP-34] (`git` stuff), but its
flexible design allows for additional use cases beyond Git workflows. For more
details, see the following section.
## Documentation
Check the documentation at [n34.dev]
## Features
- [X] Repository announcements
- [ ] Repository state announcements
- [ ] Patches
- [X] Issues
- [X] Repository state announcements
- [X] Patches (Send, fetch and list)
- [X] Issues (Send, view and list)
- [X] Replies
- [X] Status
- [X] Issues and patches status
- [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])
- [X] Signing using bunker ([NIP-46])
- [X] Signing using [NIP-07] proxy ([nostr-browser-signer-proxy])
- [ ] Code Snippets ([NIP-C0])
- [ ] In device relays and repos bookmark
- [X] In device relays and repos bookmark (`sets` command)
## Why Nostr?
@@ -54,6 +58,59 @@ into a future-proof foundation for decentralized collaboration.
- <https://nostr.org>
- <https://nostr.how/en/what-is-nostr>
## Installation
You can install n34 either by cloning the repository and building it with Cargo,
or by using `cargo install` or Nix.
### Building from source
- Clone the repository:
```sh
git clone git://git.4rs.nl/awiteb/n34.git
cd n34
```
- Build the release version:
```sh
cargo build --release
```
The binary will be available at `target/release/n34`.
### Using cargo install
```sh
cargo install n34
```
The binary will be installed to your Cargo binary directory (typically `~/.cargo/bin/n34`).
Make sure `~/.cargo/bin` is in your `PATH` environment variable to run the binary from anywhere.
### Using `nix build` (+v0.4)
- Clone the repository.
- Run the `nix build` command.
The binary will be available at `result/bin/n34`.
### Adding it to your [home-manager] (+v0.4)
- Add it as an input to your `flake.nix`:
```nix
inputs = {
# Specify the version you want to install, or remove `?ref` for the unreleased
# version. You can also use any mirror; it doesn't have to be `git.4rs.nl`.
n34.url = "git+https://git.4rs.nl/awiteb/n34.git?ref=refs/tags/vx.y.x";
};
```
- Add it to your packages (ensure your home-manager `extraSpecialArgs` includes the `inputs`):
```nix
packages = [ inputs.n34.packages."${pkgs.system}".default ];
```
## Contributing
Contributions to `n34` are welcome! You can help by opening issues (such as bug
@@ -61,6 +118,11 @@ reports or feature requests) or submitting patches. **All contributions must be
submitted through Nostr**. For more details on the process, please refer to the
[CONTRIBUTING.md](CONTRIBUTING.md) file. Your support is greatly appreciated!
## Contributions & Changes
You can find the changelog at [CHANGELOG.md](CHANGELOG.md) and the list of
contributors at [AUTHORS](AUTHORS) file.
## License
n34 is licensed under the GPL-3.0 License. This means that you are free to use,
@@ -72,3 +134,9 @@ refer to the [LICENSE](LICENSE) file for more details.
[NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md
[NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
[NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md
[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md
[NIP-07]: https://github.com/nostr-protocol/nips/blob/master/07.md
[nostr-protocol/nips#1966]: https://github.com/nostr-protocol/nips/pull/1966
[nostr-browser-signer-proxy]: https://crates.io/crates/nostr-browser-signer-proxy
[home-manager]: https://github.com/nix-community/home-manager
[n34.dev]: https://n34.dev

10
book.toml Normal file
View File

@@ -0,0 +1,10 @@
[book]
authors = ["Awiteb"]
language = "en"
multilingual = false
src = "docs"
title = "n34 documentation"
[output.html]
git-repository-icon = "fa-git"
git-repository-url = "https://git.4rs.nl/awiteb/n34.git"

View File

@@ -22,8 +22,8 @@ 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 }} - \
({{ commit.author.name}} at [{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/?id={{ commit.id }}))\
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | trim | upper_first }} - \
by {{ commit.author.name}}\
{% endfor %}
{% endfor %}\n
"""
@@ -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" },

45
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,45 @@
# n34 documentation
- [Command Line Usage](commands.md)
- [Managing Repository and Relay Sets](sets/README.md)
- [Create a Set](sets/new.md)
- [Modify a Set](sets/update.md)
- [Show Sets](sets/show.md)
- [Remove a Set](sets/remove.md)
- [Manage Configuration](config/README.md)
- [Default PoW Difficulty](config/pow.md)
- [Fallback Relays](config/relays.md)
- [NIP-07 Browser Signer Proxy](config/nip07.md)
- [NIP-46 Bunker](config/bunker.md)
- [Secret Key Keyring](config/keyring.md)
- [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](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](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)

78
docs/commands.md Normal file
View File

@@ -0,0 +1,78 @@
# 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:
- `-s`, `--secret-key`: Your Nostr secret key (in `nsec` format), used for
signing events.
- `-b`, `--bunker-url`: The URL of a NIP-46 bunker service used for remote
signing of events.
- `-7`, `--nip07`: Enables signing events using the browser's NIP-07
extension. Listens on `127.0.0.1:51034`. You can configure the address with `n34
config nip07`
- `-r`, `--relays`: A relay to read from and write to. This option can be
specified multiple times to connect to several relays.
- `--pow`: Sets the Proof of Work difficulty required when creating events.
- `--config`: Specifies a custom path to the configuration file (Default:
`$HOME/.config/n34/config.toml`).
- `-v`, `--verbose...`: Increases the logging verbosity. Can be used multiple
times for more detail (e.g., `-v`, `-vv`).
**Note:** The `--secret-key`, `--nip07` and `--bunker-url` options are mutually
exclusive. You must provide exactly one signing method.
## Multiple Repositories
Commands that interact with a repository, such as submitting an issue or a
patch, can accept multiple repository addresses (`naddr`). This feature is
useful for projects with multiple maintainers who each have their own repository
fork.
> **Important:** When you provide multiple repositories, `n34` does not
create a separate issue or patch for each one. Instead, it creates a single
event that references all of the specified repositories.
## The `nostr-address` File
The `nostr-address` file is a plain text file that stores a list of project
repository addresses. This allows the `n34` to find and use them
without requiring you to enter the addresses manually.
### Format
- Each line must contain a single addressable event coordinate `naddr` which is
the repository address.
- Lines beginning with a `#` are treated as comments and are ignored.
- Empty lines are also ignored.
## Passing repositories
By default, `n34` will look for a `nostr-address` file to extract repositories
from it. This is why repositories are not required for commands like `patch
send` and `issue new`. You can also pass repositories using the `--repo`
option or the `<NADDR-NIP05-OR-SET>` argument for commands that accept them. The
supported formats for manual input are:
- A [NIP-19] addressable event coordinate `naddr`.
- A [NIP-05] identifier and repository name, in the format
`<nip05>/<repo-name>`.
- A set name that contains repository addresses.
You do not need to specify relays for these commands if your `naddr` or `NIP-05`
identifier already includes relays; `n34` will automatically extract them.
[NIP-19]: https://github.com/nostr-protocol/nips/blob/master/19.md
[NIP-05]: https://github.com/nostr-protocol/nips/blob/master/05.md

7
docs/config/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Manage Configuration
Configuration allows you to set default values for various command parameters,
such as fallback relays, Proof of Work (PoW) difficulty, a default bunker URL,
and your Nostr private key (keyring). This avoids the need to enter your private key,
bunker URL, relays, or PoW difficulty for every command, making `n34` more
convenient to use.

27
docs/config/bunker.md Normal file
View File

@@ -0,0 +1,27 @@
# NIP-46 Bunker
> `n34 config bunker` command
**Usage:**
```
Sets a URL of NIP-46 bunker server used for signing events
Usage: n34 config bunker [BUNKER_URL]
Arguments:
[BUNKER_URL] Nostr Connect URL for the bunker. Omit this to remove the current bunker URL
```
This command configures `n34` to use a remote signer ([NIP-46]), known as a
bunker, for all cryptographic operations.
When `n34` communicates with the bunker, it uses a persistent, locally-generated
keypair. You should add this keypair's public key to your bunker's list of
authorized applications. This allows `n34` to operate securely without needing
direct access to your main private key.
Once configured, actions such as fetching your public key or signing events are
delegated to the bunker. To remove the bunker configuration, run the command
again without providing a URL.
[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md

27
docs/config/keyring.md Normal file
View File

@@ -0,0 +1,27 @@
# Secret Key Keyring
> `n34 config keyring` command
**Usage:**
```
Manages the secret key keyring, including enabling, disabling, or resetting it.
Usage: n34 config keyring <--enable|--disable|--reset>
Options:
--enable Enables the secret key keyring. You will be prompted for your key one last time to store it.
--disable Disables the secret key keyring. This removes the stored key and prevents new ones from being saved.
--reset Resets the keyring. This deletes the current key, allowing a new one to be stored on the next use.
```
To avoid entering your private key for every command, you can enable the keyring
to store it securely. First, run `n34 config keyring --enable`. The next time
you run an `n34` command that requires your private key, it will be saved
to your system's keyring. You will not need to enter it again for subsequent
commands.
To replace the stored key with a new one, use the `--reset` flag. To stop using
the keyring and remove the stored key, use the `--disable` flag.
`n34` uses your operating system's native secret management system. For example,
it uses `keyutils` on Linux and `Keychain` on macOS.

22
docs/config/nip07.md Normal file
View File

@@ -0,0 +1,22 @@
# NIP-07 Browser Signer Proxy
> `n34 config nip07`
**Usage:**
```
Manage the NIP-07 browser signer proxy by enabling or disabling it and configuring the `ip:port` address.
Usage: n34 config nip07 [OPTIONS] <--enable|--disable>
Options:
--enable Enable NIP-07 as the default signer
--disable Disable NIP-07 as the default signer
--addr <ADDR> Set the `ip:port` for the browser signer proxy (default: 127.0.0.1:51034)
```
Use [NIP-07] (Browser Extension Signer) as your default signer. This is achieved
by running a proxy at the specified `ADDR`, which defaults to `127.0.0.1:51034`.
The proxy forwards `n34` requests to the browser signer and relays the responses
back.
[NIP-07]: https://github.com/nostr-protocol/nips/blob/master/07.md

20
docs/config/pow.md Normal file
View File

@@ -0,0 +1,20 @@
# Default PoW Difficulty
> `n34 config pow`
**Usage:**
```
Sets the default PoW difficulty (0 if not specified)
Usage: n34 config pow <DIFFICULTY>
Arguments:
<DIFFICULTY> The new default PoW difficulty
```
This command configures the default Proof of Work (PoW) difficulty for newly
created events. This setting is applied to most generated events, but it
intentionally skips patch events. Because patches can be numerous, calculating
PoW for each one would significantly slow down operations.
If you want to disable the PoW just make it 0.

21
docs/config/relays.md Normal file
View File

@@ -0,0 +1,21 @@
# Fallback Relays
> `n34 config relays` command
**Usage:**
```
Sets the default fallback relays if none provided. Use this relays for read and write
Usage: n34 config relays [OPTIONS] [RELAYS]...
Arguments:
[RELAYS]... List of relay URLs to append to fallback relays. If empty, removes all fallback relays
Options:
--override Replace existing fallback relays instead of appending new ones
```
This command configures the default fallback relays, which `n34` uses to read
from and write to. To add relays, provide their URLs as arguments to append
them to the current list. Use the `--override` flag to replace the existing list
entirely. To clear all fallback relays, run the command without any arguments.

482
docs/index.html Normal file
View File

@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta property="og:title" content="n34 - CLI for NIP-34 and Nostr Code Collaboration">
<meta property="og:description" content="An open source CLI for sending and receiving Git issues, patches and comments over the Nostr protocol.">
<meta property="og:url" content="https://n34.dev">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="n34 - CLI for NIP-34 and Nostr Code Collaboration">
<meta name="twitter:description" content="An open source CLI for sending and receiving Git issues, patches and comments over the Nostr protocol.">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>n34 - CLI for NIP-34 and Nostr Code Collaboration</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(125% 125% at 50% 90%, #000000 40%, #072607 100%);
color: #ffffff;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 60px 20px;
}
.hero {
text-align: center;
margin-bottom: 80px;
}
.hero h1 {
font-size: 4rem;
font-weight: 300;
margin-bottom: 20px;
letter-spacing: 2px;
background: linear-gradient(135deg, #ffffff, #cccccc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.2rem;
color: #cccccc;
margin-bottom: 40px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 14px 28px;
background: transparent;
border: 2px solid #ffffff;
color: #ffffff;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
font-size: 1rem;
transition: all 0.3s ease;
min-width: 120px;
text-align: center;
}
.btn:hover {
background: #ffffff;
color: #0a0a0a;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 255, 255, 0.2);
}
.section {
margin-bottom: 60px;
}
.section h2 {
font-size: 2.2rem;
margin-bottom: 30px;
color: #ffffff;
font-weight: 400;
}
.section p {
font-size: 1.1rem;
color: #e0e0e0;
margin-bottom: 20px;
text-align: justify;
}
a {
color: #cccccc;
text-decoration: underline;
font-size: 1rem;
transition: color 0.3s ease;
}
a:hover {
color: #ffffff;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-bottom: 60px;
}
.feature {
background: #1a1a1a;
padding: 30px;
border-radius: 12px;
border: 1px solid #333;
transition: transform 0.3s ease, border-color 0.3s ease;
}
.feature:hover {
transform: translateY(-5px);
border-color: #555;
}
.feature h3 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ffffff;
display: flex;
align-items: center;
gap: 10px;
}
.feature-icon {
font-size: 1.5rem;
}
.feature p {
color: #cccccc;
font-size: 1rem;
text-align: left;
}
.feature-list {
background: #111;
padding: 40px;
border-radius: 12px;
border: 1px solid #333;
margin-bottom: 60px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.feature-column ul {
list-style: none;
padding: 0;
}
.feature-column li {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 1rem;
padding: 8px 0;
}
.feature-check {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 12px;
border-radius: 3px;
text-align: center;
line-height: 20px;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.feature-check.completed {
background: #22c55e;
color: #ffffff;
}
.feature-check.pending {
background: #374151;
color: #9ca3af;
border: 1px solid #4b5563;
}
.feature-check.completed::after {
content: "✓";
}
.feature-check.pending::after {
content: "○";
}
.feature-text {
color: #e0e0e0;
}
.feature-text.pending {
color: #9ca3af;
}
.install-section {
background: #111;
padding: 40px;
border-radius: 12px;
border: 1px solid #333;
margin-bottom: 60px;
}
.install-section h2 {
margin-bottom: 20px;
text-align: center;
}
.code-block {
background: #000;
padding: 20px;
border-radius: 8px;
font-family: "Courier New", monospace;
font-size: 1rem;
border: 1px solid #333;
overflow-x: auto;
margin: 15px 0;
}
.code-block p {
color: #00ff00;
}
.code-block p::before {
content: "$ ";
color: #888;
}
.code-block span::before {
content: "$ ";
color: #888;
}
.code-block span {
color: #777;
}
.code-block pre {
color: #00ff00;
}
.links {
margin-top: 30px;
}
.links h3 {
font-size: 1.3rem;
margin-bottom: 15px;
color: #ffffff;
}
.links ul {
list-style: none;
}
.links li {
margin-bottom: 8px;
}
.links a {
color: #cccccc;
text-decoration: none;
font-size: 1rem;
transition: color 0.3s ease;
}
.links a:hover {
color: #ffffff;
}
.links a:before {
content: "→ ";
margin-right: 8px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
background: #1a4d1a;
color: #4ade80;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 20px;
}
.footer {
text-align: center;
padding: 40px 0;
border-top: 1px solid #333;
margin-top: 80px;
color: #888;
}
@media (max-width: 768px) {
.hero h1 {
font-size: 2.5rem;
}
.hero p {
font-size: 1.1rem;
}
.buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 200px;
}
.section h2 {
font-size: 1.8rem;
}
.features {
grid-template-columns: 1fr;
}
.feature-grid {
grid-template-columns: 1fr;
}
.install-section,
.feature-list {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="status-badge">✨ Looking for feedback</div>
<h1>n34</h1>
<p>
n34 is an open source command-line interface (CLI) tool for
sending and receiving Git issues, patches and comments over the
Nostr protocol. It supports creating, replying to, and managing
issues and patches, making Git collaboration decentralized and
censorship-resistant.
</p>
<div class="buttons">
<a href="https://n34.dev/commands.html" class="btn">Documentation</a>
<a href="https://git.4rs.nl/awiteb/n34.git" class="btn">Git Repository</a>
</div>
</div>
<div class="section">
<h2>Key Features</h2>
<div class="features">
<div class="feature">
<h3><span class="feature-icon">🔄</span>Complete Git Workflow</h3>
<p>Handle the full development lifecycle with patches, issues, replies, and status tracking, all through the decentralized Nostr protocol.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🔗</span><a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank" rel="noreferrer">NIP-34</a> Compliant</h3>
<p>Fully implements the <a href="https://github.com/nostr-protocol/nips/blob/master/34.md" target="_blank" rel="noreferrer">NIP-34</a> specification for Git repositories on Nostr, ensuring compatibility with the decentralized ecosystem.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🛠️</span>Developer Friendly</h3>
<p>Intuitive CLI interface designed for developers who want to integrate Git workflows with Nostr seamlessly.</p>
</div>
<div class="feature">
<h3><span class="feature-icon">🔐</span>Self-Sovereign</h3>
<p>No accounts, no passwords, no centralized servers. You control your identity and data through cryptographic keys.</p>
</div>
</div>
</div>
<div class="feature-list">
<h2>Feature Roadmap</h2>
<p style="text-align: center; margin-bottom: 30px; color: #cccccc;">Current implementation status and upcoming features</p>
<div class="feature-grid">
<div class="feature-column">
<ul>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Repository announcements</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Repository state announcements</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Patches (Send, fetch and list)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Issues (Send, view and list)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Replies</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Issues and patches status</span>
</li>
<li>
<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>
<div class="feature-column">
<ul>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Gossip Model (<a href="https://github.com/nostr-protocol/nips/blob/master/65.md" target="_blank" rel="noreferrer">NIP-65</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Proof of Work (<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noreferrer">NIP-13</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">nostr: URI scheme (<a href="https://github.com/nostr-protocol/nips/blob/master/21.md" target="_blank" rel="noreferrer">NIP-21</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">Signing using bunker (<a href="https://github.com/nostr-protocol/nips/blob/master/46.md" target="_blank" rel="noreferrer">NIP-46</a>)</span>
</li>
<li>
<span class="feature-check completed"></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>
<span class="feature-text">Secret key keyring</span>
</li>
<li>
<span class="feature-check pending"></span>
<span class="feature-text pending">Code Snippets (<a href="https://github.com/nostr-protocol/nips/blob/master/C0.md" target="_blank" rel="noreferrer">NIP-C0</a>)</span>
</li>
<li>
<span class="feature-check completed"></span>
<span class="feature-text">In device relays and repos bookmark</span>
</li>
</ul>
</div>
</div>
</div>
<div class="install-section">
<h2>Quick Start</h2>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">Get started with n34 in seconds</p>
<div class="code-block">
<p>cargo install n34</p>
<span># The execautable will be in ~/.cargo/bin/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">or</p>
<div class="code-block">
<p>git clone https://git.4rs.nl/awiteb/n34.git</p>
<p>cd n34 && cargo build --release</p>
<span># The execautable will be in target/release/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">NixOS (Version 0.4.0 or later)</p>
<div class="code-block">
<p>git clone https://git.4rs.nl/awiteb/n34.git</p>
<p>cd n34 && nix build</p>
<span># The execautable will be in result/bin/n34</span>
</div>
<p style="text-align: center; margin: 20px 0; color: #888;">home-manager</p>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">Add this to your <code>flake.nix</code> inputs
<br>
Specify the version you want to install, or remove <code>?ref</code> for the unreleased version. You can also use any mirror; it doesn't have to be <code>git.4rs.nl</code>
</p>
<div class="code-block">
<pre>
inputs = {
n34.url = "git+https://git.4rs.nl/awiteb/n34.git?ref=refs/tags/vx.y.x";
};</pre>
</div>
<p style="text-align: center; margin-bottom: 20px; color: #cccccc;">And this in your home packages</p>
<div class="code-block">
<pre>packages = [ inputs.n34.packages."${pkgs.system}".default ];</pre>
</div>
<p style="text-align: center; margin-top: 20px; color: #cccccc;">Once installed, run <code style="background: #333; padding: 2px 6px; border-radius: 4px;">n34 --help</code> to see available commands.</p>
</div>
<div class="section">
<h2>Why Nostr?</h2>
<p>
Nostr is fundamentally different from traditional platforms because it's not an application or service, it's a decentralized protocol. This means any tool or app can integrate with it, enabling open, permissionless collaboration
without relying on centralized gatekeepers. Unlike proprietary systems, Nostr doesn't require emails, passwords, or accounts. You interact directly through relays, whether you self-host your own or use public ones, ensuring no
single point of failure or control.
</p>
<p>
What makes Nostr uniquely resilient is its design, the protocol itself is just a set of rules, not a company or product that can disappear. Your Git issues, patches, and comments persist as long as relays choose to store them,
immune to the whims of corporate shutdowns or policy changes. Nostr is infrastructure in its purest form, an idea that outlives any temporary implementation. n34 taps into a future-proof foundation for decentralized collaboration.
</p>
<div class="links">
<h3>More about Nostr</h3>
<ul>
<li><a href="https://nostr.com">nostr.com</a></li>
<li><a href="https://nostr.org">nostr.org</a></li>
<li><a href="https://nostr.how/en/what-is-nostr">nostr.how/en/what-is-nostr</a></li>
</ul>
</div>
</div>
<div class="footer">
<p>Built with ❤️ for the decentralized future</p>
</div>
</div>
</body>
</html>

15
docs/issue/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Issue Management
Using `n34`, you can manage Git issues stored in Nostr relays, adhering to
the [NIP-34] standard. In Nostr, events are immutable, meaning their IDs are
derived from the SHA-256 hash of their timestamp, content, author, and tags.
As a result, issues cannot be edited directly. However, with `n34`, you can
create new issues, view existing ones, or update their status—such as closing,
resolving, or reopening them.
[NIP-34] introduces support for drafting issues, though this feature is not
currently implemented in `n34` due to the lack of a clear use case for drafting
issues. The inclusion of this functionality may stem from its shared use in both
issues and patches, suggesting it was primarily designed for patch management.
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md

19
docs/issue/close.md Normal file
View File

@@ -0,0 +1,19 @@
# Close an Issue
> `n34 issue close` command
**Usage:**
```
Closes an open issue
Usage: n34 issue close [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The open issue id to close it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified issue. The issue have to
be open.

19
docs/issue/list.md Normal file
View File

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

29
docs/issue/new.md Normal file
View File

@@ -0,0 +1,29 @@
# Create an Issue
> `n34 issue new` Command
**Usage:**
```
Create a new repository issue
Usage: n34 issue new [OPTIONS] <--content <CONTENT>|--editor>
Options:
--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
-l, --label <LABEL> Labels for the issue. Can be specified as arguments (-l bug) or hashtags in content (#bug)
```
Use the `n34 issue new` command to create a new issue in a repository. This
command supports the [NIP-21] (`nostr:` URI scheme) and hashtags within the
issue content. When you mention public keys in the content, they will be
included in the event tags. Additionally, using hashtags like `#bug` in the
issue body will automatically apply them as labels.
You must choose between the `--content` and `--editor` options. With
`--content`, you provide the issue content directly in the command. With
`--editor`, your default `$EDITOR` will open, allowing you to write the issue
content. The first line of the editor's output will be used as the issue
subject.

19
docs/issue/reopen.md Normal file
View File

@@ -0,0 +1,19 @@
# Reopen an Issue
> `n34 issue reopen` command
**Usage:**
```
Reopens a closed issue
Usage: n34 issue reopen [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The ID of the closed issue to reopen
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1630` (Open status) for the specified issue. The issue have to
be closed.

18
docs/issue/resolve.md Normal file
View File

@@ -0,0 +1,18 @@
# Resolve an Issue
> `n34 issue resolve` command
**Usage:**
```
Resolves an issue
Usage: n34 issue resolve [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The issue id to resolve it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1631` (Resolved status) event for the specified issue.

19
docs/issue/view.md Normal file
View File

@@ -0,0 +1,19 @@
# View an Issue
> `n34 issue view` command
**Usage:**
```
View an issue by its ID
Usage: n34 issue view [OPTIONS] <ISSUE_ID>
Arguments:
<ISSUE_ID> The issue id to view it
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Simply provide the issue ID in `note` or `nevent` format to retrieve and display
the issue details.

21
docs/patch/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Patch Management
In `n34`, patch management is designed to give you complete control. You can
manually generate patch files using `git-format-patch` and then broadcast them
to Nostr relays. This ensures that you have full authority over the content
and structure of your patches, allowing for precise customization as per your
requirements.
Similarly, when fetching patches, `n34` provides them to you without
automatically applying, merging, or checking them. This empowers you to review
the patches at your own pace and decide whether to merge or apply them as
needed. You retain full control over the entire process, ensuring a tailored
approach to patch management.
## Patch Status Management
You can assign a status to original patches, but revision patches do not have
a specific status assigned to them. Instead, they inherit the status of the
original patch. However, if the original patch is marked as `Applied/Merged`,
the revision patch must be explicitly tagged to claim the same status. If not
tagged, the revision patch status will be `Closed`.

31
docs/patch/apply.md Normal file
View File

@@ -0,0 +1,31 @@
# Mark as Applied
> `n34 patch apply` command
**Usage:**
```
Set an open patch status to applied
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
Options:
--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
```
Creates a kind `1631` event (Applied/Merged status) for the specified patch. The
patch must be in open status.
You can specify either an original patch or revision patch ID, but the status
event will only reference the original patch. Revision patches will be mentioned
in the event.
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'`.

20
docs/patch/close.md Normal file
View File

@@ -0,0 +1,20 @@
# Close a Patch
> `n34 patch close` command
**Usage:**
```
Closes an open or drafted patch
Usage: n34 patch close [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The open/drafted patch id to close it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1632` (Close status) for the specified patch. The patch have to
be open or drafted.

19
docs/patch/draft.md Normal file
View File

@@ -0,0 +1,19 @@
# Convert to Draft
> `n34 patch draft` command
**Usage:**
```
Converts an open patch to draft state
Usage: n34 patch draft [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The open patch id to draft it. Must be orignal root patch
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
```
Issue a kind `1633` (Draft status) for the specified patch. The patch have to
be open.

31
docs/patch/fetch.md Normal file
View File

@@ -0,0 +1,31 @@
# Fetch a Patch
> `n34 patch fetch` command
**Usage:**
```
Fetches a patch by its id
Usage: n34 patch fetch [OPTIONS] <PATCH_ID>
Arguments:
<PATCH_ID> The patch id to fetch it
Options:
--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.

20
docs/patch/list.md Normal file
View File

@@ -0,0 +1,20 @@
# List Patches
> `n34 patch list` command
**Usage:**
```
List the repositories patches
Usage: n34 patch 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 patches. By default `n34` will look for `nostr-address`
file and extract the repositories from it.

29
docs/patch/merge.md Normal file
View File

@@ -0,0 +1,29 @@
# Mark as Merged
> `n34 patch merge` command
**Usage:**
```
Set an open patch status to merged
Usage: n34 patch merge [OPTIONS] <PATCH_ID> <MERGE_COMMIT>
Arguments:
<PATCH_ID> The open patch id to merge it. Must be orignal root patch or revision root
<MERGE_COMMIT> The merge commit id
Options:
--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
```
Creates a kind `1631` event (Applied/Merged status) for the specified patch. The
patch must be in open status.
You can specify either an original patch or revision patch ID, but the status
event will only reference the original patch. Revision patches will be mentioned
in the event.
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

20
docs/patch/reopen.md Normal file
View File

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

29
docs/patch/send.md Normal file
View File

@@ -0,0 +1,29 @@
# Send Patches to a Repository
> `n34 patch send` command
**Usage:**
```
Send one or more patches to a repository
Usage: n34 patch send [OPTIONS] <PATCH-PATH>...
Arguments:
<PATCH-PATH>... Space-separated list of patch files to send. Use `-` to read from stdin.
Options:
--repo <NADDR-NIP05-OR-SET> Repository addresses
--original-patch <EVENT-ID> Original patch ID if this is a revision of it
```
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.

28
docs/reply.md Normal file
View File

@@ -0,0 +1,28 @@
# Reply to Issues and Patches
> `n34 reply` command
**Usage:**
```
Reply to issues and patches
Usage: n34 reply [OPTIONS] <--comment <COMMENT>|--editor> <nevent1-or-note1>
Arguments:
<nevent1-or-note1> The issue, patch, or comment to reply to
Options:
--quote-to Quote the replied-to event in the editor
--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)
```
Craft replies ([NIP-22] Comment) to issues, patches, or comments with ease
using the `n34 reply` command. You can either input your reply directly with
the `--comment` option or open an editor for a more detailed response using
`--editor`. Additionally, when using `--editor`, the `--quote-to` option
allows you to include the original content in your editor, enabling precise and
context-aware replies.
[NIP-22]: https://github.com/nostr-protocol/nips/blob/master/22.md

4
docs/repo/README.md Normal file
View File

@@ -0,0 +1,4 @@
# Manage Repositories
In `n34` you can manage your repositories. This includes announcing new ones,
viewing existing ones, and announcing state updates (coming soon).

33
docs/repo/announce.md Normal file
View File

@@ -0,0 +1,33 @@
# Broadcast and Update a Git Repository
> `n34 repo announce` command
**Usage:**
```
Broadcast and update a git repository
Usage: n34 repo announce [OPTIONS] --id <REPO_ID>
Options:
--id <REPO_ID> Unique identifier for the repository in kebab-case
-n, --name <NAME> A name for the repository
-d, --description <DESCRIPTION> A description for the repository
-w, --web <WEB> Webpage URLs for the repository (if provided by the git server)
-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
```
This command generates an announcement event to publish your project. It can be
used to announce a new repository or update an existing one.
When updating, you must resubmit all repository fields, not just the fields
you wish to change. The command uses this information to build and publish a
completely new announcement event that will replace the old one.
It is recommended to use the `--address-file` flag. This option creates
a `nostr-address` file that enables `n34` to automatically discover the
repository's address, simplifying the workflow for contributors.

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

20
docs/repo/view.md Normal file
View File

@@ -0,0 +1,20 @@
# View Git Repository Details
> `n34 repo view` command
**Usage:**
```
View details of a nostr git repository
Usage: n34 repo view [NADDR-NIP05-OR-SET]...
Arguments:
[NADDR-NIP05-OR-SET]... Repository addresses
```
This command prints repository details to standard output. If no arguments
are provided, it looks for a `nostr-address` file in the current directory
and displays the details for the address specified within it. See [passing
repositories] for details on accepted formats.
[passing repositories]: /commands.html#passing-repositories

11
docs/sets/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Managing Repository and Relay Sets
Sets are a convenience feature for contributing to projects that do not have a
`nostr-address` file. Instead of manually specifying the project's repositories
and relays for every command, you can define them once as a named "set". You can
then reference this set by its name in commands. This allows you to use the set
as a shortcut for a list of relays (`--relays <set_name>`) or as the project's
address in commands like `issue` and `patch`.
Sets are defined in your configuration file. To use a specific configuration
file, pass its path using the `--config` option.

25
docs/sets/new.md Normal file
View File

@@ -0,0 +1,25 @@
# Create a Set
> `n34 sets new` command
**Usage:**
```
Create a new set
Usage: n34 sets new [OPTIONS] <NAME>
Arguments:
<NAME> Unique name for the set
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 addresses
```
Each set requires a unique name, provided as the final argument to the command.
Use the `--set-relays`/`--sr` option to specify the relays for the new set;
this can be a relay URL or the name of an existing set whose relays you wish to
use. To add repositories, use the `--repo` option. Check [passing repositories]
format.
[passing repositories]: /commands.html#passing-repositories

24
docs/sets/remove.md Normal file
View File

@@ -0,0 +1,24 @@
# Remove a Set
> `n34 sets remove` command
**Usage:**
```
Remove a set, or specific repos and relays within it
Usage: n34 sets remove [OPTIONS] <NAME>
Arguments:
<NAME> Set name to delete
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 addresses
```
Removes an entire set, or specific repositories and relays from it.
Without options, this command deletes the entire set.
See the [passing repositories] section for more details on supported formats.
[passing repositories]: /commands.html#passing-repositories

17
docs/sets/show.md Normal file
View File

@@ -0,0 +1,17 @@
# Show a Set
> `n34 sets show` command
**Usage:**
```
Show a single set or all the stored sets
Usage: n34 sets show [NAME]
Arguments:
[NAME] Name of the set to display. If not provided, lists all available sets
```
Prints all set names to standard output, each followed by its associated repos
and relays. To view the details for a specific set, provide its name as an
argument.

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

@@ -0,0 +1,25 @@
# Modify a Set
> `n34 sets update` command
**Usage:**
```
Modify an existing set
Usage: n34 sets update [OPTIONS] <NAME>
Arguments:
<NAME> Name of the set to update
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 addresses
--override Replace existing relays/repositories instead of adding to them
```
Use this command to update an existing set by its name. By default, providing
relays via `--set-relay` or repositories via `--repo` will add them to the set's
existing entries. To replace the current relays and repositories with the new
values, use the `--override` flag.
[passing repositories]: /commands.html#passing-repositories

12
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1745930157,
"narHash": "sha256-y3h3NLnzRSiUkYpnfvnS669zWZLoqqI6NprtLQ+5dck=",
"lastModified": 1754498491,
"narHash": "sha256-erbiH2agUTD0Z30xcVSFcDHzkRvkRXOQ3lb887bcVrs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "46e634be05ce9dc6d4db8e664515ba10b78151ae",
"rev": "c2ae88e026f9525daf89587f3cbee584b92b6134",
"type": "github"
},
"original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1746067100,
"narHash": "sha256-6JeEbboDvRjLwB9kzCnmWj+f+ZnMtKOe5c2F1VBpaTs=",
"lastModified": 1754575663,
"narHash": "sha256-afOx8AG0KYtw7mlt6s6ahBBy7eEHZwws3iCRoiuRQS4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "026e8fedefd6b167d92ed04b195c658d95ffc7a5",
"rev": "6db0fb0e9cec2e9729dc52bf4898e6c135bb8a0f",
"type": "github"
},
"original": {

View File

@@ -20,21 +20,39 @@
in
with pkgs;
{
# n34 development environment
devShells.default = mkShell {
packages = [
cargo-msrv
git-cliff
just
mdbook
nushell
taplo
];
nativeBuildInputs = [
(lib.hiPrio rust-bin.nightly."2025-04-30".rustfmt)
(lib.hiPrio rust-bin.nightly."2025-08-07".rustfmt)
rust-bin.stable.latest.default
rust-analyzer
];
buildInputs = [ ];
};
# Build n34
packages.default =
let
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
in
with pkgs;
rustPlatform.buildRustPackage {
pname = manifest.name;
version = manifest.version;
cargoLock.lockFile = ./Cargo.lock;
src = lib.cleanSource ./.;
meta = {
inherit (manifest) description homepage;
license = lib.licenses.gpl3Plus;
};
};
}
);
}

View File

@@ -1,76 +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>
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash"]
JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n"
# Get the MSRV from the Cargo.toml
msrv := `cat Cargo.toml | grep "rust-version" | sed 's/.*"\(.*\)".*/\1/'`
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
taplo fmt --check --config ./.taplo.toml
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
taplo check --config ./.taplo.toml
# Check that the current MSRV is correct
msrv:
echo "🔧 Verifying MSRV ({{msrv}})..."
rustup -q run --install {{msrv}} cargo check -q
echo "✅ MSRV verification passed"
_done_ci:
echo "🎉 CI pipeline completed successfully"
# Update the changelog
[script]
changelog:
OLD_HASH=$(sha256sum CHANGELOG.md | head -c 64)
git-cliff > CHANGELOG.md
NEW_HASH=$(sha256sum CHANGELOG.md | head -c 64)
if [[ $OLD_HASH != $NEW_HASH ]]; then
git add CHANGELOG.md
git commit -m 'chore(changelog): Update the changelog'
echo 'The changes have been added to the changelog file and committed'
else
echo 'No changes have been added to the changelog'
fi
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
set -e
TAG_MSG=$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" > 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

15
nostr-address Normal file
View File

@@ -0,0 +1,15 @@
# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# Empty lines are ignored. Lines starting with "#" are treated as comments.
naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueqyf8wumn8ghj7mn0wd68yt35wfejumnvqyt8wumn8ghj7un9d3shjtnswf5k6ctv9ehx2aqpp4mhxue69uhkummn9ekx7mqppamhxue69uhkummnw3ezumt0d5q3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qg5waehxw309ahx7um5wghx77r5wghxgetkxpx8xj

View File

@@ -0,0 +1,46 @@
// 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::nips::nip46::NostrConnectURI;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::{N34Error, N34Result},
};
#[derive(Args, Debug)]
pub struct BunkerArgs {
/// Nostr Connect URL for the bunker. Omit this to remove the current bunker
/// URL.
bunker_url: Option<NostrConnectURI>,
}
impl CommandRunner for BunkerArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if let Some(ref bunker_url) = self.bunker_url
&& !bunker_url.is_bunker()
{
return Err(N34Error::NotBunkerUrl);
}
options.config.bunker_url = self.bunker_url;
options.config.dump()
}
}

View File

@@ -0,0 +1,62 @@
// 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::{ArgGroup, Args};
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
nostr_keyring,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct KeyringArgs {
/// Turns on secret key keyring. Requires entering the key once when
/// enabled.
#[arg(long, group = "options")]
enable: bool,
/// Turns off secret key keyring. Removes any existing key and prevents
/// storing new ones.
#[arg(long, group = "options")]
disable: bool,
/// Deletes current key and stores the next provided key.
#[arg(long, group = "options")]
reset: bool,
}
impl CommandRunner for KeyringArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.enable {
options.config.keyring_secret_key = true;
} else if self.disable {
options.config.keyring_secret_key = false;
}
if self.reset || self.disable {
let _ = nostr_keyring::user::delete().await;
}
options.config.dump()
}
}

View File

@@ -0,0 +1,60 @@
// 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>.
/// `config bunker` subcommand
mod bunker;
/// `config keyring` subcommand
mod keyring;
/// `config nip07` subcommand
mod nip07;
/// `config pow` subcommand
mod pow;
/// `config relays` subcommand
mod relays;
use clap::Subcommand;
use self::bunker::BunkerArgs;
use self::keyring::KeyringArgs;
use self::nip07::Nip07Args;
use self::pow::PowArgs;
use self::relays::RelaysArgs;
use super::CliOptions;
use crate::{cli::traits::CommandRunner, error::N34Result};
#[derive(Subcommand, Debug)]
pub enum ConfigSubcommands {
/// Sets the default PoW difficulty (0 if not specified)
Pow(PowArgs),
/// Sets the default fallback relays if none provided. Use this relays for
/// read and write.
Relays(RelaysArgs),
/// Sets a URL of NIP-46 bunker server used for signing events.
Bunker(BunkerArgs),
/// Managing the secret key keyring, including enabling, disabling, or
/// resetting it.
Keyring(KeyringArgs),
/// Controls the NIP-07 browser signer proxy, turning it on or off, and
/// configures the `ip:port` address.
Nip07(Nip07Args),
}
impl CommandRunner for ConfigSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Pow Relays Bunker Keyring Nip07)
}
}

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::net::SocketAddr;
use clap::{ArgGroup, Args};
use crate::{
cli::{CliOptions, options_state::DEFAULT_NIP07_PROXY_ADDR, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("options")
.required(true)
)
)]
pub struct Nip07Args {
/// Enable NIP-07 as the default signer.
#[arg(long, group = "options")]
enable: bool,
/// Disable NIP-07 as the default signer.
#[arg(long, group = "options", group = "disable_options")]
disable: bool,
/// Set the default `ip:port` for the browser signer proxy.
#[arg(long, group = "disable_options")]
addr: Option<SocketAddr>,
}
impl CommandRunner for Nip07Args {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.enable {
let addr = self.addr.unwrap_or(DEFAULT_NIP07_PROXY_ADDR);
options.config.nip07 = Some(addr)
} else {
options.config.nip07 = None
}
options.config.dump()
}
}

View File

@@ -0,0 +1,37 @@
// 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},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct PowArgs {
/// The new default PoW difficulty
difficulty: u8,
}
impl CommandRunner for PowArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
options.config.pow = Some(self.difficulty);
options.config.dump()
}
}

View File

@@ -0,0 +1,53 @@
// 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::types::RelayUrl;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct RelaysArgs {
/// List of relay URLs to append to fallback relays. If empty, removes all
/// fallback relays.
relays: Vec<RelayUrl>,
/// Replace existing fallback relays instead of appending new ones.
#[arg(long = "override")]
override_relays: bool,
}
impl CommandRunner for RelaysArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
if self.relays.is_empty() {
options.config.fallback_relays = None;
} else if self.override_relays {
options.config.fallback_relays = Some(self.relays);
} else {
let mut relays = options.config.fallback_relays.clone().unwrap_or_default();
relays.extend(self.relays);
relays.sort_unstable();
relays.dedup();
options.config.fallback_relays = Some(relays);
}
options.config.dump()
}
}

View File

@@ -0,0 +1,71 @@
// 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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
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 issue id to close it
issue_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Closed,
|issue_status| {
if issue_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed issue".to_owned(),
));
}
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't close a resolved issue".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,57 @@
// 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(Debug, Args)]
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 issues 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::Issue as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -0,0 +1,152 @@
// 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>.
/// `issue close` subcommand
mod close;
/// `issue list` subcommand
mod list;
/// `issue new` subcommand
mod new;
/// `issue reopen` subcommand
mod reopen;
/// `issue resolve` subcommand
mod resolve;
/// `issue view` subcommand
mod view;
use std::fmt;
use clap::Subcommand;
use nostr::event::Kind;
use self::close::CloseArgs;
use self::list::ListArgs;
use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::resolve::ResolveArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
/// Prefix used for git issue alt.
pub const ISSUE_ALT_PREFIX: &str = "git issue: ";
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create an issue
New(NewArgs),
/// View an issue
View(ViewArgs),
/// Reopen an issue.
Reopen(ReopenArgs),
/// Closes an issue.
Close(CloseArgs),
/// Resolve an issue.
Resolve(ResolveArgs),
/// List issues.
List(ListArgs),
}
/// Possible states for a Git issue
#[derive(Debug)]
pub enum IssueStatus {
/// The issue is currently open
Open,
/// The issue has been resolved
Resolved,
/// The issue has been closed
Closed,
}
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 const fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::Resolved => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
}
}
/// Returns the string representation of the issue status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::Resolved => "Resolved",
Self::Closed => "Closed",
}
}
/// Check if the issue is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the issue is resolved.
#[inline]
pub fn is_resolved(&self) -> bool {
matches!(self, Self::Resolved)
}
/// Check if the issue is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
}
impl From<&IssueStatus> for Kind {
fn from(status: &IssueStatus) -> Self {
status.kind()
}
}
impl fmt::Display for IssueStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for IssueStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::Resolved),
Kind::GitStatusClosed => Ok(Self::Closed),
_ => Err(N34Error::InvalidIssueStatus(kind)),
}
}
}
impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & New View Reopen Close Resolve List)
}
}

View File

@@ -0,0 +1,143 @@
// 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::{ArgGroup, Args};
use nostr::event::{EventBuilder, Tag};
use crate::{
cli::{
CliOptions,
CommandRunner,
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, NewGitRepositoryAnnouncement, ReposUtils},
utils,
},
};
/// Arguments for the `issue new` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("issue-subject")
.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>>,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[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, group = "issue-subject", group = "issue-content")]
editor: bool,
/// The issue subject. Cannot be used together with the `--editor` flag.
#[arg(long, group = "issue-subject")]
subject: Option<String>,
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
/// in content (#bug).
#[arg(short, long)]
label: Vec<String>,
}
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 user_pubk = client.pubkey().await?;
let coordinates = naddrs.clone().into_coordinates();
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
client.add_relays(&repos.extract_relays()).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, content) = utils::subject_and_body(self.subject, self.content, ".md")?;
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()
.map(|c| c.into_tags())
.unwrap_or_default(),
)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
naddrs.extract_relays(),
utils::add_write_relays(relays_list.as_ref()),
repos.extract_relays(),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
content_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!("Issue created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,71 @@
// 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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
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 issue id to reopen it
issue_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Open,
|issue_status| {
if issue_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't reopen an open issue".to_owned(),
));
}
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't open a resolved issue".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,65 @@
// 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 super::IssueStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ResolveArgs {
/// 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 issue id to resolve it
issue_id: NostrEvent,
}
impl CommandRunner for ResolveArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::issue_status_command(
options,
self.issue_id,
self.naddrs,
IssueStatus::Resolved,
|issue_status| {
if issue_status.is_resolved() {
return Err(N34Error::InvalidStatus(
"You can't resolve an resolved issue".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,53 @@
// 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(Debug, Args)]
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>>,
/// The issue id to view it
issue_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::<false>(options, self.naddrs, self.issue_id)
.await
}
}

210
src/cli/commands/mod.rs Normal file
View File

@@ -0,0 +1,210 @@
// 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>.
/// `config` subcommands
pub mod config;
/// `issue` subcommands
pub mod issue;
/// `patch` subcommands
pub mod patch;
/// `pr` subcommands
pub mod pr;
/// `reply` command
pub mod reply;
/// `repo` subcommands
pub mod repo;
/// `sets` subcommands
pub mod sets;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use clap::{Args, Parser};
use nostr::key::{Keys, SecretKey};
use nostr::nips::nip46::NostrConnectURI;
use nostr::signer::{IntoNostrSigner, NostrSigner};
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;
use super::CliConfig;
use super::options_state::OptionsState;
use super::types::RelayOrSet;
use super::{parsers, traits::CommandRunner};
use crate::cli::Cli;
use crate::cli::types::EchoAuthUrl;
use crate::error::{N34Error, N34Result};
/// Default path used when no path is provided via command line arguments.
///
/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
/// How long to wait for bunker response (3 minutes).
const BUNKER_TIMEOUT: Duration = Duration::from_secs(60 * 3);
/// The command-line interface options
#[derive(Args)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long, group = "signer")]
pub secret_key: Option<SecretKey>,
/// NIP-46 bunker url used for signing events
#[arg(short, long, group = "signer", value_parser = parsers::parse_bunker_url)]
pub bunker_url: Option<NostrConnectURI>,
/// Enables signing events using the browser's NIP-07 extension. Listens on
/// `127.0.0.1:51034`.
#[arg(short = '7', long, group = "signer")]
pub nip07: bool,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// 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")]
pub pow: Option<u8>,
/// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
#[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
hide_default_value = true, value_parser = parsers::parse_config_path
)]
pub config: CliConfig,
/// The state of options. Some values that are used by them but should not
/// be entered via the CLI
#[arg(skip)]
pub state: OptionsState,
}
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
/// Manage repositories and relays sets
Sets {
#[command(subcommand)]
subcommands: SetsSubcommands,
},
/// Manage repositories
Repo {
#[command(subcommand)]
subcommands: RepoSubcommands,
},
/// Manage issues
Issue {
#[command(subcommand)]
subcommands: IssueSubcommands,
},
/// Manage patches
Patch {
#[command(subcommand)]
subcommands: PatchSubcommands,
},
/// Manage pull requests
Pr {
#[command(subcommand)]
subcommands: PrSubcommands,
},
/// Manage configuration
Config {
#[command(subcommand)]
subcommands: ConfigSubcommands,
},
/// Reply to issues and patches.
Reply(ReplyArgs),
}
impl CliOptions {
/// Returns the signer
pub async fn signer(&self) -> N34Result<Option<Arc<dyn NostrSigner + 'static>>> {
if self.nip07 {
self.state.browser_signer_proxy.start().await?;
println!(
"Browser signer proxy started at: {}",
self.state.browser_signer_proxy.url()
);
// FIXME: Use `BrowserSignerProxy::is_session_active` after it release
// nostr@0.44.0
tokio::time::sleep(Duration::from_secs(10)).await;
return Ok(Some(
self.state.browser_signer_proxy.clone().into_nostr_signer(),
));
}
if let Some(sk) = &self.secret_key {
return Ok(Some(Keys::new(sk.clone()).into_nostr_signer()));
}
if let Some(ref bunker_url) = self.bunker_url {
let mut nostrconnect = NostrConnect::new(
bunker_url.clone(),
Cli::n34_keypair().await?,
BUNKER_TIMEOUT,
None,
)
.expect("It's a bunker url and not a client");
nostrconnect.auth_url_handler(EchoAuthUrl);
return Ok(Some(nostrconnect.into_nostr_signer()));
}
Ok(None)
}
/// Returns an error if there are no relays.
pub fn ensure_relays(&self) -> N34Result<()> {
if self.relays.is_empty() {
return Err(N34Error::EmptyRelays);
}
Ok(())
}
/// Returns an error if there are no signers
pub fn ensure_signer(&self) -> N34Result<()> {
if self.secret_key.is_none() && self.bunker_url.is_none() && !self.nip07 {
return Err(N34Error::SignerRequired);
}
Ok(())
}
}
impl fmt::Debug for CliOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
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)
.finish()
}
}
impl CommandRunner for Commands {
async fn run(self, options: CliOptions) -> N34Result<()> {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
crate::run_command!(self, options, Repo Issue Sets Patch Pr Config & Reply)
}
}

View File

@@ -0,0 +1,85 @@
// 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, VecNostrEventExt},
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 patch id to apply it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been applied. Use this when only some patches have
/// been applied, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
applied_patches: Vec<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::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchPrStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
self.applied_patches.into_event_ids(),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't apply an already applied patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't apply a closed patch".to_owned(),
));
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus("Cannot apply a draft".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/drafted patch id to close it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchPrStatus::Closed,
None,
Vec::new(),
|patch_status| {
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't close a merged/applied patch".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 patch id to draft it. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchPrStatus::Draft,
None,
Vec::new(),
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already draft patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't draft a closed patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't draft a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,160 @@
// 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::{fs, ops::Deref, path::PathBuf, str::FromStr};
use clap::Args;
use nostr::{
event::{Kind, TagKind},
filter::Filter,
nips::nip19::ToBech32,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Debug, Args)]
pub struct FetchArgs {
/// 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>>,
/// 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
patch_id: NostrEvent,
}
impl CommandRunner for FetchArgs {
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;
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(
&[
naddrs.extract_relays(),
self.patch_id.relays,
client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
]
.concat(),
)
.await;
let root_patch = client
.fetch_event(
Filter::new()
.id(self.patch_id.event_id)
.kind(Kind::GitPatch),
)
.await?
.ok_or(N34Error::CanNotFoundPatch)?;
if !root_patch
.tags
.iter()
.any(|t| t.kind() == TagKind::t() && t.content().is_some_and(|c| c == "root"))
{
return Err(N34Error::NotRootPatch);
}
let root_author = root_patch.pubkey;
let root_patch = super::GitPatch::from_str(&root_patch.content)
.map_err(|err| N34Error::InvalidEvent(format!("Failed to parse the patch: {err}")))?;
tracing::info!("Found the root patch: `{}`", root_patch.subject);
let mut patches = client
.fetch_patch_series(self.patch_id.event_id, root_author)
.await?
.into_iter()
.map(|p| {
let patch = super::GitPatch::from_str(&p.content).map_err(|err| {
N34Error::InvalidPatch(format!(
"Failed to parse the patch `{}`: {err}",
p.id.to_bech32().expect("Infallible")
))
})?;
N34Result::Ok((patch.filename(&output_path)?, patch))
})
.collect::<N34Result<Vec<_>>>()?;
patches.push((root_patch.filename(&output_path)?, root_patch));
patches.sort_unstable_by_key(|p| p.0.clone());
patches.dedup_by_key(|p| p.0.clone());
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

@@ -0,0 +1,57 @@
// 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(Debug, Args)]
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::Patch as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -0,0 +1,87 @@
// 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, VecNostrEventExt},
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 patch id to merge it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// Patches that have been merged. Use this when only some patches have been
/// merged, not all.
#[arg(long = "patches", value_name = "PATCH-EVENT-ID")]
merged_patches: Vec<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::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
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/applied patch".to_owned(),
));
}
if patch_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't merge a closed patch".to_owned(),
));
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a draft patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,224 @@
// 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>.
/// `patch apply` suubcommand
mod apply;
/// `patch close` subcommand
mod close;
/// `patch draft` subcommand
mod draft;
/// `patch fetch` subcommand
mod fetch;
/// `patch list` subcommand
mod list;
/// `patch merge` subcommand
mod merge;
/// `patch reopen` subcommand
mod reopen;
/// `patch send` subcommand
mod send;
#[cfg(test)]
mod tests;
use std::{
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand;
use regex::Regex;
use self::apply::ApplyArgs;
use self::close::CloseArgs;
use self::draft::DraftArgs;
use self::fetch::FetchArgs;
use self::list::ListArgs;
use self::merge::MergeArgs;
use self::reopen::ReopenArgs;
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());
/// Regular expression for extracting the patch body.
static BODY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap());
/// Regular expiration for extracting the patch version and number
static PATCH_VERSION_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[PATCH\s+(?:v(?<version>\d+)\s*)?(?<number>\d+)/(?:\d+)").unwrap()
});
/// Content of the hashtag representing the root patch.
pub const ROOT_HASHTAG_CONTENT: &str = "root";
/// Content of the hashtag representing the root revision patch.
pub const REVISION_ROOT_HASHTAG_CONTENT: &str = "root-revision";
/// The content of the hashtag used by `ngit-cli` to represent a root revision
/// patch before the commit 6ae42e67d9da36f6c2e1356acba30a3a62112bc7. This was a
/// typo.
pub const LEGACY_NGIT_REVISION_ROOT_HASHTAG_CONTENT: &str = "revision-root";
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
/// Send patches to a repository
Send(SendArgs),
/// Fetch a patch
Fetch(FetchArgs),
/// Close a patch
Close(CloseArgs),
/// Convert to draft
Draft(DraftArgs),
/// Reopen a patch
Reopen(ReopenArgs),
/// Mark as applied
Apply(ApplyArgs),
/// Mark as merged
Merge(MergeArgs),
/// List patches
List(ListArgs),
}
/// Represents a git patch
#[derive(Clone, Debug)]
pub struct GitPatch {
/// Full content of the patch file
pub inner: String,
/// Short description of the patch changes
pub subject: String,
/// Detailed explanation of the patch changes
pub body: String,
}
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
let (patch_version, patch_number) = if self.subject.contains("[PATCH]") {
(String::new(), "1")
} else {
patch_version_and_subject(&self.subject)?
};
let patch_name = if patch_number == "0" {
"cover-letter".to_owned()
} else {
patch_file_name(&self.subject)?
};
Ok(parent
.as_ref()
.join(format!("{patch_version}{patch_number:0>4}-{patch_name}").replace("--", "-"))
.with_extension("patch"))
}
}
impl CommandRunner for PatchSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge List)
}
}
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)
.and_then(|cap| cap.get(1))
.ok_or("No subject found")?
.as_str()
.trim()
.replace('\n', "")
.to_string();
// Regex for body
let body = BODY_RE
.captures(patch_content)
.and_then(|cap| cap.get(1))
.ok_or("No body found")?
.as_str()
.trim()
.to_string();
Ok(Self {
inner: patch_content.to_owned(),
subject,
body,
})
}
}
/// Extracts the version prefix and patch number from a patch subject string.
///
/// The version prefix is formatted as "v{version}-" if present, or an empty
/// string. The patch number is mandatory and will cause an error if not found.
fn patch_version_and_subject(subject: &str) -> N34Result<(String, &str)> {
let captures = PATCH_VERSION_NUMBER_RE.captures(subject).ok_or_else(|| {
N34Error::InvalidEvent(format!("Can not parse the patch subject `{subject}`"))
})?;
Ok((
captures
.name("version")
.map(|m| format!("v{}-", m.as_str()))
.unwrap_or_default(),
captures
.name("number")
.map(|m| m.as_str())
.expect("It's not optional, regex will fail if it's not found"),
))
}
/// Extracts a clean file name from the patch subject by removing version info
/// and special characters. Converts to lowercase and ensures the name only
/// contains alphanumeric, '.', '-', or '_' characters.
fn patch_file_name(subject: &str) -> N34Result<String> {
Ok(subject
.split_once("]")
.ok_or_else(|| {
N34Error::InvalidEvent(format!(
"Invalid patch subject. No `[PATCH ...]`: `{subject}`",
))
})?
.1
.trim()
.to_lowercase()
.replace(
|c: char| !c.is_ascii_alphanumeric() && !['.', '-', '_'].contains(&c),
"-",
)
.chars()
.take(60)
.collect::<String>()
.trim_matches('-')
.trim()
.replace("--", "-"))
}

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. Must be orignal root patch
patch_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
PatchPrStatus::Open,
None,
Vec::new(),
|patch_status| {
if patch_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't open an already open patch".to_owned(),
));
}
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't open a merged/applied patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,285 @@
// 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::{fs, str::FromStr};
use clap::Args;
use futures::future;
use nostr::{
event::{EventBuilder, EventId, Kind, Tag, TagKind, Tags, UnsignedEvent},
hashes::sha1::Hash as Sha1Hash,
key::PublicKey,
nips::{nip01::Coordinate, nip10::Marker},
types::RelayUrl,
};
use super::GitPatch;
use crate::{
cli::{
CliOptions,
patch::{REVISION_ROOT_HASHTAG_CONTENT, ROOT_HASHTAG_CONTENT},
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt, VecPatchesExt},
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// Prefix used for git patch alt.
const PATCH_ALT_PREFIX: &str = "git patch: ";
#[derive(Args, Debug)]
pub struct SendArgs {
/// 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>>,
/// Space-separated list of patch files to send. Use `-` to read from stdin.
///
/// 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<Option<GitPatch>>,
/// Original patch ID if this is a revision of it
#[arg(long, value_name = "EVENT-ID")]
original_patch: Option<NostrEvent>,
}
impl CommandRunner for SendArgs {
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 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;
let user_pubk = client.pubkey().await?;
client.add_relays(&naddrs.extract_relays()).await;
if let Some(original_patch) = &self.original_patch {
client.add_relays(&original_patch.relays).await;
}
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let repos = client.fetch_repos(&repo_coordinates).await?;
let euc = repos.extract_euc();
let maintainers = repos.extract_maintainers();
client.add_relays(&repos.extract_relays()).await;
let (events, events_write_relays) = make_patch_series(
&client,
patches,
self.original_patch.as_ref().map(|e| e.event_id),
repos.extract_relays().first().cloned(),
repo_coordinates,
euc,
user_pubk,
)
.await?;
let write_relays = [
relays,
repos.extract_relays(),
events_write_relays,
naddrs.extract_relays(),
self.original_patch.map(|e| e.relays).unwrap_or_default(),
utils::add_write_relays(relays_list.as_ref()),
client.read_relays_from_users(&maintainers).await,
]
.concat();
tracing::trace!(write_relays = ?write_relays, "Write relays of the patches");
let nevents = future::join_all(events.into_iter().map(|mut event| {
async {
let event_id = event.id();
let subject = event
.tags
.find(TagKind::Alt)
.and_then(Tag::content)
.expect("There is an alt")
.replace(PATCH_ALT_PREFIX, "");
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await
.map(|r| Ok((subject, utils::new_nevent(event_id, &r)?)))?
}
}))
.await
.into_iter()
.collect::<N34Result<Vec<_>>>()?;
for (subject, nevent) in nevents {
println!("Created '{subject}': {nevent}");
}
Ok(())
}
}
/// 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).map(Option::Some)
}
async fn make_patch_series(
client: &NostrClient,
patches: Vec<GitPatch>,
original_patch: Option<EventId>,
relay_hint: Option<RelayUrl>,
repo_coordinates: Vec<Coordinate>,
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> N34Result<(Vec<UnsignedEvent>, Vec<RelayUrl>)> {
let mut write_relays = Vec::new();
let mut patch_series = Vec::new();
let mut patches = patches.into_iter();
let root_patch = patches.next().expect("Patches can't be empty");
let (root_event, root_relays) = make_patch(
client,
root_patch,
None,
original_patch,
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
write_relays.extend(root_relays);
let root_id = *root_event.id.as_ref().expect("There is an id");
let mut previous_patch = root_id;
patch_series.push(root_event);
for patch in patches {
let (patch_event, patch_relays) = make_patch(
client,
patch,
Some(root_id),
Some(previous_patch),
relay_hint.as_ref(),
&repo_coordinates,
euc,
author_pkey,
)
.await;
previous_patch = patch_event.id.expect("there is an id");
write_relays.extend(patch_relays);
patch_series.push(patch_event);
}
Ok((patch_series, write_relays))
}
#[allow(clippy::too_many_arguments)]
async fn make_patch(
client: &NostrClient,
patch: GitPatch,
root: Option<EventId>,
reply_to: Option<EventId>,
write_relay: Option<&RelayUrl>,
repo_coordinates: &[Coordinate],
euc: Option<&Sha1Hash>,
author_pkey: PublicKey,
) -> (UnsignedEvent, Vec<RelayUrl>) {
let content_details = client.parse_content(&patch.body).await;
let content_relays = content_details.write_relays.clone();
// NIP-34 compliance requires referencing the previous patch using `NIP-10 e
// reply`. However, this fails for the second patch when
// `EventBuilder::dedup_tags` is enabled because:
// 1. The tag is treated as a duplicate based on its content (the root ID).
// 2. The second patch would reply to the root twice:
// - First with the 'root' marker
// - Then with the 'reply' marker
// The `EventBuilder::dedup_tags` function then removes the 'reply' marker as a
// duplicate.
let mut safe_dedup_tags = Tags::new();
safe_dedup_tags.push(Tag::alt(format!("{PATCH_ALT_PREFIX}{}", patch.subject)));
safe_dedup_tags.push(Tag::description(patch.subject));
safe_dedup_tags.extend(content_details.into_tags());
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::coordinate(c.clone(), None)),
);
safe_dedup_tags.extend(
repo_coordinates
.iter()
.map(|c| Tag::public_key(c.public_key)),
);
if let Some(euc) = euc {
safe_dedup_tags.push(Tag::reference(euc.to_string()));
}
safe_dedup_tags.dedup();
let mut event_builder = EventBuilder::new(Kind::GitPatch, patch.inner).tags(safe_dedup_tags);
// If the root is None, this indicates we're handling the root event
if let Some(root_id) = root {
event_builder =
event_builder.tag(utils::event_reply_tag(&root_id, write_relay, Marker::Root));
} else {
event_builder = event_builder.tag(Tag::hashtag(ROOT_HASHTAG_CONTENT));
}
// Handles the case where there is a patch to reply to but no root. This
// indicates we are processing a revision, as the root revision should reply
// directly to the original patch.
if let Some(reply_to_id) = reply_to {
if root.is_none() {
event_builder = event_builder.tags([
utils::event_reply_tag(&reply_to_id, write_relay, Marker::Reply),
Tag::hashtag(REVISION_ROOT_HASHTAG_CONTENT),
]);
} else {
event_builder = event_builder.tag(utils::event_reply_tag(
&reply_to_id,
write_relay,
Marker::Reply,
));
}
}
(
event_builder.build(author_pkey),
content_relays.into_iter().collect(),
)
}

View File

@@ -0,0 +1,351 @@
// 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 super::*;
#[test]
fn patch_normal() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_normal_with_patch_in_content() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Subject in subject
A good test patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: What a subject
hi
---
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: Subject in subject");
assert_eq!(patch.body, "A good test patch");
}
#[test]
fn patch_multiline_subject() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: Some long subject yes so long one Some long subject yes
so long one
Abc patch
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH] chore: Some long subject yes so long one Some long subject yes so long one"
);
assert_eq!(patch.body, "Abc patch");
}
#[test]
fn patch_multiline_body() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 27 May 2025 19:20:42 +0000
Subject: [PATCH] chore: a to abc
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
---
src/nostr_utils/mod.rs | 1 +
1files changed, 3 insertions(+), 1 deletions(-)
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 4120f5a..e68783c 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
- a
+ abc
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH] chore: a to abc");
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur."
);
}
#[test]
fn patch_cover_letter() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(patch.subject, "[PATCH v2 0/7] feat: Some test just a test");
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_subject() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Cover body
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn patch_multiline_cover_body() {
let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
From: Awiteb <a@4rs.nl>
Date: Tue, 3 Jun 2025 08:41:12 +0000
Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
test just a test
Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a
--
2.49.0"#;
let patch = GitPatch::from_str(patch_content).unwrap();
assert_eq!(
patch.subject,
"[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a test"
);
assert_eq!(
patch.body,
"Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
aspernatur.
Awiteb (1):
chore: Update `README.md`
README.md | 2 +-
base-commit: f670859b92d525874fd621452080c8479964ac6a"
);
}
#[test]
fn normal_patch_filename() {
let mut patch = GitPatch {
inner: String::new(),
subject: String::new(),
body: String::new(),
};
patch.subject = "[PATCH v2 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0000-cover-letter.patch")
);
patch.subject = "[PATCH 0/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0000-cover-letter.patch")
);
patch.subject = "[PATCH v2 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v2-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v42 23/30] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v42-0023-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 1/3] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0001-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH 32/50] feat: Some test just a test".to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("0032-feat-some-test-just-a-test.patch")
);
patch.subject = "[PATCH v100 32/50] feat: some long subject some long subject some long \
subject some long subject"
.to_owned();
assert_eq!(
patch.filename("").unwrap(),
PathBuf::from("v100-0032-feat-some-long-subject-some-long-subject-some-long-subject.patch")
);
}
#[test]
fn patch_filename_without_patch() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[RFC v5 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_number() {
let mut patch = GitPatch {
inner: String::new(),
subject: "[PATCH v5 /2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_err());
patch.subject = "[PATCH v5 2/] Something".to_owned();
assert!(patch.filename("").is_err());
}
#[test]
fn patch_filename_without_version() {
let patch = GitPatch {
inner: String::new(),
subject: "[PATCH 1/2] Something".to_owned(),
body: String::new(),
};
assert!(patch.filename("").is_ok());
}

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
}
}

236
src/cli/commands/reply.rs Normal file
View File

@@ -0,0 +1,236 @@
// 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::fs;
use clap::{ArgGroup, Args};
use futures::future;
use nostr::{
event::{Event, EventBuilder, Kind},
filter::Filter,
nips::nip01::Coordinate,
types::RelayUrl,
};
use super::{CliOptions, CommandRunner};
use crate::{
cli::{
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
/// The max date "9999-01-01 at 00:00 UTC"
const MAX_DATE: i64 = 253370764800;
/// Arguments for the `reply` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("comment-content")
.args(["comment", "editor"])
.required(true)
),
group(
ArgGroup::new("quote-reply-to")
.args(["comment", "quote_to"])
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(value_name = "nevent1-or-note1")]
to: NostrEvent,
/// Quote the replied-to event in the editor
#[arg(long)]
quote_to: bool,
/// 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 comment (cannot be used with --editor)
#[arg(short, long)]
comment: Option<String>,
/// Open editor to write comment (cannot be used with --content)
#[arg(short, long)]
editor: bool,
}
impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let nostr_address_path = 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?;
let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&options.config.sets)? {
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else if fs::exists(&nostr_address_path).is_ok() {
let naddrs = utils::naddrs_or_file(None, &nostr_address_path)?;
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else {
None
};
client.add_relays(&self.to.relays).await;
let relays_list = client.user_relays_list(user_pubk).await?;
let author_read_relays =
utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref());
client.add_relays(&author_read_relays).await;
let reply_to = client
.fetch_event(Filter::new().id(self.to.event_id))
.await?
.ok_or(N34Error::EventNotFound)?;
let root = client.find_root(reply_to.clone()).await?;
let repos_coordinate = if let Some(naddrs) = repo_naddrs {
naddrs.into_coordinates()
} else if let Some(ref root_event) = root {
coordinates_from_root(root_event)?
} else {
return Err(N34Error::NotFoundRepo);
};
let repos = client.fetch_repos(&repos_coordinate).await?;
let maintainers = repos.extract_maintainers();
let quoted_content = if self.quote_to {
Some(quote_reply_to_content(&client, &reply_to).await)
} else {
None
};
let content = utils::get_content(self.comment.as_ref(), quoted_content.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repos.first().and_then(|r| r.relays.first()).cloned(),
)
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(content_details.clone().into_tags())
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Merge repository announcement relays into write relays
repos.extract_relays(),
// Include read relays for each repository maintainer (if found)
client.read_relays_from_users(&maintainers).await,
// read relays of the root event and the reply to event
{
let (r1, r2) = future::join(
client.read_relays_from_user(reply_to.pubkey),
event_author_read_relays(&client, root.as_ref()),
)
.await;
[r1, r2].concat()
},
content_details.write_relays.into_iter().collect(),
]
.concat();
tracing::trace!(relays = ?write_relays, "Write relays list");
let (success, ..) = futures::join!(
client.send_event_to(event, relays_list.as_ref(), &write_relays),
client.broadcast(&reply_to, &author_read_relays),
async {
if let Some(root_event) = root {
let _ = client.broadcast(&root_event, &author_read_relays).await;
}
},
);
let nevent = utils::new_nevent(event_id, &success?)?;
println!("Comment created: {nevent}");
Ok(())
}
}
/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC,
/// {author} wrote:" followed by the event content. Uses display name if
/// available, otherwise falls back to a shortened npub string. Dates are
/// formatted in UTC.
async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String {
let author_name = client.get_username(quoted_event.pubkey).await;
let fdate = chrono::DateTime::from_timestamp(
quoted_event
.created_at
.as_u64()
.try_into()
.unwrap_or(MAX_DATE),
0,
)
.map(|datetime| datetime.format("On %F at %R UTC, ").to_string())
.unwrap_or_default();
format!(
"{fdate}{author_name} wrote:\n> {}",
quoted_event.content.trim().replace("\n", "\n> ")
)
}
/// Gets the repository coordinate from a root Nostr event's tags.
/// The event must contain a coordinate tag with GitRepoAnnouncement kind.
fn coordinates_from_root(root: &Event) -> N34Result<Vec<Coordinate>> {
let coordinates: Vec<Coordinate> = root
.tags
.coordinates()
.filter(|c| c.kind == Kind::GitRepoAnnouncement)
.cloned()
.collect();
if coordinates.is_empty() {
return Err(N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
));
}
Ok(coordinates)
}
/// Returns the event author read relays if found, otherwise an empty vector
async fn event_author_read_relays(client: &NostrClient, event: Option<&Event>) -> Vec<RelayUrl> {
if let Some(root_event) = event {
client.read_relays_from_user(root_event.pubkey).await
} else {
Vec::new()
}
}

View File

@@ -0,0 +1,169 @@
// 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::{fs, io::Write};
use clap::Args;
use futures::future;
use nostr::{
event::{EventBuilder, Tag},
key::PublicKey,
types::Url,
};
use crate::{
cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, traits::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Header written to new `nostr-address` files. Contains two trailing newline
/// for formatting.
const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this
# project's issues and patches.
#
# The file acts as a **read-only reference** for retrieving repository relays
# when embedded in an `naddr` and mentions those repositories when opening
# patches or issues. Modifications here will not affect in the relays, as the
# file is **explicitly untracked**. Its goal is to simplify contributions by
# removing the need for manual address entry.
#
# Each entry must start with "naddr". Embedded relays are **strongly recommended**
# to assist client-side discovery.
#
# 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)]
pub struct AnnounceArgs {
/// Unique identifier for the repository in kebab-case.
#[arg(long = "id")]
repo_id: String,
/// A name for the repository.
#[arg(short, long)]
name: Option<String>,
/// A description for the repository.
#[arg(short, long)]
description: Option<String>,
/// Webpage URLs for the repository (if provided by the git server).
#[arg(short, long)]
web: Vec<Url>,
/// URLs for cloning the repository.
#[arg(short, long)]
clone: Vec<Url>,
/// Additional maintainers of the repository (besides yourself).
#[arg(short, long)]
maintainers: Vec<PublicKey>,
/// Labels to categorize the repository. Can be specified multiple times.
#[arg(short, long)]
label: Vec<String>,
/// 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)]
address_file: bool,
}
impl CommandRunner for AnnounceArgs {
const NEED_RELAYS: bool = true;
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &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;
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
let mut event_builder = EventBuilder::new_git_repo(
self.repo_id,
self.name.map(utils::str_trim),
self.description.map(utils::str_trim),
self.web,
self.clone,
relays.clone(),
self.maintainers.clone(),
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
)?
.dedup_tags()
.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);
if !address_path.exists() {
tracing::info!(
"Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \
header",
address_path.display()
);
fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?;
}
let mut file = fs::OpenOptions::new().append(true).open(&address_path)?;
tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'");
file.write_all(format!("{naddr}\n").as_bytes())?;
tracing::info!("Successfully wrote naddr to address file");
}
let write_relays = [
relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
future::join_all(
self.maintainers
.iter()
.map(|pkey| client.read_relays_from_user(*pkey)),
)
.await
.into_iter()
.flatten()
.collect(),
]
.concat();
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
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,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;
@@ -30,17 +33,14 @@ use crate::error::N34Result;
pub enum RepoSubcommands {
/// View details of a nostr git repository
View(ViewArgs),
/// Publish information about a git repository to Nostr for collaboration
/// and feedback. Can also be used to update an existing repository's
/// details.
/// Broadcast and update a git repository
Announce(AnnounceArgs),
/// Repository state announcements
State(StateArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::View(args) => args.run(options).await,
Self::Announce(args) => args.run(options).await,
}
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

@@ -0,0 +1,99 @@
// 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::nips::nip19::ToBech32;
use crate::{
cli::{
CliOptions,
CommandRunner,
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Arguments for the `repo view` command
#[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", value_delimiter = ',')]
naddrs: Option<Vec<NaddrOrSet>>,
}
impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
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;
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
let mut repos_details: Vec<String> = Vec::new();
for repo in repos {
let mut repo_details = format!("ID: {}", repo.id);
if let Some(name) = repo.name {
repo_details.push_str(&format!("\nName: {name}"));
}
if let Some(desc) = repo.description {
repo_details.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
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{}",
utils::format_iter(repo.clone)
));
}
if !repo.relays.is_empty() {
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}"));
}
if !repo.maintainers.is_empty() {
repo_details.push_str(&format!(
"\nMaintainers:\n{}",
utils::format_iter(
repo.maintainers
.iter()
.map(|p| p.to_bech32().expect("Infallible"))
)
));
}
repos_details.push(repo_details);
}
println!("{}", repos_details.join("\n----------\n"));
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
@@ -14,25 +14,39 @@
// 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>.
/// `issue new` subcommand
/// `sets new` command
mod new;
/// `sets remove` command
mod remove;
/// `sets show` commands
mod show;
/// `sets update` command
mod update;
use clap::Subcommand;
use self::new::NewArgs;
use self::remove::RemoveArgs;
use self::show::ShowArgs;
use self::update::UpdateArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
pub enum SetsSubcommands {
/// Remove a set, or specific repos and relays within it
Remove(RemoveArgs),
/// Create a new set
New(NewArgs),
/// Modify an existing set
Update(UpdateArgs),
/// Show a single set or all the stored sets
Show(ShowArgs),
}
impl CommandRunner for IssueSubcommands {
impl CommandRunner for SetsSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::New(args) => args.run(options).await,
}
crate::run_command!(self, options, & Remove New Update Show)
}
}

View File

@@ -0,0 +1,63 @@
// 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,
ConfigError,
traits::{CommandRunner, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
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, separated by commas. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"), value_delimiter = ',')]
relays: Vec<RelayOrSet>,
/// 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>,
}
impl CommandRunner for NewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
return Err(ConfigError::NewEmptySet.into());
}
options.config.sets.push_set(self.name, naddrs, relays)?;
options.config.dump()
}
}

View File

@@ -0,0 +1,75 @@
// 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, MutRepoRelaySetsExt, NaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, RelayOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
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, separated by commas. [aliases: `--sr`]
#[arg(long = "set-relay", alias("sr"), value_delimiter = ',')]
relays: Vec<RelayOrSet>,
/// 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>,
}
impl CommandRunner for RemoveArgs {
const NEED_SIGNER: bool = false;
async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() {
options.config.sets.remove_set(self.name)?;
} else {
if !relays.is_empty() {
options
.config
.sets
.remove_relays(&self.name, relays.into_iter())?;
}
if !naddrs.is_empty() {
options
.config
.sets
.remove_naddrs(self.name, naddrs.into_iter())?;
}
}
options.config.dump()
}
}

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