diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 11:33:10 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 11:41:09 +0000 |
| commit | 3ac5395b47c709c00f0072668dfdceb04f2d4974 (patch) | |
| tree | 79508c5dbdf92590c667fcc407a464f1e0118e92 | |
| parent | 0e493c455a0345c206dd1c5b0dfb5322b8a4e3e9 (diff) | |
feat(label): add `ngit issue label` and `ngit pr label` commands
Publishes a NIP-32 kind-1985 label event referencing the target issue or
PR. Only the event author or a repository maintainer may apply labels.
Duplicate labels (already present via t-tags or prior kind-1985 events)
are silently skipped. The new event is saved to the local cache before
broadcasting so subsequent reads reflect the change immediately.
CLI:
ngit issue label <id> --label <L> [--label <L>...]
ngit pr label <id> --label <L> [--label <L>...]
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | skills/ngit/SKILL.md | 2 | ||||
| -rw-r--r-- | src/bin/ngit/cli.rs | 24 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 10 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/label.rs | 219 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/mod.rs | 1 |
6 files changed, 258 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9fc51..3e9c1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md | |||
| @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 10 | ### Added | 10 | ### Added |
| 11 | 11 | ||
| 12 | - NIP-32 label support: kind-1985 label events are now fetched alongside status events and merged with inline `t` tags to compute the effective label set for issues and PRs; only labels authored by the issue/PR author or a repository maintainer are applied; label counts appear in the fetch progress report | 12 | - NIP-32 label support: kind-1985 label events are now fetched alongside status events and merged with inline `t` tags to compute the effective label set for issues and PRs; only labels authored by the issue/PR author or a repository maintainer are applied; label counts appear in the fetch progress report |
| 13 | - `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event | 13 | - `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately |
| 14 | - `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event | 14 | - `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately |
| 15 | - `ngit account whoami` — show the currently logged-in account(s) | 15 | - `ngit account whoami` — show the currently logged-in account(s) |
| 16 | - `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged | 16 | - `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged |
| 17 | - `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order | 17 | - `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order |
diff --git a/skills/ngit/SKILL.md b/skills/ngit/SKILL.md index 7621086..1436452 100644 --- a/skills/ngit/SKILL.md +++ b/skills/ngit/SKILL.md | |||
| @@ -122,6 +122,7 @@ git push origin main # push to nostr remote records the merge event | |||
| 122 | ngit pr close <ID|nevent> | 122 | ngit pr close <ID|nevent> |
| 123 | ngit pr reopen <ID|nevent> | 123 | ngit pr reopen <ID|nevent> |
| 124 | ngit pr ready <ID|nevent> # mark draft as ready for review | 124 | ngit pr ready <ID|nevent> # mark draft as ready for review |
| 125 | ngit pr label <ID|nevent> --label bug --label enhancement | ||
| 125 | ``` | 126 | ``` |
| 126 | 127 | ||
| 127 | ## Issues | 128 | ## Issues |
| @@ -138,6 +139,7 @@ ngit issue comment <ID|nevent> --body "Reproduced on v2.1" | |||
| 138 | ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent> | 139 | ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent> |
| 139 | ngit issue close <ID|nevent> | 140 | ngit issue close <ID|nevent> |
| 140 | ngit issue reopen <ID|nevent> | 141 | ngit issue reopen <ID|nevent> |
| 142 | ngit issue label <ID|nevent> --label bug --label enhancement | ||
| 141 | ``` | 143 | ``` |
| 142 | 144 | ||
| 143 | ## Account management | 145 | ## Account management |
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs index ccf25bb..b9b274e 100644 --- a/src/bin/ngit/cli.rs +++ b/src/bin/ngit/cli.rs | |||
| @@ -301,6 +301,18 @@ pub enum PrCommands { | |||
| 301 | #[arg(long)] | 301 | #[arg(long)] |
| 302 | offline: bool, | 302 | offline: bool, |
| 303 | }, | 303 | }, |
| 304 | /// add one or more labels to a PR (author or maintainer only) | ||
| 305 | Label { | ||
| 306 | /// Proposal event-id (hex) or nevent (bech32) | ||
| 307 | #[arg(value_name = "ID|nevent")] | ||
| 308 | id: String, | ||
| 309 | /// Label to apply (repeatable: --label bug --label help-wanted) | ||
| 310 | #[arg(long = "label", value_name = "LABEL", required = true)] | ||
| 311 | labels: Vec<String>, | ||
| 312 | /// Use local cache only, skip network fetch | ||
| 313 | #[arg(long)] | ||
| 314 | offline: bool, | ||
| 315 | }, | ||
| 304 | } | 316 | } |
| 305 | 317 | ||
| 306 | // --------------------------------------------------------------------------- | 318 | // --------------------------------------------------------------------------- |
| @@ -395,6 +407,18 @@ pub enum IssueCommands { | |||
| 395 | #[arg(long)] | 407 | #[arg(long)] |
| 396 | offline: bool, | 408 | offline: bool, |
| 397 | }, | 409 | }, |
| 410 | /// add one or more labels to an issue (author or maintainer only) | ||
| 411 | Label { | ||
| 412 | /// Issue event-id (hex) or nevent (bech32) | ||
| 413 | #[arg(value_name = "ID|nevent")] | ||
| 414 | id: String, | ||
| 415 | /// Label to apply (repeatable: --label bug --label help-wanted) | ||
| 416 | #[arg(long = "label", value_name = "LABEL", required = true)] | ||
| 417 | labels: Vec<String>, | ||
| 418 | /// Use local cache only, skip network fetch | ||
| 419 | #[arg(long)] | ||
| 420 | offline: bool, | ||
| 421 | }, | ||
| 398 | } | 422 | } |
| 399 | 423 | ||
| 400 | #[derive(Subcommand)] | 424 | #[derive(Subcommand)] |
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 6a5a8f0..b0cf375 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs | |||
| @@ -130,6 +130,11 @@ async fn main() { | |||
| 130 | squash, | 130 | squash, |
| 131 | offline, | 131 | offline, |
| 132 | } => sub_commands::pr_merge::launch(id, *squash, *offline).await, | 132 | } => sub_commands::pr_merge::launch(id, *squash, *offline).await, |
| 133 | PrCommands::Label { | ||
| 134 | id, | ||
| 135 | labels, | ||
| 136 | offline, | ||
| 137 | } => sub_commands::label::launch_pr_label(id, labels, *offline).await, | ||
| 133 | }, | 138 | }, |
| 134 | Commands::Issue(args) => match &args.issue_command { | 139 | Commands::Issue(args) => match &args.issue_command { |
| 135 | IssueCommands::List { | 140 | IssueCommands::List { |
| @@ -193,6 +198,11 @@ async fn main() { | |||
| 193 | ) | 198 | ) |
| 194 | .await | 199 | .await |
| 195 | } | 200 | } |
| 201 | IssueCommands::Label { | ||
| 202 | id, | ||
| 203 | labels, | ||
| 204 | offline, | ||
| 205 | } => sub_commands::label::launch_issue_label(id, labels, *offline).await, | ||
| 196 | }, | 206 | }, |
| 197 | Commands::Sync(args) => sub_commands::sync::launch(args).await, | 207 | Commands::Sync(args) => sub_commands::sync::launch(args).await, |
| 198 | } | 208 | } |
diff --git a/src/bin/ngit/sub_commands/label.rs b/src/bin/ngit/sub_commands/label.rs new file mode 100644 index 0000000..634e0b5 --- /dev/null +++ b/src/bin/ngit/sub_commands/label.rs | |||
| @@ -0,0 +1,219 @@ | |||
| 1 | use anyhow::{Context, Result, bail}; | ||
| 2 | use ngit::{ | ||
| 3 | client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, | ||
| 4 | git_events::{KIND_LABEL, get_labels}, | ||
| 5 | }; | ||
| 6 | use nostr::{EventBuilder, Tag, TagStandard}; | ||
| 7 | use nostr_sdk::{EventId, FromBech32}; | ||
| 8 | use nostr::nips::nip19::Nip19; | ||
| 9 | |||
| 10 | use crate::{ | ||
| 11 | client::{ | ||
| 12 | Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, | ||
| 13 | save_event_in_local_cache, | ||
| 14 | }, | ||
| 15 | git::{Repo, RepoActions}, | ||
| 16 | login, | ||
| 17 | repo_ref::get_repo_coordinates_when_remote_unknown, | ||
| 18 | }; | ||
| 19 | |||
| 20 | fn parse_event_id(id: &str) -> Result<EventId> { | ||
| 21 | if let Ok(nip19) = Nip19::from_bech32(id) { | ||
| 22 | match nip19 { | ||
| 23 | Nip19::Event(e) => return Ok(e.event_id), | ||
| 24 | Nip19::EventId(event_id) => return Ok(event_id), | ||
| 25 | _ => {} | ||
| 26 | } | ||
| 27 | } | ||
| 28 | if let Ok(event_id) = EventId::from_hex(id) { | ||
| 29 | return Ok(event_id); | ||
| 30 | } | ||
| 31 | bail!("invalid event-id or nevent: {id}") | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Shared implementation: publish a NIP-32 kind-1985 label event for `target`. | ||
| 35 | /// | ||
| 36 | /// `labels` must be non-empty. The caller is responsible for ensuring | ||
| 37 | /// `target` is a valid issue or PR event that belongs to the current repo. | ||
| 38 | #[allow(clippy::too_many_lines)] | ||
| 39 | async fn publish_label_event( | ||
| 40 | id: &str, | ||
| 41 | labels: &[String], | ||
| 42 | offline: bool, | ||
| 43 | target_kind: &str, // "issue" or "PR" — used in error messages | ||
| 44 | ) -> Result<()> { | ||
| 45 | if labels.is_empty() { | ||
| 46 | bail!("at least one --label value is required"); | ||
| 47 | } | ||
| 48 | |||
| 49 | let event_id = parse_event_id(id)?; | ||
| 50 | |||
| 51 | let git_repo = Repo::discover().context("failed to find a git repository")?; | ||
| 52 | let git_repo_path = git_repo.get_path()?; | ||
| 53 | |||
| 54 | let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); | ||
| 55 | let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; | ||
| 56 | |||
| 57 | if !offline { | ||
| 58 | fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; | ||
| 59 | } | ||
| 60 | |||
| 61 | let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; | ||
| 62 | |||
| 63 | // Resolve the target event from cache. | ||
| 64 | let target = if target_kind == "issue" { | ||
| 65 | let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 66 | issues | ||
| 67 | .into_iter() | ||
| 68 | .find(|e| e.id == event_id) | ||
| 69 | .context(format!( | ||
| 70 | "issue with id {} not found in cache", | ||
| 71 | event_id.to_hex() | ||
| 72 | ))? | ||
| 73 | } else { | ||
| 74 | let proposals = | ||
| 75 | get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; | ||
| 76 | proposals | ||
| 77 | .into_iter() | ||
| 78 | .find(|e| e.id == event_id) | ||
| 79 | .context(format!( | ||
| 80 | "PR with id {} not found in cache", | ||
| 81 | event_id.to_hex() | ||
| 82 | ))? | ||
| 83 | }; | ||
| 84 | |||
| 85 | // Login — we need the signer and user pubkey. | ||
| 86 | let (signer, user_ref, _) = | ||
| 87 | login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; | ||
| 88 | |||
| 89 | let user_pubkey = signer.get_public_key().await?; | ||
| 90 | |||
| 91 | // Permission check: only the author or a maintainer may label. | ||
| 92 | if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { | ||
| 93 | bail!( | ||
| 94 | "only the {target_kind} author or a repository maintainer can label a {target_kind}" | ||
| 95 | ); | ||
| 96 | } | ||
| 97 | |||
| 98 | // Fetch existing label events so we can warn about duplicates. | ||
| 99 | let existing_label_events = get_events_from_local_cache( | ||
| 100 | git_repo_path, | ||
| 101 | vec![ | ||
| 102 | nostr::Filter::default() | ||
| 103 | .event(event_id) | ||
| 104 | .kind(KIND_LABEL), | ||
| 105 | ], | ||
| 106 | ) | ||
| 107 | .await?; | ||
| 108 | |||
| 109 | let existing_labels = get_labels(&target, &repo_ref, &existing_label_events); | ||
| 110 | |||
| 111 | // Deduplicate: only add labels not already present. | ||
| 112 | let new_labels: Vec<String> = labels | ||
| 113 | .iter() | ||
| 114 | .map(|l| l.trim().to_string()) | ||
| 115 | .filter(|l| !l.is_empty()) | ||
| 116 | .filter(|l| !existing_labels.iter().any(|e| e.eq_ignore_ascii_case(l))) | ||
| 117 | .collect(); | ||
| 118 | |||
| 119 | if new_labels.is_empty() { | ||
| 120 | let already: Vec<String> = labels | ||
| 121 | .iter() | ||
| 122 | .map(|l| format!("#{}", l.trim())) | ||
| 123 | .collect(); | ||
| 124 | println!( | ||
| 125 | "{target_kind} already has label{}: {}", | ||
| 126 | if already.len() == 1 { "" } else { "s" }, | ||
| 127 | already.join(", ") | ||
| 128 | ); | ||
| 129 | return Ok(()); | ||
| 130 | } | ||
| 131 | |||
| 132 | // Build the kind-1985 label event. | ||
| 133 | // | ||
| 134 | // Structure (NIP-32 §hashtag namespace): | ||
| 135 | // ["L", "#t"] — namespace declaration | ||
| 136 | // ["l", "<value>", "#t"] — one tag per label | ||
| 137 | // ["e", <target-id>, <relay>] — reference to the labelled event | ||
| 138 | // ["p", <author-pubkey>] — notify the author | ||
| 139 | let relay_hint = repo_ref.relays.first().cloned(); | ||
| 140 | |||
| 141 | let mut tags: Vec<Tag> = vec![ | ||
| 142 | // Namespace declaration | ||
| 143 | Tag::custom( | ||
| 144 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")), | ||
| 145 | vec!["#t".to_string()], | ||
| 146 | ), | ||
| 147 | ]; | ||
| 148 | |||
| 149 | // One ["l", value, "#t"] tag per label. | ||
| 150 | for label in &new_labels { | ||
| 151 | tags.push(Tag::custom( | ||
| 152 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")), | ||
| 153 | vec![label.clone(), "#t".to_string()], | ||
| 154 | )); | ||
| 155 | } | ||
| 156 | |||
| 157 | // Reference the target event. | ||
| 158 | tags.push(Tag::from_standardized(TagStandard::Event { | ||
| 159 | event_id: target.id, | ||
| 160 | relay_url: relay_hint.clone(), | ||
| 161 | marker: None, | ||
| 162 | public_key: None, | ||
| 163 | uppercase: false, | ||
| 164 | })); | ||
| 165 | |||
| 166 | // Notify the target event author. | ||
| 167 | tags.push(Tag::public_key(target.pubkey)); | ||
| 168 | |||
| 169 | // Human-readable alt text. | ||
| 170 | let label_list = new_labels | ||
| 171 | .iter() | ||
| 172 | .map(|l| format!("#{l}")) | ||
| 173 | .collect::<Vec<_>>() | ||
| 174 | .join(", "); | ||
| 175 | tags.push(Tag::custom( | ||
| 176 | nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), | ||
| 177 | vec![format!("labelled {target_kind} with {label_list}")], | ||
| 178 | )); | ||
| 179 | |||
| 180 | let label_event = ngit::client::sign_event( | ||
| 181 | EventBuilder::new(KIND_LABEL, "").tags(tags), | ||
| 182 | &signer, | ||
| 183 | format!("label {target_kind}"), | ||
| 184 | ) | ||
| 185 | .await?; | ||
| 186 | |||
| 187 | // Save to local cache immediately so subsequent reads reflect the new labels. | ||
| 188 | save_event_in_local_cache(git_repo_path, &label_event).await?; | ||
| 189 | |||
| 190 | let mut client = client; | ||
| 191 | client.set_signer(signer).await; | ||
| 192 | |||
| 193 | send_events( | ||
| 194 | &client, | ||
| 195 | Some(git_repo_path), | ||
| 196 | vec![label_event], | ||
| 197 | user_ref.relays.write(), | ||
| 198 | repo_ref.relays.clone(), | ||
| 199 | true, | ||
| 200 | false, | ||
| 201 | ) | ||
| 202 | .await?; | ||
| 203 | |||
| 204 | println!( | ||
| 205 | "{} {} labelled with {}", | ||
| 206 | target_kind, | ||
| 207 | &event_id.to_hex()[..8], | ||
| 208 | label_list, | ||
| 209 | ); | ||
| 210 | Ok(()) | ||
| 211 | } | ||
| 212 | |||
| 213 | pub async fn launch_issue_label(id: &str, labels: &[String], offline: bool) -> Result<()> { | ||
| 214 | publish_label_event(id, labels, offline, "issue").await | ||
| 215 | } | ||
| 216 | |||
| 217 | pub async fn launch_pr_label(id: &str, labels: &[String], offline: bool) -> Result<()> { | ||
| 218 | publish_label_event(id, labels, offline, "PR").await | ||
| 219 | } | ||
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs index c4d0821..7a3c2b5 100644 --- a/src/bin/ngit/sub_commands/mod.rs +++ b/src/bin/ngit/sub_commands/mod.rs | |||
| @@ -7,6 +7,7 @@ pub mod init; | |||
| 7 | pub mod issue_create; | 7 | pub mod issue_create; |
| 8 | pub mod issue_list; | 8 | pub mod issue_list; |
| 9 | pub mod issue_status; | 9 | pub mod issue_status; |
| 10 | pub mod label; | ||
| 10 | pub mod list; | 11 | pub mod list; |
| 11 | pub mod login; | 12 | pub mod login; |
| 12 | pub mod logout; | 13 | pub mod logout; |