upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 11:33:10 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 11:41:09 +0000
commit3ac5395b47c709c00f0072668dfdceb04f2d4974 (patch)
tree79508c5dbdf92590c667fcc407a464f1e0118e92
parent0e493c455a0345c206dd1c5b0dfb5322b8a4e3e9 (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.md4
-rw-r--r--skills/ngit/SKILL.md2
-rw-r--r--src/bin/ngit/cli.rs24
-rw-r--r--src/bin/ngit/main.rs10
-rw-r--r--src/bin/ngit/sub_commands/label.rs219
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
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
122ngit pr close <ID|nevent> 122ngit pr close <ID|nevent>
123ngit pr reopen <ID|nevent> 123ngit pr reopen <ID|nevent>
124ngit pr ready <ID|nevent> # mark draft as ready for review 124ngit pr ready <ID|nevent> # mark draft as ready for review
125ngit 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"
138ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent> 139ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent>
139ngit issue close <ID|nevent> 140ngit issue close <ID|nevent>
140ngit issue reopen <ID|nevent> 141ngit issue reopen <ID|nevent>
142ngit 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 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 git_events::{KIND_LABEL, get_labels},
5};
6use nostr::{EventBuilder, Tag, TagStandard};
7use nostr_sdk::{EventId, FromBech32};
8use nostr::nips::nip19::Nip19;
9
10use 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
20fn 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)]
39async 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
213pub async fn launch_issue_label(id: &str, labels: &[String], offline: bool) -> Result<()> {
214 publish_label_event(id, labels, offline, "issue").await
215}
216
217pub 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;
7pub mod issue_create; 7pub mod issue_create;
8pub mod issue_list; 8pub mod issue_list;
9pub mod issue_status; 9pub mod issue_status;
10pub mod label;
10pub mod list; 11pub mod list;
11pub mod login; 12pub mod login;
12pub mod logout; 13pub mod logout;