Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c029ddf86c | ||
|
|
fc7d7baeb6 | ||
|
|
8715cd5547 | ||
|
|
70e76f1699 | ||
|
|
9e840be820 | ||
|
|
3fb7abe62a | ||
|
|
3814d1b4ec | ||
|
|
1c11b8f6bb | ||
|
|
74d05033f8 | ||
|
|
9bbeda8de8 | ||
|
|
3510b59fbb | ||
|
|
05b4ae30f7 | ||
|
|
387dd32a37 | ||
|
|
5c5987e07b | ||
|
|
9e1e5d7c42 | ||
|
|
7e479f3c7a | ||
|
|
4cc01668fd | ||
|
|
7d700602ef | ||
|
|
7c589f1c6c | ||
|
|
f3a09d30ea | ||
|
|
2874ba8457 | ||
|
|
55e5d86c03 | ||
|
|
6467bc3bb5 | ||
|
|
da284a084c | ||
|
|
45ea7d25f2 | ||
|
|
8b09cff5e3 | ||
|
|
d6dd63f576 | ||
|
|
ae6cff1a1d | ||
|
|
a38a811cbf | ||
|
|
93136fb767 | ||
|
|
035e22a6ba | ||
|
|
043ef6fc96 | ||
|
|
54cae48123 | ||
|
|
1651fd5a47 | ||
|
|
ec08a1febc | ||
|
|
a9a2cb2b16 | ||
|
|
63f6c1fb11 | ||
|
|
3e6fed8bc6 | ||
|
|
431bfd2512 | ||
|
|
364356a573 | ||
|
|
2a3e2e09c0 | ||
|
|
8870f8ced3 | ||
|
|
da96985a40 | ||
|
|
76b76bc8d6 | ||
|
|
983d2f209d | ||
|
|
7ba98bbbe8 | ||
|
|
8f70778c26 | ||
|
|
9069adbe22 | ||
|
|
b1027b3b04 | ||
|
|
5dc8e31eb3 | ||
|
|
51bd239024 | ||
|
|
53a2eb5bda | ||
|
|
a6a61aedf1 | ||
|
|
30b3056d39 | ||
|
|
8fe6755d3b | ||
|
|
812a06a276 | ||
|
|
494cced9cb | ||
|
|
16e6300c81 | ||
|
|
ef8d6c1c4f | ||
|
|
01f5fa60e6 | ||
|
|
7d9b968f16 | ||
|
|
57fc868b63 | ||
|
|
08505151bd | ||
|
|
864f3018f6 | ||
|
|
37128a8c1a |
43
CHANGELOG.md
43
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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 you’re addressing, whether it’s 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
8
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@@ -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
|
||||||
|
|||||||
@@ -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
12
flake.lock
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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 = [ ];
|
||||||
|
|||||||
15
justfile
15
justfile
@@ -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
|
||||||
|
|||||||
43
src/cli/commands/config/mod.rs
Normal file
43
src/cli/commands/config/mod.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/cli/commands/config/pow.rs
Normal file
37
src/cli/commands/config/pow.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/cli/commands/config/relays.rs
Normal file
53
src/cli/commands/config/relays.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/cli/commands/issue/close.rs
Normal file
65
src/cli/commands/issue/close.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/cli/commands/issue/list.rs
Normal file
46
src/cli/commands/issue/list.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
65
src/cli/commands/issue/reopen.rs
Normal file
65
src/cli/commands/issue/reopen.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/cli/commands/issue/resolve.rs
Normal file
59
src/cli/commands/issue/resolve.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/cli/commands/issue/view.rs
Normal file
93
src/cli/commands/issue/view.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
77
src/cli/commands/patch/apply.rs
Normal file
77
src/cli/commands/patch/apply.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/cli/commands/patch/close.rs
Normal file
66
src/cli/commands/patch/close.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/cli/commands/patch/draft.rs
Normal file
73
src/cli/commands/patch/draft.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/cli/commands/patch/fetch.rs
Normal file
137
src/cli/commands/patch/fetch.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/cli/commands/patch/list.rs
Normal file
46
src/cli/commands/patch/list.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/cli/commands/patch/merge.rs
Normal file
77
src/cli/commands/patch/merge.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
631
src/cli/commands/patch/mod.rs
Normal file
631
src/cli/commands/patch/mod.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/cli/commands/patch/reopen.rs
Normal file
67
src/cli/commands/patch/reopen.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/cli/commands/patch/send.rs
Normal file
265
src/cli/commands/patch/send.rs
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>()
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
403
src/cli/common_commands.rs
Normal 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")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
54
src/cli/macros.rs
Normal 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
|
||||||
|
},
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
68
src/error.rs
68
src/error.rs
@@ -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
|
||||||
|
/// user’s 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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(", ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user