65 Commits

Author SHA1 Message Date
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
49 changed files with 3278 additions and 336 deletions

View File

@@ -5,6 +5,48 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 ## [0.2.0] - 2025-06-01
### Added ### Added
@@ -59,6 +101,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initialize the project - by Awiteb - Initialize the project - by Awiteb
[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.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.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 [0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0

View File

@@ -41,6 +41,11 @@ Nostr-compatible client using the address:
yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00 yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00
p68ytnyv4mqwuj6xc` p68ytnyv4mqwuj6xc`
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 ## Contribution Workflow
Before submitting changes, open an issue to discuss your proposed contribution. Before submitting changes, open an issue to discuss your proposed contribution.
@@ -81,6 +86,12 @@ to their read relays, most tools handle this automatically.
- For all other changes, use `chore`. - For all other changes, use `chore`.
- Add `!` to the subject if your patch contains a breacking change, e.g. - Add `!` to the subject if your patch contains a breacking change, e.g.
`remove!: text` and `fix(reply)!: text` `remove!: text` and `fix(reply)!: text`
- 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 #### Code Style

8
Cargo.lock generated
View File

@@ -1079,7 +1079,7 @@ dependencies = [
[[package]] [[package]]
name = "n34" name = "n34"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@@ -1087,9 +1087,11 @@ dependencies = [
"convert_case", "convert_case",
"dirs", "dirs",
"easy-ext", "easy-ext",
"either",
"futures", "futures",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"regex",
"serde", "serde",
"tempfile", "tempfile",
"thiserror 2.0.12", "thiserror 2.0.12",
@@ -1107,9 +1109,9 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d"
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.42.0" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0453f6c77c49cef830f3acbf5c3ac29732899c1fde4ea97045fb59896e48fda" checksum = "d193102a62a22b61f9a61b9df54fb19ebab8c1763d088fbb9a6f0f57000fba2d"
dependencies = [ dependencies = [
"base64", "base64",
"bech32", "bech32",

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "n34" name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr" description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"] authors = ["Awiteb <a@4rs.nl>"]
@@ -9,7 +9,7 @@ readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34.git" repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"] keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
rust-version = "1.85.1" rust-version = "1.88.0"
[dependencies] [dependencies]
chrono = "0.4.41" chrono = "0.4.41"
@@ -20,9 +20,11 @@ clap-verbosity-flag = { version = "3.0.2", default-features = false, features =
convert_case = "0.8.0" convert_case = "0.8.0"
dirs = "6.0.0" dirs = "6.0.0"
easy-ext = "1.0.2" easy-ext = "1.0.2"
either = "1.15.0"
futures = "0.3.31" futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser", "nip05"] } nostr = { version = "0.42.2", features = ["parser", "nip05"] }
nostr-sdk = "0.42.0" nostr-sdk = "0.42.0"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
tempfile = "3.19.1" tempfile = "3.19.1"
thiserror = "2.0.12" thiserror = "2.0.12"
@@ -30,3 +32,7 @@ tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
toml = "0.8.22" toml = "0.8.22"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
[profile.release]
lto = "fat"
opt-level = 3

View File

@@ -20,15 +20,15 @@ details, see the following section.
- [X] Repository announcements - [X] Repository announcements
- [ ] Repository state announcements - [ ] Repository state announcements
- [ ] Patches - [X] Patches (Send, fetch and list)
- [X] Issues - [X] Issues (Send, view and list)
- [X] Replies - [X] Replies
- [X] Status - [X] Issues and patches status
- [X] Gossip Model ([NIP-65]) - [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13]) - [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21]) - [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
- [ ] Code Snippets ([NIP-C0]) - [ ] Code Snippets ([NIP-C0])
- [ ] In device relays and repos bookmark - [X] In device relays and repos bookmark (`sets` command)
## Why Nostr? ## Why Nostr?

12
flake.lock generated
View File

@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1745930157, "lastModified": 1751271578,
"narHash": "sha256-y3h3NLnzRSiUkYpnfvnS669zWZLoqqI6NprtLQ+5dck=", "narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "46e634be05ce9dc6d4db8e664515ba10b78151ae", "rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1746067100, "lastModified": 1751683029,
"narHash": "sha256-6JeEbboDvRjLwB9kzCnmWj+f+ZnMtKOe5c2F1VBpaTs=", "narHash": "sha256-dYO5X5jK8bpQOeRAo8R5aUt6M/+Ji1cZgstZI7SQ2IA=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "026e8fedefd6b167d92ed04b195c658d95ffc7a5", "rev": "9e5e62a33a929a67a5427fb7324a6f583dced0b2",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -29,8 +29,9 @@
]; ];
nativeBuildInputs = [ nativeBuildInputs = [
(lib.hiPrio rust-bin.nightly."2025-04-30".rustfmt) (lib.hiPrio rust-bin.nightly."2025-07-05".rustfmt)
rust-bin.stable.latest.default rust-bin.stable.latest.default
rust-analyzer
]; ];
buildInputs = [ ]; buildInputs = [ ];

View File

@@ -13,13 +13,13 @@ set script-interpreter := ["/usr/bin/env", "bash"]
JUST_EXECUTABLE := "just -u -f " + justfile() JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n" header := "Available tasks:\n"
# Get the MSRV from the Cargo.toml # 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") %} tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
{{ group | upper_first }} {{ group | upper_first }}
{% for commit in commits %} {% 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 %}''' - {{ 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" export TZ := "UTC"
@@ -32,15 +32,16 @@ ci: && msrv _done_ci
cargo build -q cargo build -q
echo "🔍 Checking code formatting..." echo "🔍 Checking code formatting..."
cargo fmt -q -- --check cargo fmt -q -- --check
taplo fmt --check --config ./.taplo.toml RUST_LOG=none taplo fmt --check --config ./.taplo.toml || (echo "❌Toml files is not properly formatted" && exit 1)
echo "🧹 Running linter checks..." echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings cargo clippy -q -- -D warnings
taplo check --config ./.taplo.toml echo "🧪 Running tests..."
cargo test -q
# Check that the current MSRV is correct # Check that the current MSRV is correct
msrv: msrv:
echo "🔧 Verifying MSRV ({{msrv}})..." echo "🔧 Verifying MSRV..."
rustup -q run --install {{msrv}} cargo check -q cargo-msrv verify
echo "✅ MSRV verification passed" echo "✅ MSRV verification passed"
_done_ci: _done_ci:
@@ -64,7 +65,7 @@ changelog:
[script] [script]
release version: release version:
set -e set -e
TAG_MSG="Version {{ version }}\n\n$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')\n" TAG_MSG="Version {{ version }}$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')"
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml taplo fmt --config ./.taplo.toml ./Cargo.toml
{{ JUST_EXECUTABLE }} ci {{ JUST_EXECUTABLE }} ci

View File

@@ -0,0 +1,43 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 pow` subcommand
mod pow;
/// `config relays` subcommand
mod relays;
use clap::Subcommand;
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),
}
impl CommandRunner for ConfigSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & Pow Relays)
}
}

View File

@@ -0,0 +1,37 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 codes 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,65 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::NaddrOrSet},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
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_patches_and_issues(options, self.naddrs, false, self.limit.into())
.await
}
}

View File

@@ -14,25 +14,129 @@
// You should have received a copy of the GNU General Public License // 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>. // 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 /// `issue new` subcommand
mod new; mod new;
/// `issue reopen` subcommand
mod reopen;
/// `issue resolve` subcommand
mod resolve;
/// `issue view` subcommand
mod view;
use std::fmt;
use clap::Subcommand; use clap::Subcommand;
use nostr::event::Kind;
use self::close::CloseArgs;
use self::list::ListArgs;
use self::new::NewArgs; use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::resolve::ResolveArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner}; use super::{CliOptions, CommandRunner};
use crate::error::N34Result; use crate::error::{N34Error, N34Result};
/// Prefix used for git issue alt.
pub const ISSUE_ALT_PREFIX: &str = "git issue: ";
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum IssueSubcommands { pub enum IssueSubcommands {
/// Create a new repository issue /// Create a new repository issue
New(NewArgs), New(NewArgs),
/// View an issue by its ID
View(ViewArgs),
/// Reopens a closed issue.
Reopen(ReopenArgs),
/// Closes an open issue.
Close(CloseArgs),
/// Resolves an open issue.
Resolve(ResolveArgs),
/// List the repositories 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 {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub 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 { impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
match self { crate::run_command!(self, options, & New View Reopen Close Resolve List)
Self::New(args) => args.run(options).await,
}
} }
} }

View File

@@ -16,12 +16,10 @@
use clap::{ArgGroup, Args}; use clap::{ArgGroup, Args};
use futures::future;
use nostr::event::{EventBuilder, Tag}; use nostr::event::{EventBuilder, Tag};
use crate::{ use crate::{
cli::{ cli::{
CliConfig,
CliOptions, CliOptions,
CommandRunner, CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
@@ -97,56 +95,45 @@ impl NewArgs {
impl CommandRunner for NewArgs { impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
let config = CliConfig::load_toml(&options.config_path)?; let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
let naddrs = utils::naddrs_or_file( self.naddrs.flat_naddrs(&options.config.sets)?,
self.naddrs.flat_naddrs(&config.sets)?,
&utils::nostr_address_path()?, &utils::nostr_address_path()?,
)?; )?)?;
let relays = options.relays.clone().flat_relays(&config.sets)?; let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await; let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?; let user_pubk = options.pubkey().await?;
let mut naddrs_iter = naddrs.clone().into_iter(); let coordinates = naddrs.clone().into_coordinates();
client.add_relays(&naddrs.extract_relays()).await; 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?; 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) = self.issue_content()?; let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await; let content_details = client.parse_content(&content).await;
let event = EventBuilder::new_git_issue( let event =
naddrs_iter EventBuilder::new_git_issue(coordinates.as_slice(), content, subject, self.label)?
.next() .dedup_tags()
.expect("There is at least one address") .pow(options.pow.unwrap_or_default())
.coordinate .tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.clone(), .tags(content_details.clone().into_tags())
content, .build(user_pubk);
subject,
self.label,
)?
.dedup_tags()
.pow(options.pow)
.tags(content_details.clone().into_tags())
// p-tag the reset of the reposotoies owners
.tags(naddrs_iter.clone().map(|n| Tag::public_key(n.public_key)))
// a-tag the reset of the reposotoies
.tags(naddrs_iter.clone().map(|n| Tag::coordinate(n.coordinate, n.relays.first().cloned())))
.build(user_pubk);
let event_id = event.id.expect("There is an id"); let event_id = event.id.expect("There is an id");
let write_relays = [ let write_relays = [
relays, relays,
naddrs.extract_relays(),
utils::add_write_relays(relays_list.as_ref()), utils::add_write_relays(relays_list.as_ref()),
client client
.fetch_repos(&naddrs.into_coordinates()) .fetch_repos(&naddrs.into_coordinates())
.await? .await?
.extract_relays(), .extract_relays(),
// Include read relays for each repository owner (if found) // Include read relays for each maintainer (if found)
future::join_all(naddrs_iter.map(|c| client.read_relays_from_user(c.public_key))) client.read_relays_from_users(&maintainers).await,
.await
.into_iter()
.flatten()
.collect(),
content_details.write_relays.clone().into_iter().collect(), content_details.write_relays.clone().into_iter().collect(),
] ]
.concat(); .concat();

View File

@@ -0,0 +1,65 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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,59 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open 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,93 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::{event::Kind, filter::Filter};
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{GitIssueUtils, NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Debug, Args)]
pub struct ViewArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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<()> {
let naddrs = utils::naddrs_or_file(
self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
client.add_relays(&self.issue_id.relays).await;
client
.add_relays(
&client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
)
.await;
let issue = client
.fetch_event(
Filter::new()
.id(self.issue_id.event_id)
.kind(Kind::GitIssue),
)
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_subject = utils::smart_wrap(issue.extract_issue_subject(), 70);
let issue_author = client.get_username(issue.pubkey).await;
let mut issue_labels = utils::smart_wrap(&issue.extract_issue_labels(), 70);
if issue_labels.is_empty() {
issue_labels = "\n".to_owned();
} else {
issue_labels = format!("{issue_labels}\n\n")
}
println!(
"{issue_subject} - [by {issue_author}]\n{issue_labels}{}",
utils::smart_wrap(&issue.content, 80)
);
Ok(())
}
}

View File

@@ -14,27 +14,33 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `config` subcommands
pub mod config;
/// `issue` subcommands /// `issue` subcommands
mod issue; pub mod issue;
/// 'reply` command /// `patch` subcommands
mod reply; pub mod patch;
/// `reply` command
pub mod reply;
/// `repo` subcommands /// `repo` subcommands
mod repo; pub mod repo;
/// `sets` subcommands /// `sets` subcommands
mod sets; pub mod sets;
use std::fmt; use std::fmt;
use std::path::PathBuf;
use clap::{ArgGroup, Args, Parser}; use clap::{ArgGroup, Args, Parser};
use nostr::key::{Keys, PublicKey, SecretKey}; use nostr::key::{Keys, PublicKey, SecretKey};
use self::config::ConfigSubcommands;
use self::issue::IssueSubcommands; use self::issue::IssueSubcommands;
use self::patch::PatchSubcommands;
use self::reply::ReplyArgs; use self::reply::ReplyArgs;
use self::repo::RepoSubcommands; use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands; use self::sets::SetsSubcommands;
use super::traits::CommandRunner; use super::CliConfig;
use super::types::RelayOrSet; use super::types::RelayOrSet;
use super::{parsers, traits::CommandRunner};
use crate::error::{N34Error, N34Result}; use crate::error::{N34Error, N34Result};
/// Default path used when no path is provided via command line arguments. /// Default path used when no path is provided via command line arguments.
@@ -42,38 +48,37 @@ use crate::error::{N34Error, N34Result};
/// This is a workaround since Clap doesn't support lazy evaluation of defaults. /// 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"; pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
// TODO: Make the `signer` group optional
/// The command-line interface options /// The command-line interface options
#[derive(Args, Clone)] #[derive(Args, Clone)]
#[clap( #[clap(
group( group(
ArgGroup::new("signer") ArgGroup::new("signer")
.args(&["secret_key"]) .args(&["secret_key"])
.required(true) .required(false)
) )
)] )]
pub struct CliOptions { pub struct CliOptions {
/// Your Nostr secret key /// Your Nostr secret key
#[arg(short, long)] #[arg(short, long)]
pub secret_key: Option<SecretKey>, pub secret_key: Option<SecretKey>,
/// Fallbacks relay to write and read from it. Multiple relays can be /// Fallbacks relay to write and read from it. Multiple relays can be
/// passed. /// passed.
#[arg(short, long)] #[arg(short, long)]
pub relays: Vec<RelayOrSet>, pub relays: Vec<RelayOrSet>,
/// Proof of Work difficulty when creatring events /// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)] #[arg(long, value_name = "DIFFICULTY")]
pub pow: u8, pub pow: Option<u8>,
/// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`] /// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
#[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH, #[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
hide_default_value = true hide_default_value = true, value_parser = parsers::parse_config_path
)] )]
pub config_path: PathBuf, pub config: CliConfig,
} }
/// N34 commands /// N34 commands
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub enum Commands { pub enum Commands {
/// Manage reposotoies and relays sets /// Manage repositories and relays sets
Sets { Sets {
#[command(subcommand)] #[command(subcommand)]
subcommands: SetsSubcommands, subcommands: SetsSubcommands,
@@ -88,38 +93,40 @@ pub enum Commands {
#[command(subcommand)] #[command(subcommand)]
subcommands: IssueSubcommands, subcommands: IssueSubcommands,
}, },
// /// Manage patches /// Manage patches
// Patch { Patch {
// #[command(subcommand)] #[command(subcommand)]
// subcommands: PatchSubcommands, subcommands: PatchSubcommands,
// }, },
/// Manage configuration
Config {
#[command(subcommand)]
subcommands: ConfigSubcommands,
},
/// Reply to issues and patches. /// Reply to issues and patches.
Reply(ReplyArgs), Reply(ReplyArgs),
} }
impl CommandRunner for Commands {
async fn run(self, options: CliOptions) -> N34Result<()> {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
match self {
Self::Repo { subcommands } => subcommands.run(options).await,
Self::Issue { subcommands } => subcommands.run(options).await,
Commands::Reply(args) => args.run(options).await,
Commands::Sets { subcommands } => subcommands.run(options).await,
}
}
}
// TODO: Implement `ensure_signer` function with similar functionality to
// `ensure_relays`
impl CliOptions { impl CliOptions {
/// Gets the public key of the user. /// Gets the public key of the user.
pub async fn pubkey(&self) -> N34Result<PublicKey> { pub async fn pubkey(&self) -> N34Result<PublicKey> {
if let Some(sk) = &self.secret_key { if let Some(sk) = &self.secret_key {
return Ok(Keys::new(sk.clone()).public_key()); return Ok(Keys::new(sk.clone()).public_key());
} }
unreachable!("There is no other method until now") unreachable!(
"This method should only be called when a signer is required. If this panic occurs, \
it indicates a bug where the command failed to properly require a signer (which is \
the default behavior)"
)
}
/// Returns the signer
pub fn signer(&self) -> Option<impl nostr::signer::IntoNostrSigner> {
if let Some(sk) = &self.secret_key {
return Some(Keys::new(sk.clone()));
}
None
} }
/// Returns an error if there are no relays. /// Returns an error if there are no relays.
@@ -129,6 +136,15 @@ impl CliOptions {
} }
Ok(()) Ok(())
} }
/// Returns an error if there are no signers
pub fn ensure_signer(&self) -> N34Result<()> {
if self.secret_key.is_none() {
Err(N34Error::SignerRequired)
} else {
Ok(())
}
}
} }
impl fmt::Debug for CliOptions { impl fmt::Debug for CliOptions {
@@ -136,6 +152,17 @@ impl fmt::Debug for CliOptions {
f.debug_struct("CliOptions") f.debug_struct("CliOptions")
.field("secret_key", &self.secret_key.as_ref().map(|_| "*******")) .field("secret_key", &self.secret_key.as_ref().map(|_| "*******"))
.field("relays", &self.relays) .field("relays", &self.relays)
.field("pow", &self.pow)
.field("config", &self.config)
.finish() .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 Config & Reply)
}
}

View File

@@ -0,0 +1,77 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ApplyArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to apply it. Must be orignal root patch or
/// revision root
patch_id: NostrEvent,
/// The applied commits
#[arg(num_args = 1..)]
applied_commits: Vec<Sha1Hash>,
}
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
|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(
"You can't apply a drafted patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,66 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Closed,
None,
|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,73 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct DraftArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Draft,
None,
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already drafted 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,137 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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,
path::{Path, PathBuf},
str::FromStr,
};
use clap::Args;
use nostr::{
event::{Kind, TagKind},
filter::Filter,
nips::nip19::ToBech32,
};
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Debug, Args)]
pub struct FetchArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// Output directory for the patches. Default to the current directory
#[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();
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::InvalidEvent(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 output_path.as_path() != Path::new("") && !output_path.exists() {
fs::create_dir_all(&output_path)?;
}
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,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::NaddrOrSet},
error::N34Result,
};
#[derive(Debug, Args)]
pub struct ListArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET")]
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_patches_and_issues(options, self.naddrs, true, self.limit.into())
.await
}
}

View File

@@ -0,0 +1,77 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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 super::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open patch id to merge it. Must be orignal root patch or
/// revision root
patch_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_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged 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 drafted patch".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,631 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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;
use std::{
fmt,
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand;
use nostr::event::Kind;
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 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()
});
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
/// Send one or more patches to a repository.
Send(SendArgs),
/// Fetches a patch by its id.
Fetch(FetchArgs),
/// Closes an open or drafted patch.
Close(CloseArgs),
/// Converts the closed or open patch to draft state.
Draft(DraftArgs),
/// Reopens a closed or drafted patch.
Reopen(ReopenArgs),
/// Set an open patch status to applied.
Apply(ApplyArgs),
/// Set an open patch status to merged.
Merge(MergeArgs),
/// List the repositories 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,
}
#[derive(Debug)]
pub enum PatchStatus {
/// The patch is currently open
Open,
/// The patch has been merged/applied
MergedApplied,
/// The patch has been closed
Closed,
/// A patch that has been drafted but not yet applied.
Draft,
}
impl PatchStatus {
/// Maps the issue status to its corresponding Nostr kind.
#[inline]
pub fn kind(&self) -> Kind {
match self {
Self::Open => Kind::GitStatusOpen,
Self::MergedApplied => Kind::GitStatusApplied,
Self::Closed => Kind::GitStatusClosed,
Self::Draft => Kind::GitStatusDraft,
}
}
/// Returns the string representation of the patch status.
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "Open",
Self::MergedApplied => "Merged/Applied",
Self::Closed => "Closed",
Self::Draft => "Draft",
}
}
/// Check if the patch is open.
#[inline]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open)
}
/// Check if the patch is merged/applied.
#[inline]
pub fn is_merged_or_applied(&self) -> bool {
matches!(self, Self::MergedApplied)
}
/// Check if the patch is closed.
#[inline]
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed)
}
/// Check if the patch is drafted
#[inline]
pub fn is_drafted(&self) -> bool {
matches!(self, Self::Draft)
}
}
impl From<&PatchStatus> for Kind {
fn from(status: &PatchStatus) -> Self {
status.kind()
}
}
impl fmt::Display for PatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl TryFrom<Kind> for PatchStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
match kind {
Kind::GitStatusOpen => Ok(Self::Open),
Kind::GitStatusApplied => Ok(Self::MergedApplied),
Kind::GitStatusClosed => Ok(Self::Closed),
Kind::GitStatusDraft => Ok(Self::Draft),
_ => Err(N34Error::InvalidPatchStatus(kind)),
}
}
}
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
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> {
// 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("--", "-"))
}
#[cfg(test)]
mod tests {
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,67 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::PatchStatus;
use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
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_status_command(
options,
self.patch_id,
self.naddrs,
PatchStatus::Open,
None,
|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,265 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
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 address in `naddr` format (`naddr1...`), NIP-05 format
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
naddrs: Option<Vec<NaddrOrSet>>,
/// List of patch files to send (space separated).
///
/// For p-tagging users, include them in the cover letter with
/// `nostr:npub1...`.
#[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
patches: Vec<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 repo_coordinates = naddrs.clone().into_coordinates();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let user_pubk = options.pubkey().await?;
let client = NostrClient::init(&options, &relays).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,
self.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(())
}
}
fn parse_patch_path(patch_path: &str) -> Result<GitPatch, String> {
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)
}
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"));
}
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("root-revision"),
]);
} 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

@@ -14,26 +14,20 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, str::FromStr}; use std::fs;
use clap::{ArgGroup, Args}; use clap::{ArgGroup, Args};
use futures::future; use futures::future;
use nostr::{ use nostr::{
event::{Event, EventBuilder, EventId, Kind}, event::{Event, EventBuilder, Kind},
filter::Filter, filter::Filter,
nips::{ nips::nip01::Coordinate,
nip01::{Coordinate, Metadata},
nip19::{self, FromBech32, ToBech32},
},
types::RelayUrl, types::RelayUrl,
}; };
use super::{CliOptions, CommandRunner}; use super::{CliOptions, CommandRunner};
use crate::{ use crate::{
cli::{ cli::types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
CliConfig,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
},
error::{N34Error, N34Result}, error::{N34Error, N34Result},
nostr_utils::{ nostr_utils::{
NostrClient, NostrClient,
@@ -42,48 +36,9 @@ use crate::{
}, },
}; };
/// Length of a Nostr npub (public key) in characters.
const NPUB_LEN: usize = 63;
/// The max date "9999-01-01 at 00:00 UTC" /// The max date "9999-01-01 at 00:00 UTC"
const MAX_DATE: i64 = 253370764800; const MAX_DATE: i64 = 253370764800;
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
struct NostrEvent {
/// Unique identifier for the event.
event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
relays: Vec<RelayUrl>,
}
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
impl FromStr for NostrEvent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let str_event = s.trim().trim_start_matches("nostr:");
if str_event.starts_with("nevent1") {
let event = nip19::Nip19Event::from_bech32(str_event).map_err(|e| e.to_string())?;
Ok(Self::new(event.event_id, event.relays))
} else if str_event.starts_with("note1") {
Ok(Self::new(
EventId::from_bech32(str_event).map_err(|e| e.to_string())?,
Vec::new(),
))
} else {
Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
}
}
}
/// Arguments for the `reply` command /// Arguments for the `reply` command
#[derive(Args, Debug)] #[derive(Args, Debug)]
#[clap( #[clap(
@@ -99,7 +54,7 @@ impl FromStr for NostrEvent {
)] )]
pub struct ReplyArgs { pub struct ReplyArgs {
/// The issue, patch, or comment to reply to /// The issue, patch, or comment to reply to
#[arg(long, value_name = "nevent1-or-note1")] #[arg(value_name = "nevent1-or-note1")]
to: NostrEvent, to: NostrEvent,
/// Quote the replied-to event in the editor /// Quote the replied-to event in the editor
#[arg(long)] #[arg(long)]
@@ -121,12 +76,11 @@ pub struct ReplyArgs {
impl CommandRunner for ReplyArgs { impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
let nostr_address_path = utils::nostr_address_path()?; let nostr_address_path = utils::nostr_address_path()?;
let config = CliConfig::load_toml(&options.config_path)?; let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let relays = options.relays.clone().flat_relays(&config.sets)?;
let client = NostrClient::init(&options, &relays).await; let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?; let user_pubk = options.pubkey().await?;
let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&config.sets)? { let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&options.config.sets)? {
client.add_relays(&naddrs.extract_relays()).await; client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs) Some(naddrs)
} else if fs::exists(&nostr_address_path).is_ok() { } else if fs::exists(&nostr_address_path).is_ok() {
@@ -138,6 +92,11 @@ impl CommandRunner for ReplyArgs {
}; };
client.add_relays(&self.to.relays).await; 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 let reply_to = client
.fetch_event(Filter::new().id(self.to.event_id)) .fetch_event(Filter::new().id(self.to.event_id))
@@ -154,6 +113,7 @@ impl CommandRunner for ReplyArgs {
}; };
let repos = client.fetch_repos(&repos_coordinate).await?; let repos = client.fetch_repos(&repos_coordinate).await?;
let maintainers = repos.extract_maintainers();
let quoted_content = if self.quote_to { let quoted_content = if self.quote_to {
Some(quote_reply_to_content(&client, &reply_to).await) Some(quote_reply_to_content(&client, &reply_to).await)
@@ -171,29 +131,18 @@ impl CommandRunner for ReplyArgs {
repos.first().and_then(|r| r.relays.first()).cloned(), repos.first().and_then(|r| r.relays.first()).cloned(),
) )
.dedup_tags() .dedup_tags()
.pow(options.pow) .pow(options.pow.unwrap_or_default())
.tags(content_details.clone().into_tags()) .tags(content_details.clone().into_tags())
.build(user_pubk); .build(user_pubk);
let event_id = event.id.expect("There is an id"); let event_id = event.id.expect("There is an id");
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());
let write_relays = [ let write_relays = [
relays, relays,
utils::add_write_relays(relays_list.as_ref()), utils::add_write_relays(relays_list.as_ref()),
// Merge repository announcement relays into write relays // Merge repository announcement relays into write relays
repos.extract_relays(), repos.extract_relays(),
// Include read relays for each repository owner (if found) // Include read relays for each repository maintainer (if found)
future::join_all( client.read_relays_from_users(&maintainers).await,
repos_coordinate
.iter()
.map(|c| client.read_relays_from_user(c.public_key)),
)
.await
.into_iter()
.flatten()
.collect(),
// read relays of the root event and the reply to event // read relays of the root event and the reply to event
{ {
let (r1, r2) = future::join( let (r1, r2) = future::join(
@@ -227,28 +176,11 @@ impl CommandRunner for ReplyArgs {
} }
/// Creates a quoted reply string in the format "On yyyy-mm-dd at hh:mm UTC, /// 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 /// {author} wrote:" followed by the event content. Uses display name if
/// available, otherwise falls back to a shortened npub string. Dates are /// available, otherwise falls back to a shortened npub string. Dates are
/// formatted in UTC. /// formatted in UTC.
async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String { async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> String {
let author_name = client let author_name = client.get_username(quoted_event.pubkey).await;
.fetch_event(
Filter::new()
.kind(Kind::Metadata)
.author(quoted_event.pubkey),
)
.await
.ok()
.flatten()
.and_then(|e| Metadata::try_from(&e).ok())
.and_then(|m| m.display_name.or(m.name))
.unwrap_or_else(|| {
let pubkey = quoted_event
.pubkey
.to_bech32()
.expect("The error is `Infallible`");
format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..])
});
let fdate = chrono::DateTime::from_timestamp( let fdate = chrono::DateTime::from_timestamp(
quoted_event quoted_event

View File

@@ -21,7 +21,7 @@ use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url}; use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{ use crate::{
cli::{CliConfig, CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt}, cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt},
error::N34Result, error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils}, nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
}; };
@@ -78,14 +78,16 @@ pub struct AnnounceArgs {
} }
impl CommandRunner for AnnounceArgs { impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> { const NEED_RELAYS: bool = true;
options.ensure_relays()?;
let config = CliConfig::load_toml(&options.config_path)?; async fn run(mut self, options: CliOptions) -> N34Result<()> {
let relays = options.relays.clone().flat_relays(&config.sets)?; let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await; let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?; let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).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) { if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk); self.maintainers.insert(0, user_pubk);
@@ -104,7 +106,7 @@ impl CommandRunner for AnnounceArgs {
self.force_id, self.force_id,
)? )?
.dedup_tags() .dedup_tags()
.pow(options.pow) .pow(options.pow.unwrap_or_default())
.build(user_pubk); .build(user_pubk);

View File

@@ -38,9 +38,6 @@ pub enum RepoSubcommands {
impl CommandRunner for RepoSubcommands { impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
match self { crate::run_command!(self, options, & View Announce)
Self::View(args) => args.run(options).await,
Self::Announce(args) => args.run(options).await,
}
} }
} }

View File

@@ -17,10 +17,10 @@
use std::fmt; use std::fmt;
use clap::Args; use clap::Args;
use nostr::nips::nip19::ToBech32;
use crate::{ use crate::{
cli::{ cli::{
CliConfig,
CliOptions, CliOptions,
CommandRunner, CommandRunner,
types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
@@ -36,20 +36,19 @@ pub struct ViewArgs {
/// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
/// ///
/// If omitted, looks for a `nostr-address` file. /// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] #[arg(value_name = "NADDR-NIP05-OR-SET")]
naddrs: Option<Vec<NaddrOrSet>>, naddrs: Option<Vec<NaddrOrSet>>,
} }
impl CommandRunner for ViewArgs { impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> { const NEED_SIGNER: bool = false;
// FIXME: The signer is not required here
let config = CliConfig::load_toml(&options.config_path)?; async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::naddrs_or_file( let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.naddrs.flat_naddrs(&config.sets)?, self.naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?, &utils::nostr_address_path()?,
)?; )?)?;
let relays = options.relays.clone().flat_relays(&config.sets)?; let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await; let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await; client.add_relays(&naddrs.extract_relays()).await;
@@ -80,7 +79,11 @@ impl CommandRunner for ViewArgs {
if !repo.maintainers.is_empty() { if !repo.maintainers.is_empty() {
repo_details.push_str(&format!( repo_details.push_str(&format!(
"\nMaintainers:\n{}", "\nMaintainers:\n{}",
format_list(repo.maintainers) format_list(
repo.maintainers
.iter()
.map(|p| p.to_bech32().expect("Infallible"))
)
)); ));
} }
repos_details.push(repo_details); repos_details.push(repo_details);
@@ -92,11 +95,12 @@ impl CommandRunner for ViewArgs {
} }
/// Format a vector to print it /// Format a vector to print it
fn format_list<T>(vector: Vec<T>) -> String fn format_list<I, T>(iterator: I) -> String
where where
I: IntoIterator<Item = T>,
T: fmt::Display, T: fmt::Display,
{ {
vector iterator
.into_iter() .into_iter()
.map(|t| format!(" - {t}")) .map(|t| format!(" - {t}"))
.collect::<Vec<String>>() .collect::<Vec<String>>()

View File

@@ -47,11 +47,6 @@ pub enum SetsSubcommands {
impl CommandRunner for SetsSubcommands { impl CommandRunner for SetsSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
match self { crate::run_command!(self, options, & Remove New Update Show)
Self::Remove(args) => args.run(options).await,
Self::New(args) => args.run(options).await,
Self::Update(args) => args.run(options).await,
Self::Show(args) => args.run(options).await,
}
} }
} }

View File

@@ -18,7 +18,6 @@ use clap::Args;
use crate::{ use crate::{
cli::{ cli::{
CliConfig,
CliOptions, CliOptions,
ConfigError, ConfigError,
MutRepoRelaySetsExt, MutRepoRelaySetsExt,
@@ -43,17 +42,17 @@ pub struct NewArgs {
} }
impl CommandRunner for NewArgs { impl CommandRunner for NewArgs {
// FIXME: The signer is not required here const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?; async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&config.sets)?; let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?; let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() { if relays.is_empty() && naddrs.is_empty() {
return Err(ConfigError::NewEmptySet.into()); return Err(ConfigError::NewEmptySet.into());
} }
config.sets.push_set(self.name, naddrs, relays)?; options.config.sets.push_set(self.name, naddrs, relays)?;
config.dump_toml(&options.config_path) options.config.dump()
} }
} }

View File

@@ -18,7 +18,6 @@ use clap::Args;
use crate::{ use crate::{
cli::{ cli::{
CliConfig,
CliOptions, CliOptions,
MutRepoRelaySetsExt, MutRepoRelaySetsExt,
traits::CommandRunner, traits::CommandRunner,
@@ -43,23 +42,29 @@ pub struct RemoveArgs {
} }
impl CommandRunner for RemoveArgs { impl CommandRunner for RemoveArgs {
// FIXME: The signer is not required here const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?; async fn run(self, mut options: CliOptions) -> N34Result<()> {
let naddrs = self.naddrs.flat_naddrs(&config.sets)?; let naddrs = self.naddrs.flat_naddrs(&options.config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?; let relays = self.relays.flat_relays(&options.config.sets)?;
if relays.is_empty() && naddrs.is_empty() { if relays.is_empty() && naddrs.is_empty() {
config.sets.remove_set(self.name)?; options.config.sets.remove_set(self.name)?;
} else { } else {
if !relays.is_empty() { if !relays.is_empty() {
config.sets.remove_relays(&self.name, relays.into_iter())?; options
.config
.sets
.remove_relays(&self.name, relays.into_iter())?;
} }
if !naddrs.is_empty() { if !naddrs.is_empty() {
config.sets.remove_naddrs(self.name, naddrs.into_iter())?; options
.config
.sets
.remove_naddrs(self.name, naddrs.into_iter())?;
} }
} }
config.dump_toml(&options.config_path) options.config.dump()
} }
} }

View File

@@ -18,7 +18,7 @@ use clap::Args;
use nostr::nips::nip19::ToBech32; use nostr::nips::nip19::ToBech32;
use crate::{ use crate::{
cli::{CliConfig, CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner}, cli::{CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner},
error::N34Result, error::N34Result,
}; };
@@ -29,16 +29,19 @@ pub struct ShowArgs {
} }
impl CommandRunner for ShowArgs { impl CommandRunner for ShowArgs {
// FIXME: The signer is not required here const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let config = CliConfig::load_toml(&options.config_path)?;
async fn run(self, options: CliOptions) -> N34Result<()> {
if let Some(name) = self.name { if let Some(name) = self.name {
println!("{}", format_set(config.sets.as_slice().get_set(&name)?)); println!(
"{}",
format_set(options.config.sets.as_slice().get_set(&name)?)
);
} else { } else {
println!( println!(
"{}", "{}",
config options
.config
.sets .sets
.iter() .iter()
.map(format_set) .map(format_set)

View File

@@ -20,7 +20,6 @@ use clap::Args;
use crate::{ use crate::{
cli::{ cli::{
CliConfig,
CliOptions, CliOptions,
MutRepoRelaySetsExt, MutRepoRelaySetsExt,
traits::CommandRunner, traits::CommandRunner,
@@ -47,13 +46,13 @@ pub struct UpdateArgs {
} }
impl CommandRunner for UpdateArgs { impl CommandRunner for UpdateArgs {
// FIXME: The signer is not required here const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
let mut config = CliConfig::load_toml(&options.config_path)?;
let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
let relays = self.relays.flat_relays(&config.sets)?;
let set = config.sets.get_mut_set(&self.name)?; 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)?;
let set = options.config.sets.get_mut_set(&self.name)?;
if self.override_set { if self.override_set {
if !relays.is_empty() { if !relays.is_empty() {
@@ -67,6 +66,6 @@ impl CommandRunner for UpdateArgs {
set.naddrs.extend(naddrs); set.naddrs.extend(naddrs);
} }
config.dump_toml(&options.config_path) options.config.dump()
} }
} }

403
src/cli/common_commands.rs Normal file
View File

@@ -0,0 +1,403 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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, str::FromStr, sync::Arc};
use either::Either;
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind},
filter::Filter,
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
};
use super::{
issue::IssueStatus,
patch::PatchStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::types::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
use crate::{
cli::{CliOptions, patch::GitPatch},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssueUtils, GitPatchUtils, ReposUtils},
};
/// Updates the issue's status to `new_status` after validating it with
/// `check_fn`.
pub async fn issue_status_command(
options: CliOptions,
issue_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: IssueStatus,
check_fn: impl FnOnce(&IssueStatus) -> N34Result<()>,
) -> N34Result<()> {
let user_pkey = options.pubkey().await?;
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client
.add_relays(&[naddrs.extract_relays(), issue_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let issue_event = client
.fetch_event(Filter::new().id(issue_id.event_id))
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
let issue_status = client
.fetch_issue_status(
issue_id.event_id,
[maintainers.as_slice(), &[issue_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&issue_status)?;
let status_event = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&issue_id.event_id,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(issue_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
)
.dedup_tags()
.build(user_pkey);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pkey).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(issue_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue status created: {nevent}");
Ok(())
}
/// Updates the patch's status to `new_status` after validating it with
/// `check_fn`.
pub async fn patch_status_command(
options: CliOptions,
patch_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: PatchStatus,
merge_or_applied_commits: Option<Either<Sha1Hash, Vec<Sha1Hash>>>,
check_fn: impl FnOnce(&PatchStatus) -> N34Result<()>,
) -> N34Result<()> {
let user_pkey = options.pubkey().await?;
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client
.add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let maintainers = repos.extract_maintainers();
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let patch_event = client.fetch_patch(patch_id.event_id).await?;
if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
'close', and 'draft' are not supported."
.to_owned(),
));
}
let (root_patch, root_revision) = get_patch_root_revision(&patch_event)?;
let patch_status = client
.fetch_patch_status(
root_patch,
root_revision,
[maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
)
.await?;
check_fn(&patch_status)?;
let mut status_builder = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&root_patch,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(patch_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, relay_hint.clone())),
);
if new_status.is_merged_or_applied() {
if let Some(merge_commit) = merge_or_applied_commits
.as_ref()
.and_then(|e| e.as_ref().left())
{
let commit = merge_commit.to_string();
status_builder = status_builder
.tag(Tag::custom(
TagKind::custom("merge-commit"),
iter::once(&commit),
))
.tag(Tag::reference(commit));
} else if let Some(applied_commits) = merge_or_applied_commits.and_then(|e| e.right()) {
let commits = applied_commits
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
status_builder = status_builder
.tag(Tag::custom(TagKind::custom("applied-as-commits"), &commits))
.tags(commits.into_iter().map(Tag::reference));
};
let root = if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
relay_hint.as_ref(),
Marker::Reply,
));
root_revision
} else {
root_patch
};
let patches = client.fetch_patch_series(root, patch_event.pubkey).await?;
status_builder = status_builder.tags(
patches
.into_iter()
.map(|p| utils::event_reply_tag(&p.id, relay_hint.as_ref(), Marker::Mention)),
);
}
let status_event = status_builder.dedup_tags().build(user_pkey);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pkey).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(patch_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
]
.concat();
let success = client
.send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Patch status created: {nevent}");
Ok(())
}
/// Fetch and display patches and issues for given repositories.
/// If `list_patches` is true, lists patches instead of issues.
/// `limit` controls the maximum number of items to fetch.
pub async fn list_patches_and_issues(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
list_patches: bool,
limit: usize,
) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
)?)?;
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
let coordinates = naddrs.clone().into_coordinates();
let repos = client.fetch_repos(&coordinates).await?;
let authorized_pubkeys = [naddrs.extract_owners(), repos.extract_maintainers()].concat();
client.add_relays(&repos.extract_relays()).await;
// This helps discover issues and their status.
client
.add_relays(&client.read_relays_from_users(&authorized_pubkeys).await)
.await;
let kind = if list_patches {
Kind::GitPatch
} else {
Kind::GitIssue
};
let mut filter = Filter::new()
.coordinates(coordinates.iter())
.kind(kind)
.limit(limit);
if list_patches {
filter = filter.hashtag("root");
}
let arc_client = Arc::new(client);
// Events are sorted by kind in ascending order:
// 1630 (Open), 1631 (Resolved/Applied), 1632 (Closed), 1633 (Draft)
let events = utils::sort_by_key(
future::join_all(
arc_client
.fetch_events(filter)
.await?
.into_iter()
.take(limit)
.map(|event| {
let c = arc_client.clone();
let keys = authorized_pubkeys.clone();
async move {
let status = if list_patches {
let (root, root_revision) = get_patch_root_revision(&event)?;
c.fetch_patch_status(
root,
root_revision,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Left)?
} else {
c.fetch_issue_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(Either::Right)?
};
N34Result::Ok((event, status))
}
}),
)
.await
.into_iter()
.filter_map(|r| r.ok()),
|(_, status)| status.as_ref().either_into::<Kind>(),
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.collect::<Vec<String>>();
let max_width = lines
.iter()
.map(|s| s.split_once('\n').map_or(85, |(l, _)| l.chars().count()))
.max()
.unwrap_or(85)
.max(67); // length of the event id
println!("{}", lines.join(&format!("{}\n", "-".repeat(max_width))));
Ok(())
}
/// Returns a tuple of (root_id, patch_id) if this is a valid root or revision
/// patch.
fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option<EventId>)> {
if patch_event.is_revision_patch() {
Ok((
patch_event.root_patch_from_revision()?,
Some(patch_event.id),
))
} else if patch_event.is_root_patch() {
Ok((patch_event.id, None))
} else {
Err(N34Error::NotRootPatch)
}
}
/// Formats an event as either a patch or an issue. For patches, extracts the
/// subject line from the Git patch format. For issues, combines the subject
/// with labels. The output includes status and formatted ID.
fn format_patch_and_issue(event: &Event, status: Either<PatchStatus, IssueStatus>) -> String {
let subject = if status.is_left() {
GitPatch::from_str(&event.content)
.map(|p| p.subject)
.unwrap_or_else(|_| {
event
.content
.lines()
.find(|line| line.trim().starts_with("Subject: "))
.unwrap_or_default()
.trim()
.trim_start_matches("Subject: ")
.to_owned()
})
} else {
let labels = event.extract_issue_labels();
let subject = event.extract_issue_subject();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
}
};
format!(
"({status}) {}\nID: {}\n",
utils::smart_wrap(&subject, 85),
event.id.to_bech32().expect("Infallible")
)
}

View File

@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{collections::HashSet, fs, path::Path}; use std::{collections::HashSet, fs, path::PathBuf};
use nostr::{ use nostr::{
nips::nip19::{FromBech32, Nip19Coordinate, ToBech32}, nips::nip19::{FromBech32, Nip19Coordinate, ToBech32},
@@ -51,9 +51,18 @@ pub enum ConfigError {
/// Configuration for the command-line interface. /// Configuration for the command-line interface.
#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)] #[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
pub struct CliConfig { pub struct CliConfig {
/// Path to the configuration file (not serialized)
#[serde(skip)]
path: PathBuf,
/// Groups of repositories and relays. /// Groups of repositories and relays.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sets: Vec<RepoRelaySet>, pub sets: Vec<RepoRelaySet>,
/// The default PoW difficulty
#[serde(skip_serializing_if = "Option::is_none")]
pub pow: Option<u8>,
/// List of fallback relays used if no fallback relays was provided.
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_relays: Option<Vec<RelayUrl>>,
} }
/// A named group of repositories and relays. /// A named group of repositories and relays.
@@ -77,19 +86,21 @@ pub struct RepoRelaySet {
impl CliConfig { impl CliConfig {
/// Reads and parse a TOML config file from the given path, creating it if /// Reads and parse a TOML config file from the given path, creating it if
/// missing. /// missing.
pub fn load_toml(file_path: &Path) -> N34Result<Self> { pub fn load(file_path: PathBuf) -> N34Result<Self> {
tracing::info!(path = %file_path.display(), "Loading configuration from file"); tracing::info!(path = %file_path.display(), "Loading configuration from file");
// Make sure the file is exist // Make sure the file is exist
if let Some(parent) = file_path.parent() { if let Some(parent) = file_path.parent()
if !parent.exists() { && !parent.exists()
fs::create_dir_all(parent)?; {
} fs::create_dir_all(parent)?;
} }
let _ = fs::File::create_new(file_path);
let _ = fs::File::create_new(&file_path);
let mut config: Self = let mut config: Self =
toml::from_str(&fs::read_to_string(file_path).map_err(ConfigError::ReadFile)?) toml::from_str(&fs::read_to_string(&file_path).map_err(ConfigError::ReadFile)?)
.map_err(ConfigError::ParseFile)?; .map_err(ConfigError::ParseFile)?;
config.path = file_path;
config.post_sets()?; config.post_sets()?;
@@ -97,12 +108,12 @@ impl CliConfig {
} }
/// Dump the config as toml in a file /// Dump the config as toml in a file
pub fn dump_toml(mut self, file_path: &Path) -> N34Result<()> { pub fn dump(mut self) -> N34Result<()> {
tracing::debug!(config = ?self, "Writing configuration to {}", file_path.display()); tracing::debug!(config = ?self, "Writing configuration to {}", self.path.display());
self.post_sets()?; self.post_sets()?;
fs::write( fs::write(
file_path, &self.path,
toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?, toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?,
) )
.map_err(ConfigError::WriteFile)?; .map_err(ConfigError::WriteFile)?;

54
src/cli/macros.rs Normal file
View File

@@ -0,0 +1,54 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes 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::traits::CommandRunner;
/// Returns whether the command runner type `T` requires relays.
pub fn get_relays_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_RELAYS
}
/// Returns whether the command runner type `T` requires a signer.
pub fn get_signer_state<T: CommandRunner>(_v: &T) -> bool {
T::NEED_SIGNER
}
/// Executes a command with required setup checks. The first parameter is the
/// command to match on (often `self`), followed by options. Optional
/// subcommands come next, and commands with arguments (after `&`) are listed
/// last.
#[macro_export]
macro_rules! run_command {
($command:ident, $options:ident, $($subcommands:ident)* & $($commands:ident)*) => {
match $command {
$(
Self::$subcommands { subcommands } => subcommands.run($options).await,
)*
$(
Self::$commands ( args ) => {
if $crate::cli::macros::get_relays_state(&args) {
$options.ensure_relays()?;
}
if $crate::cli::macros::get_signer_state(&args) {
$options.ensure_signer()?;
}
args.run($options).await
},
)*
}
};
}

View File

@@ -16,10 +16,14 @@
/// Commands module /// Commands module
pub mod commands; pub mod commands;
/// Common commands used by multiply commands
pub mod common_commands;
/// The CLI config /// The CLI config
pub mod config; pub mod config;
/// Default lazy values for CLI arguments /// Default lazy values for CLI arguments
pub mod defaults; pub mod defaults;
/// Macros for CLI application.
pub mod macros;
/// CLI arguments parsers /// CLI arguments parsers
pub mod parsers; pub mod parsers;
/// CLI traits /// CLI traits
@@ -29,6 +33,7 @@ pub mod types;
use clap::Parser; use clap::Parser;
use clap_verbosity_flag::Verbosity; use clap_verbosity_flag::Verbosity;
use types::RelayOrSet;
pub use self::commands::*; pub use self::commands::*;
pub use self::config::*; pub use self::config::*;
@@ -71,3 +76,17 @@ impl Cli {
self.command.run(self.options).await self.command.run(self.options).await
} }
} }
/// Processes the CLI configuration by applying fallback values from config if
/// needed. Returns the processed Cli configuration if successful.
pub fn post_cli(mut cli: Cli) -> N34Result<Cli> {
cli.options.pow = cli.options.pow.or(cli.options.config.pow);
if cli.options.relays.is_empty()
&& let Some(relays) = &cli.options.config.fallback_relays
{
cli.options.relays = relays.iter().cloned().map(RelayOrSet::Relay).collect();
}
Ok(cli)
}

View File

@@ -14,14 +14,17 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fs, path::Path}; use std::{
fs,
path::{Path, PathBuf},
};
use nostr::{ use nostr::{
Kind, Kind,
nips::nip19::{FromBech32, Nip19Coordinate}, nips::nip19::{FromBech32, Nip19Coordinate},
}; };
use super::Cli; use super::CliConfig;
use crate::{ use crate::{
cli::DEFAULT_FALLBACK_PATH, cli::DEFAULT_FALLBACK_PATH,
error::{N34Error, N34Result}, error::{N34Error, N34Result},
@@ -55,11 +58,14 @@ pub fn parse_nostr_address_file(file_path: &Path) -> N34Result<Vec<Nip19Coordina
Ok(addresses) Ok(addresses)
} }
/// Post parse cli arguments /// Loads CLI configuration from given path. Uses default config path if input
pub fn post_parse_cli(mut cli: Cli) -> N34Result<Cli> { /// matches fallback path.
if let Some(DEFAULT_FALLBACK_PATH) = cli.options.config_path.to_str() { pub fn parse_config_path(config_path: &str) -> N34Result<CliConfig> {
cli.options.config_path = super::defaults::config_path()?; let mut path = PathBuf::from(config_path.trim());
}
Ok(cli) if config_path == DEFAULT_FALLBACK_PATH {
path = super::defaults::config_path()?;
};
CliConfig::load(path)
} }

View File

@@ -19,6 +19,13 @@ use crate::error::N34Result;
/// A trait defining the interface for command runners in the CLI. /// A trait defining the interface for command runners in the CLI.
pub trait CommandRunner { pub trait CommandRunner {
/// Whether this command needs the relays option (false by default).
/// Only applies to commands, not subcommands.
const NEED_RELAYS: bool = false;
/// Indicates if this command requires the signer. Defaults to true.
/// Only applies to commands, not subcommands.
const NEED_SIGNER: bool = true;
/// Executes the command and returns a Result indicating success or failure. /// Executes the command and returns a Result indicating success or failure.
async fn run(self, options: CliOptions) -> N34Result<()>; fn run(self, options: CliOptions) -> impl Future<Output = N34Result<()>> + Send;
} }

View File

@@ -17,8 +17,12 @@
use std::str::FromStr; use std::str::FromStr;
use nostr::{ use nostr::{
event::Kind, event::{EventId, Kind},
nips::{self, nip01::Coordinate, nip19::Nip19Coordinate}, nips::{
self,
nip01::Coordinate,
nip19::{self, FromBech32, Nip19Coordinate},
},
types::RelayUrl, types::RelayUrl,
}; };
use tokio::runtime::Handle; use tokio::runtime::Handle;
@@ -47,6 +51,16 @@ pub enum RelayOrSet {
Set(String), Set(String),
} }
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
pub struct NostrEvent {
/// Unique identifier for the event.
pub event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
pub relays: Vec<RelayUrl>,
}
impl NaddrOrSet { impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set. /// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found. /// Returns error if the set naddrs are empty or the set not found.
@@ -75,7 +89,9 @@ impl RelayOrSet {
match self { match self {
Self::Relay(relay) => Ok(vec![relay]), Self::Relay(relay) => Ok(vec![relay]),
Self::Set(name) => { Self::Set(name) => {
let set = sets.get_set(&name)?; let set = sets
.get_set(&name)
.map_err(|_| N34Error::InvalidRelaysArg(name.clone()))?;
if set.relays.is_empty() { if set.relays.is_empty() {
Err(N34Error::EmptySetRelays(name)) Err(N34Error::EmptySetRelays(name))
} else { } else {
@@ -86,6 +102,13 @@ impl RelayOrSet {
} }
} }
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
impl FromStr for NaddrOrSet { impl FromStr for NaddrOrSet {
type Err = String; type Err = String;
@@ -132,30 +155,29 @@ impl FromStr for RelayOrSet {
} }
} }
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> { impl FromStr for NostrEvent {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5)); type Err = String;
let nip5_profile = tokio::task::block_in_place(|| { fn from_str(s: &str) -> Result<Self, Self::Err> {
Handle::current().block_on(async { let str_event = s.trim().trim_start_matches("nostr:");
nips::nip05::profile(format!("{username}@{domain}"), None) if str_event.starts_with("nevent1") {
.await let event = nip19::Nip19Event::from_bech32(str_event).map_err(|e| e.to_string())?;
.map_err(|err| err.to_string()) Ok(Self::new(event.event_id, event.relays))
}) } else if str_event.starts_with("note1") {
})?; Ok(Self::new(
EventId::from_bech32(str_event).map_err(|e| e.to_string())?,
Ok(NaddrOrSet::Naddr( Vec::new(),
Nip19Coordinate::new( ))
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id), } else {
nip5_profile.relays, Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
) }
.expect("The relays is `RelayUrl`"), }
))
} }
#[easy_ext::ext(NaddrOrSetVecExt)] #[easy_ext::ext(NaddrOrSetVecExt)]
impl Vec<NaddrOrSet> { impl Vec<NaddrOrSet> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of /// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinates`] using the given sets. /// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> { pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result<Vec<Nip19Coordinate>> {
self.into_iter() self.into_iter()
.map(|n| n.get_naddrs(sets)) .map(|n| n.get_naddrs(sets))
@@ -184,7 +206,7 @@ impl Vec<RelayOrSet> {
#[easy_ext::ext(OptionNaddrOrSetVecExt)] #[easy_ext::ext(OptionNaddrOrSetVecExt)]
impl Option<Vec<NaddrOrSet>> { impl Option<Vec<NaddrOrSet>> {
/// Converts this vector of [`NaddrOrSet`] into a flat vector of /// Converts this vector of [`NaddrOrSet`] into a flat vector of
/// [`Nip19Coordinates`] using the given sets. /// [`Nip19Coordinate`] using the given sets.
pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result<Option<Vec<Nip19Coordinate>>> { pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result<Option<Vec<Nip19Coordinate>>> {
// Clones self here to simplify command code // Clones self here to simplify command code
self.clone() self.clone()
@@ -192,3 +214,23 @@ impl Option<Vec<NaddrOrSet>> {
.transpose() .transpose()
} }
} }
fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result<NaddrOrSet, String> {
let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
let nip5_profile = tokio::task::block_in_place(|| {
Handle::current().block_on(async {
nips::nip05::profile(format!("{username}@{domain}"), None)
.await
.map_err(|err| err.to_string())
})
})?;
Ok(NaddrOrSet::Naddr(
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
nip5_profile.relays,
)
.expect("The relays is `RelayUrl`"),
))
}

View File

@@ -16,11 +16,25 @@
use std::process::ExitCode; use std::process::ExitCode;
use nostr::event::builder::Error as EventBuilderError; use nostr::event::{Kind, builder::Error as EventBuilderError};
use nostr_sdk::client::Error as ClientError; use nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError; use crate::cli::ConfigError;
/// The input data was incorrect in some way. This should only be used for
/// users data and not system file.
const DATA_ERROR: u8 = 65;
/// An internal software error has been detected. This should be limited to
/// non-operating system related errors.
const SOFTWARE_ERROR: u8 = 70;
/// An error occurred while doing I/O on some file.
const IO_ERROR: u8 = 74;
/// Something was found in an unconfigured or misconfigured state.
const CONFIG_ERROR: u8 = 78;
pub type N34Result<T> = Result<T, N34Error>; pub type N34Result<T> = Result<T, N34Error>;
/// N34 errors /// N34 errors
@@ -34,6 +48,8 @@ pub enum N34Error {
EditorNotFound, EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")] #[error("The file you edited is empty. Please save your changes before exiting the editor.")]
EmptyEditorFile, EmptyEditorFile,
#[error("The editor `{0}` exit with unsuccessful exit code `{1}`")]
EditorErr(String, i32),
#[error("Client Error: {0}")] #[error("Client Error: {0}")]
Client(#[from] ClientError), Client(#[from] ClientError),
#[error("Unable to locate the repository. The repository may not exists in the given relays")] #[error("Unable to locate the repository. The repository may not exists in the given relays")]
@@ -66,6 +82,12 @@ pub enum N34Error {
InvalidNostrAddressFileContent(String), InvalidNostrAddressFileContent(String),
#[error("This command requires at least one relay, but none were provided")] #[error("This command requires at least one relay, but none were provided")]
EmptyRelays, EmptyRelays,
#[error("One naddr is required for this command")]
EmptyNaddrs,
#[error(
"This command requires a signer to sign events. Use `--secret-key` to provide a signer"
)]
SignerRequired,
#[error( #[error(
"Invalid repository address. Expected one of these formats:\n- NIP-05 identifier with \ "Invalid repository address. Expected one of these formats:\n- NIP-05 identifier with \
repository ID: `<user@domain.com>/<repo_id>`\n- Valid NIP-19 naddr string (starts with \ repository ID: `<user@domain.com>/<repo_id>`\n- Valid NIP-19 naddr string (starts with \
@@ -73,18 +95,56 @@ pub enum N34Error {
'{0}' exists." '{0}' exists."
)] )]
InvalidNaddrArg(String), InvalidNaddrArg(String),
#[error(
"Invalid relays. Expected a relay url or a set name that contains some relays\nError: No \
set named '{0}' exists."
)]
InvalidRelaysArg(String),
#[error( #[error(
"The set '{0}' doesn't contain any addresses. Use 'sets update' to add addresses to it." "The set '{0}' doesn't contain any addresses. Use 'sets update' to add addresses to it."
)] )]
EmptySetNaddrs(String), EmptySetNaddrs(String),
#[error("The set '{0}' doesn't contain any relays. Use 'sets update' to add addresses to it.")] #[error("The set '{0}' doesn't contain any relays. Use 'sets update' to add relays to it.")]
EmptySetRelays(String), EmptySetRelays(String),
#[error(
"Issue not found, make sure it is in the relays and make sure that the ID is an issue ID"
)]
CanNotFoundIssue,
#[error(
"Patch not found, make sure it is in the relays and make sure that the ID is an patch ID"
)]
CanNotFoundPatch,
#[error(r#"The given patch id is not a root patch. It must contains `["t", "root"]` tag"#)]
NotRootPatch,
#[error("This status kind can't be set for an issue: {0}")]
InvalidIssueStatus(Kind),
#[error("This status kind can't be set for a patch: {0}")]
InvalidPatchStatus(Kind),
#[error("Can't find the root patch of the given patch-revision")]
RevisionRootNotFound,
#[error("Invalid status for the issue/patch: {0}")]
InvalidStatus(String),
} }
impl N34Error { impl N34Error {
/// Returns the exit code associated with this error /// Returns the exit code associated with this error
pub fn exit_code(&self) -> ExitCode { pub fn exit_code(&self) -> ExitCode {
// TODO: More specific exit code match self {
ExitCode::FAILURE Self::Io(_) | Self::CanNotReadNostrAddressFile(_) => ExitCode::from(IO_ERROR),
Self::Config(_) => ExitCode::from(CONFIG_ERROR),
Self::EditorErr(..) => ExitCode::from(SOFTWARE_ERROR),
Self::InvalidRepoId
| Self::EmptyNostrAddressFile
| Self::InvalidNostrAddressFileContent(_)
| Self::EmptyRelays
| Self::EmptyNaddrs
| Self::SignerRequired
| Self::InvalidNaddrArg(_)
| Self::InvalidRelaysArg(_)
| Self::EmptySetNaddrs(_)
| Self::EmptySetRelays(_)
| Self::NotRootPatch => ExitCode::from(DATA_ERROR),
_ => ExitCode::FAILURE,
}
} }
} }

View File

@@ -15,11 +15,11 @@
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// Command line interface module /// Command line interface module
mod cli; pub mod cli;
/// N34 errors /// N34 errors
mod error; pub mod error;
/// Nostr utils module /// Nostr utils module
mod nostr_utils; pub mod nostr_utils;
use std::{ use std::{
process::ExitCode, process::ExitCode,
@@ -61,7 +61,7 @@ fn set_log_level(verbosity: Verbosity) {
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = match cli::parsers::post_parse_cli(Cli::parse()) { let cli = match cli::post_cli(Cli::parse()) {
Ok(cli) => cli, Ok(cli) => cli,
Err(err) => { Err(err) => {
eprintln!("{err}"); eprintln!("{err}");

View File

@@ -23,10 +23,15 @@ use std::{collections::HashSet, time::Duration};
use futures::future; use futures::future;
use nostr::{ use nostr::{
event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent}, event::{Event, EventId, Kind, Tag, TagKind, TagStandard, Tags, UnsignedEvent},
filter::Filter, filter::Filter,
key::{Keys, PublicKey}, key::PublicKey,
nips::{nip01::Coordinate, nip22, nip34::GitRepositoryAnnouncement}, nips::{
nip01::{Coordinate, Metadata},
nip19::ToBech32,
nip22,
nip34::GitRepositoryAnnouncement,
},
parser::NostrParser, parser::NostrParser,
types::RelayUrl, types::RelayUrl,
}; };
@@ -34,12 +39,14 @@ use nostr_sdk::Client;
use traits::TokenUtils; use traits::TokenUtils;
use crate::{ use crate::{
cli::CliOptions, cli::{CliOptions, issue::IssueStatus, patch::PatchStatus},
error::{N34Error, N34Result}, error::{N34Error, N34Result},
}; };
/// Timeout duration for the clinet. /// Timeout duration for the client.
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500); const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
/// Length of a Nostr npub (public key) in characters.
const NPUB_LEN: usize = 63;
/// Parsed content details /// Parsed content details
#[derive(Clone)] #[derive(Clone)]
@@ -103,17 +110,13 @@ impl NostrClient {
/// Initializes a new [`NostrClient`] instance and connects to the specified /// Initializes a new [`NostrClient`] instance and connects to the specified
/// relays. /// relays.
pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self { pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self {
let client = Self::new( let mut client_builder = Client::builder();
Client::builder()
.signer(Keys::new( if let Some(signer) = options.signer() {
options client_builder = client_builder.signer(signer);
.secret_key }
.as_ref()
.expect("This the only method for now") let client = Self::new(client_builder.build());
.clone(),
))
.build(),
);
client.add_relays(relays).await; client.add_relays(relays).await;
client client
@@ -198,12 +201,22 @@ impl NostrClient {
pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> { pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> {
Ok(self Ok(self
.client .client
.fetch_events(filter, CLIENT_TIMEOUT) .fetch_events(filter.limit(1), CLIENT_TIMEOUT)
.await? .await?
.first_owned()) .first_owned())
} }
/// Try to fetch the reposotoies and returns them /// Fetches the events matching the given filter
pub async fn fetch_events(&self, filter: Filter) -> N34Result<Vec<Event>> {
// Multiply timeout by 5 to account for multiple events being fetched
Ok(self
.client
.fetch_events(filter, CLIENT_TIMEOUT * 5)
.await?
.to_vec())
}
/// Try to fetch the repositories and returns them
pub async fn fetch_repos( pub async fn fetch_repos(
&self, &self,
repo_naddrs: &[Coordinate], repo_naddrs: &[Coordinate],
@@ -226,6 +239,116 @@ impl NostrClient {
.collect() .collect()
} }
/// Fetch the patch by the given id. None if not found
pub async fn fetch_patch(&self, patch_id: EventId) -> N34Result<Event> {
self.fetch_event(Filter::new().id(patch_id).kind(Kind::GitPatch))
.await?
.ok_or(N34Error::CanNotFoundPatch)
}
/// Returns the username for a given public key. If no username is found,
/// falls back to a shortened version of the public key.
pub async fn get_username(&self, user: PublicKey) -> String {
self.fetch_event(Filter::new().kind(Kind::Metadata).author(user))
.await
.ok()
.flatten()
.and_then(|e| Metadata::try_from(&e).ok())
.and_then(|m| m.display_name.or(m.name))
.unwrap_or_else(|| {
let pubkey = user.to_bech32().expect("The error is `Infallible`");
format!("{}...{}", &pubkey[..8], &pubkey[NPUB_LEN - 8..])
})
}
/// Get the latest status of an issue by its ID, only considering status
/// events from authorized_pubkeys. If no valid status event is found,
/// defaults to Open.
pub async fn fetch_issue_status(
&self,
issue_id: EventId,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<IssueStatus> {
self.fetch_events(
Filter::new()
.event(issue_id)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
])
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| IssueStatus::try_from(status.kind))
.unwrap_or_else(|| Ok(IssueStatus::Open))
}
/// Gets the status of a patch. If it's a revision patch, checks if it's
/// closed when the root patch is already merged/applied but doesn't
/// reference this revision. Defaults to Open status if no status event
/// is found.
pub async fn fetch_patch_status(
&self,
root_patch: EventId,
root_revision: Option<EventId>,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchStatus> {
let (root_status, event_tags) = self
.fetch_events(
Filter::new()
.event(root_patch)
.kinds([
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| N34Result::Ok((PatchStatus::try_from(status.kind)?, status.tags)))
.unwrap_or_else(|| Ok((PatchStatus::Open, Tags::new())))?;
if let Some(revision_id) = root_revision
&& root_status.is_merged_or_applied()
&& !event_tags
.filter(TagKind::e())
.any(|t| t.is_reply() && t.content().is_some_and(|c| c == revision_id.to_hex()))
{
return Ok(PatchStatus::Closed);
}
Ok(root_status)
}
pub async fn fetch_patch_series(
&self,
root_patch_id: EventId,
root_patch_author: PublicKey,
) -> N34Result<Vec<Event>> {
Ok(self
.fetch_events(
Filter::new()
.kind(Kind::GitPatch)
.author(root_patch_author)
.event(root_patch_id),
)
.await?
.into_iter()
.filter(|e| {
e.tags.iter().any(|t| {
t.is_root() && t.content().is_some_and(|c| c == root_patch_id.to_hex())
})
})
.collect())
}
/// Finds the root issue or patch for a given event. If the event is already /// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows /// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns /// parent/root references until finding the root or failing. Returns
@@ -245,10 +368,10 @@ impl NostrClient {
{ {
self.add_relay_hint(relay_hint.cloned()).await; self.add_relay_hint(relay_hint.cloned()).await;
let root_event = self.fetch_event(Filter::new().id(*id)).await?; let root_event = self.fetch_event(Filter::new().id(*id)).await?;
if let Some(ref root_event) = root_event { if let Some(ref root_event) = root_event
if !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch) { && !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch)
return Err(N34Error::CanNotReplyToEvent); {
} return Err(N34Error::CanNotReplyToEvent);
} }
return Ok(root_event); return Ok(root_event);
} else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) = } else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
@@ -289,6 +412,21 @@ impl NostrClient {
utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref()) utils::add_read_relays(self.user_relays_list(user).await.ok().flatten().as_ref())
} }
/// Returns the read relays of the given users if found, otherwise empty
/// vector
pub async fn read_relays_from_users(&self, users: &[PublicKey]) -> Vec<RelayUrl> {
self.fetch_events(
Filter::new()
.kind(nostr::event::Kind::RelayList)
.authors(utils::dedup(users.iter().copied())),
)
.await
.unwrap_or_default()
.into_iter()
.flat_map(|e| utils::add_read_relays(Some(&e)))
.collect()
}
/// Parse the given content and returns the details that inside it /// Parse the given content and returns the details that inside it
pub async fn parse_content(&self, content: &str) -> ContentDetails { pub async fn parse_content(&self, content: &str) -> ContentDetails {
let mut write_relays = Vec::new(); let mut write_relays = Vec::new();

View File

@@ -15,8 +15,9 @@
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use nostr::hashes::sha1::Hash as Sha1Hash;
use nostr::{ use nostr::{
event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags}, event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard, Tags},
key::PublicKey, key::PublicKey,
nips::{ nips::{
nip01::Coordinate, nip01::Coordinate,
@@ -28,6 +29,7 @@ use nostr::{
types::{RelayUrl, Url}, types::{RelayUrl, Url},
}; };
use crate::cli::issue::ISSUE_ALT_PREFIX;
use crate::error::{N34Error, N34Result}; use crate::error::{N34Error, N34Result};
@@ -35,12 +37,14 @@ use crate::error::{N34Error, N34Result};
#[easy_ext::ext(TagsExt)] #[easy_ext::ext(TagsExt)]
impl Tags { impl Tags {
/// Search for the given tag and map it value to a function /// Search for the given tag and map it value to a function
#[inline]
pub fn map_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> Option<T> { pub fn map_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> Option<T> {
self.find_standardized(kind).map(f) self.find_standardized(kind).map(f)
} }
/// Search for the given tag and map it value to a function. If the tag not /// Search for the given tag and map it value to a function. If the tag not
/// found return the default `T` /// found return the default `T`
#[inline]
pub fn dmap_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> T pub fn dmap_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> T
where where
T: Default, T: Default,
@@ -50,6 +54,7 @@ impl Tags {
/// Finds the first standard tag of the given kind with the specified /// Finds the first standard tag of the given kind with the specified
/// marker, then applies the function to the tag and returns the result. /// marker, then applies the function to the tag and returns the result.
#[inline]
pub fn map_marker<T>( pub fn map_marker<T>(
&self, &self,
kind: TagKind, kind: TagKind,
@@ -110,18 +115,34 @@ impl EventBuilder {
/// Creates a new [`GitIssue`] event builder with the given /// Creates a new [`GitIssue`] event builder with the given
/// issue details. /// issue details.
pub fn new_git_issue( pub fn new_git_issue(
repository: Coordinate, coordinates: &[Coordinate],
content: String, content: String,
subject: Option<String>, subject: Option<String>,
labels: Vec<String>, labels: Vec<String>,
) -> N34Result<EventBuilder> { ) -> N34Result<EventBuilder> {
EventBuilder::git_issue(GitIssue { let mut coordinates = coordinates.iter();
repository, let first_coordinate = coordinates.next().ok_or(N34Error::EmptyNaddrs)?;
let mut event_builder = EventBuilder::git_issue(GitIssue {
repository: first_coordinate.clone(),
content, content,
subject, subject: subject.clone(),
labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(), labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(),
}) })
.map_err(N34Error::from) .map_err(N34Error::from)?
.tags(
coordinates
.clone()
.map(|c| Tag::coordinate(c.clone(), None)),
)
.tags(coordinates.map(|c| Tag::public_key(c.public_key)));
if let Some(issue_subject) = subject {
event_builder =
event_builder.tag(Tag::alt(format!("{ISSUE_ALT_PREFIX}{issue_subject}")))
}
Ok(event_builder)
} }
} }
@@ -130,6 +151,7 @@ impl EventBuilder {
impl Token<'_> { impl Token<'_> {
/// Returns `Some((public_key, relays))` from the givin token if it's npub1 /// Returns `Some((public_key, relays))` from the givin token if it's npub1
/// or nprofile1 /// or nprofile1
#[inline]
pub fn extract_public_key(&self) -> Option<(PublicKey, Vec<RelayUrl>)> { pub fn extract_public_key(&self) -> Option<(PublicKey, Vec<RelayUrl>)> {
match self { match self {
Token::Nostr(nip21) => { Token::Nostr(nip21) => {
@@ -145,6 +167,7 @@ impl Token<'_> {
/// Returns `Some((note_id, relays))` from the givin token if it's note1 or /// Returns `Some((note_id, relays))` from the givin token if it's note1 or
/// nevent1 /// nevent1
#[inline]
pub fn extract_event_id(&self) -> Option<(EventId, Vec<RelayUrl>)> { pub fn extract_event_id(&self) -> Option<(EventId, Vec<RelayUrl>)> {
match self { match self {
Token::Nostr(nip21) => { Token::Nostr(nip21) => {
@@ -159,6 +182,7 @@ impl Token<'_> {
} }
/// Returns `Some(hashtag)` from the givin token if it's a hashtag /// Returns `Some(hashtag)` from the givin token if it's a hashtag
#[inline]
pub fn extract_hashtag(&self) -> Option<String> { pub fn extract_hashtag(&self) -> Option<String> {
match self { match self {
Token::Hashtag(tag) => Some(tag.trim().to_owned()), Token::Hashtag(tag) => Some(tag.trim().to_owned()),
@@ -171,11 +195,19 @@ impl Token<'_> {
#[easy_ext::ext(NaddrsUtils)] #[easy_ext::ext(NaddrsUtils)]
impl Vec<Nip19Coordinate> { impl Vec<Nip19Coordinate> {
/// Converts these coordinate addresses to basic coordinates /// Converts these coordinate addresses to basic coordinates
#[inline]
pub fn into_coordinates(self) -> Vec<Coordinate> { pub fn into_coordinates(self) -> Vec<Coordinate> {
self.into_iter().map(|n| n.coordinate).collect() self.into_iter().map(|n| n.coordinate).collect()
} }
/// Returns all repository owners' public keys from these coordinates.
#[inline]
pub fn extract_owners(&self) -> Vec<PublicKey> {
self.iter().map(|n| n.public_key).collect()
}
/// Extracts all relay URLs from these coordinates /// Extracts all relay URLs from these coordinates
#[inline]
pub fn extract_relays(&self) -> Vec<RelayUrl> { pub fn extract_relays(&self) -> Vec<RelayUrl> {
self.iter().flat_map(|n| n.relays.clone()).collect() self.iter().flat_map(|n| n.relays.clone()).collect()
} }
@@ -184,8 +216,87 @@ impl Vec<Nip19Coordinate> {
/// Utility functions for working with lists of repository announcement /// Utility functions for working with lists of repository announcement
#[easy_ext::ext(ReposUtils)] #[easy_ext::ext(ReposUtils)]
impl Vec<GitRepositoryAnnouncement> { impl Vec<GitRepositoryAnnouncement> {
/// Extracts all relay URLs from these reposotoies /// Extracts all relay URLs from these repositories
#[inline]
pub fn extract_relays(&self) -> Vec<RelayUrl> { pub fn extract_relays(&self) -> Vec<RelayUrl> {
self.iter().flat_map(|n| n.relays.clone()).collect() self.iter().flat_map(|n| n.relays.clone()).collect()
} }
/// Extract all the maintainers from these repositories
#[inline]
pub fn extract_maintainers(&self) -> Vec<PublicKey> {
self.iter().flat_map(|r| r.maintainers.clone()).collect()
}
/// Gets the first EUC hash from the reposotoies if it exists.
#[inline]
pub fn extract_euc(&self) -> Option<&Sha1Hash> {
self.iter().find_map(|r| r.euc.as_ref())
}
}
/// Utility functions for working with patch events
#[easy_ext::ext(GitPatchUtils)]
impl Event {
/// Returns whether the patch is a root or not
#[inline]
pub fn is_root_patch(&self) -> bool {
self.kind == Kind::GitPatch
&& self
.tags
.filter(TagKind::t())
.any(|t| t.content() == Some("root"))
}
/// Returns whether the patch is patch-revision or not
#[inline]
pub fn is_revision_patch(&self) -> bool {
self.kind == Kind::GitPatch
&& self
.tags
.filter(TagKind::t())
.any(|t| t.content() == Some("root-revision"))
}
/// Gets the root patch ID from a patch-revision event by finding the `e`
/// tag that replies to it. Fails if no such tag is found or if the tag
/// contains an invalid event ID.
pub fn root_patch_from_revision(&self) -> N34Result<EventId> {
self.tags
.iter()
.find(|tag| tag.is_reply())
.ok_or_else(|| {
N34Error::InvalidEvent(
"A patch revision without `e`-reply to the root patch".to_owned(),
)
})?
.content()
.ok_or_else(|| N34Error::InvalidEvent("`e` tag without an event".to_owned()))?
.parse()
.map_err(|err| N34Error::InvalidEvent(format!("Invalid event ID in `e` tag: {err}")))
}
}
/// Utility functions for working with issue events
#[easy_ext::ext(GitIssueUtils)]
impl Event {
/// Gets the subject line of the issue or "N/A" if none exists
#[inline]
pub fn extract_issue_subject(&self) -> &str {
self.tags
.find(TagKind::Subject)
.and_then(|t| t.content())
.unwrap_or("N/A")
}
/// Gets all issue labels formatted as comma-separated hashtags (e.g. "#bug,
/// #feature")
#[inline]
pub fn extract_issue_labels(&self) -> String {
self.tags
.filter(TagKind::t())
.filter_map(|t| t.content().map(|l| format!("#{l}")))
.collect::<Vec<_>>()
.join(", ")
}
} }

View File

@@ -23,11 +23,12 @@ use std::{
}; };
use nostr::{ use nostr::{
event::{Event, EventId, Kind, TagKind, TagStandard}, event::{Event, EventId, Kind, Tag, TagKind, TagStandard},
filter::{Alphabet, SingleLetterTag}, filter::Alphabet,
key::PublicKey, key::PublicKey,
nips::{ nips::{
nip01::Coordinate, nip01::Coordinate,
nip10::Marker,
nip19::{Nip19Coordinate, Nip19Event, ToBech32}, nip19::{Nip19Coordinate, Nip19Event, ToBech32},
nip34::GitRepositoryAnnouncement, nip34::GitRepositoryAnnouncement,
nip65::{self, RelayMetadata}, nip65::{self, RelayMetadata},
@@ -42,17 +43,20 @@ use crate::{
}; };
/// Returns the value of the given tag /// Returns the value of the given tag
#[inline]
fn tag_value(tag: &TagStandard) -> String { fn tag_value(tag: &TagStandard) -> String {
tag.clone().to_vec().remove(1) tag.clone().to_vec().remove(1)
} }
/// Parses the tag value into type `T` if possible. /// Parses the tag value into type `T` if possible.
#[inline]
fn parse_value<T: FromStr>(tag: &TagStandard) -> Option<T> { fn parse_value<T: FromStr>(tag: &TagStandard) -> Option<T> {
tag_value(tag).parse().ok() tag_value(tag).parse().ok()
} }
/// Gets all values from the tag. If any value fails to parse, returns an empty /// Gets all values from the tag. If any value fails to parse, returns an empty
/// vector. /// vector.
#[inline]
fn tag_values<T>(tag: &TagStandard) -> Vec<T> fn tag_values<T>(tag: &TagStandard) -> Vec<T>
where where
T: FromStr + fmt::Debug, T: FromStr + fmt::Debug,
@@ -81,7 +85,7 @@ pub fn event_into_repo(event: Event, repo_id: impl Into<String>) -> GitRepositor
description: tags.map_tag(TagKind::Description, tag_value), description: tags.map_tag(TagKind::Description, tag_value),
euc: tags euc: tags
.map_marker( .map_marker(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), TagKind::single_letter(Alphabet::R, false),
"euc", "euc",
parse_value, parse_value,
) )
@@ -110,8 +114,21 @@ where
vector vector
} }
/// Sorts items from the iterator using the given key function.
/// The sorting is unstable, but faster than stable sorting.
pub fn sort_by_key<I, T, K>(iterator: I, key: impl FnMut(&T) -> K) -> impl Iterator<Item = T>
where
I: IntoIterator<Item = T>,
K: Ord,
{
let mut vector = Vec::<T>::from_iter(iterator);
vector.sort_unstable_by_key(key);
vector.into_iter()
}
/// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay /// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay
/// URLs. /// URLs.
#[inline]
pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> { pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
Nip19Event::new(event_id) Nip19Event::new(event_id)
.relays( .relays(
@@ -126,6 +143,7 @@ pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
/// Creates a NIP-19 naddr string for a git repository announcement and up to 3 /// Creates a NIP-19 naddr string for a git repository announcement and up to 3
/// unique relay URLs. /// unique relay URLs.
#[inline]
pub fn repo_naddr( pub fn repo_naddr(
repo_id: impl Into<String>, repo_id: impl Into<String>,
pubk: PublicKey, pubk: PublicKey,
@@ -183,16 +201,16 @@ pub fn read_editor(file_pre_content: Option<&str>, file_suffix: &str) -> N34Resu
// Disable the logs to not show up in a terminal text editor // Disable the logs to not show up in a terminal text editor
crate::EDITOR_OPEN.store(true, Ordering::Relaxed); crate::EDITOR_OPEN.store(true, Ordering::Relaxed);
let exit_status = std::process::Command::new(editor) let exit_status = std::process::Command::new(&editor)
.arg(temp_path.to_str().expect("The path is valid utf8")) .arg(temp_path.to_str().expect("The path is valid utf8"))
.spawn()? .spawn()?
.wait()?; .wait()?;
crate::EDITOR_OPEN.store(false, Ordering::Relaxed); crate::EDITOR_OPEN.store(false, Ordering::Relaxed);
if !exit_status.success() { if !exit_status.success()
if let Some(code) = exit_status.code() { && let Some(code) = exit_status.code()
tracing::warn!("The editor exit with `{code}` status") {
} return Err(N34Error::EditorErr(editor, code));
} }
let content = fs::read_to_string(&temp_path) let content = fs::read_to_string(&temp_path)
@@ -222,12 +240,14 @@ pub fn get_content(
} }
/// Path to the `nostr-address` file in current directory. /// Path to the `nostr-address` file in current directory.
#[inline]
pub fn nostr_address_path() -> std::io::Result<PathBuf> { pub fn nostr_address_path() -> std::io::Result<PathBuf> {
std::env::current_dir().map(|p| p.join(NOSTR_ADDRESS_FILE)) std::env::current_dir().map(|p| p.join(NOSTR_ADDRESS_FILE))
} }
/// If the given coordinate is Some, return it. Otherwise, try to read and parse /// Returns the given coordinates if Some, otherwise attempts to read and parse
/// the first non-comment line from the `nostr-address` file. /// coordinates from the specified file. Returns an empty vector if the file
/// doesn't exist.
pub fn naddrs_or_file( pub fn naddrs_or_file(
naddrs: Option<Vec<Nip19Coordinate>>, naddrs: Option<Vec<Nip19Coordinate>>,
address_file_path: &Path, address_file_path: &Path,
@@ -235,5 +255,58 @@ pub fn naddrs_or_file(
if let Some(naddrs) = naddrs { if let Some(naddrs) = naddrs {
return Ok(naddrs); return Ok(naddrs);
} }
parsers::parse_nostr_address_file(address_file_path)
if address_file_path.exists() {
parsers::parse_nostr_address_file(address_file_path)
} else {
Ok(Vec::new())
}
}
/// Generate a reply tag for an event with the given ID, relay URL (if any), and
/// marker.
#[inline]
pub fn event_reply_tag(reply_to: &EventId, relay: Option<&RelayUrl>, marker: Marker) -> Tag {
Tag::custom(
TagKind::e(),
[
reply_to.to_hex(),
relay.map(|r| r.to_string()).unwrap_or_default(),
marker.to_string(),
],
)
}
/// Wraps text into lines no longer than max_width, breaking only at whitespace.
pub fn smart_wrap(text: &str, max_width: usize) -> String {
text.lines()
.map(|line| {
if !line.trim().is_empty() {
line.split(" ")
.fold((String::new(), 0), |(result, last_newline), word| {
let result_len = result.chars().count();
if result_len == 0 {
(word.to_owned(), 0)
} else if (result_len - last_newline) + word.chars().count() > max_width {
(format!("{result}\n{word}"), result_len + 1)
} else {
(format!("{result} {word}"), last_newline)
}
})
.0
} else {
String::new()
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// Returns an error if the given naddrs is empty otherwise returned it
pub fn check_empty_naddrs(naddrs: Vec<Nip19Coordinate>) -> N34Result<Vec<Nip19Coordinate>> {
if naddrs.is_empty() {
return Err(N34Error::EmptyNaddrs);
}
Ok(naddrs)
} }