From 3ac5395b47c709c00f0072668dfdceb04f2d4974 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 11:33:10 +0000 Subject: 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 --label [--label ...] ngit pr label --label [--label ...] --- src/bin/ngit/cli.rs | 24 ++++ src/bin/ngit/main.rs | 10 ++ src/bin/ngit/sub_commands/label.rs | 219 +++++++++++++++++++++++++++++++++++++ src/bin/ngit/sub_commands/mod.rs | 1 + 4 files changed, 254 insertions(+) create mode 100644 src/bin/ngit/sub_commands/label.rs (limited to 'src/bin') 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 { #[arg(long)] offline: bool, }, + /// add one or more labels to a PR (author or maintainer only) + Label { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Label to apply (repeatable: --label bug --label help-wanted) + #[arg(long = "label", value_name = "LABEL", required = true)] + labels: Vec, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } // --------------------------------------------------------------------------- @@ -395,6 +407,18 @@ pub enum IssueCommands { #[arg(long)] offline: bool, }, + /// add one or more labels to an issue (author or maintainer only) + Label { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Label to apply (repeatable: --label bug --label help-wanted) + #[arg(long = "label", value_name = "LABEL", required = true)] + labels: Vec, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, } #[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() { squash, offline, } => sub_commands::pr_merge::launch(id, *squash, *offline).await, + PrCommands::Label { + id, + labels, + offline, + } => sub_commands::label::launch_pr_label(id, labels, *offline).await, }, Commands::Issue(args) => match &args.issue_command { IssueCommands::List { @@ -193,6 +198,11 @@ async fn main() { ) .await } + IssueCommands::Label { + id, + labels, + offline, + } => sub_commands::label::launch_issue_label(id, labels, *offline).await, }, Commands::Sync(args) => sub_commands::sync::launch(args).await, } 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 @@ +use anyhow::{Context, Result, bail}; +use ngit::{ + client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events}, + git_events::{KIND_LABEL, get_labels}, +}; +use nostr::{EventBuilder, Tag, TagStandard}; +use nostr_sdk::{EventId, FromBech32}; +use nostr::nips::nip19::Nip19; + +use crate::{ + client::{ + Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache, + save_event_in_local_cache, + }, + git::{Repo, RepoActions}, + login, + repo_ref::get_repo_coordinates_when_remote_unknown, +}; + +fn parse_event_id(id: &str) -> Result { + if let Ok(nip19) = Nip19::from_bech32(id) { + match nip19 { + Nip19::Event(e) => return Ok(e.event_id), + Nip19::EventId(event_id) => return Ok(event_id), + _ => {} + } + } + if let Ok(event_id) = EventId::from_hex(id) { + return Ok(event_id); + } + bail!("invalid event-id or nevent: {id}") +} + +/// Shared implementation: publish a NIP-32 kind-1985 label event for `target`. +/// +/// `labels` must be non-empty. The caller is responsible for ensuring +/// `target` is a valid issue or PR event that belongs to the current repo. +#[allow(clippy::too_many_lines)] +async fn publish_label_event( + id: &str, + labels: &[String], + offline: bool, + target_kind: &str, // "issue" or "PR" — used in error messages +) -> Result<()> { + if labels.is_empty() { + bail!("at least one --label value is required"); + } + + let event_id = parse_event_id(id)?; + + let git_repo = Repo::discover().context("failed to find a git repository")?; + let git_repo_path = git_repo.get_path()?; + + let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo))); + let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?; + + if !offline { + fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; + } + + let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; + + // Resolve the target event from cache. + let target = if target_kind == "issue" { + let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?; + issues + .into_iter() + .find(|e| e.id == event_id) + .context(format!( + "issue with id {} not found in cache", + event_id.to_hex() + ))? + } else { + let proposals = + get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?; + proposals + .into_iter() + .find(|e| e.id == event_id) + .context(format!( + "PR with id {} not found in cache", + event_id.to_hex() + ))? + }; + + // Login — we need the signer and user pubkey. + let (signer, user_ref, _) = + login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?; + + let user_pubkey = signer.get_public_key().await?; + + // Permission check: only the author or a maintainer may label. + if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { + bail!( + "only the {target_kind} author or a repository maintainer can label a {target_kind}" + ); + } + + // Fetch existing label events so we can warn about duplicates. + let existing_label_events = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .event(event_id) + .kind(KIND_LABEL), + ], + ) + .await?; + + let existing_labels = get_labels(&target, &repo_ref, &existing_label_events); + + // Deduplicate: only add labels not already present. + let new_labels: Vec = labels + .iter() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .filter(|l| !existing_labels.iter().any(|e| e.eq_ignore_ascii_case(l))) + .collect(); + + if new_labels.is_empty() { + let already: Vec = labels + .iter() + .map(|l| format!("#{}", l.trim())) + .collect(); + println!( + "{target_kind} already has label{}: {}", + if already.len() == 1 { "" } else { "s" }, + already.join(", ") + ); + return Ok(()); + } + + // Build the kind-1985 label event. + // + // Structure (NIP-32 §hashtag namespace): + // ["L", "#t"] — namespace declaration + // ["l", "", "#t"] — one tag per label + // ["e", , ] — reference to the labelled event + // ["p", ] — notify the author + let relay_hint = repo_ref.relays.first().cloned(); + + let mut tags: Vec = vec![ + // Namespace declaration + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("L")), + vec!["#t".to_string()], + ), + ]; + + // One ["l", value, "#t"] tag per label. + for label in &new_labels { + tags.push(Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("l")), + vec![label.clone(), "#t".to_string()], + )); + } + + // Reference the target event. + tags.push(Tag::from_standardized(TagStandard::Event { + event_id: target.id, + relay_url: relay_hint.clone(), + marker: None, + public_key: None, + uppercase: false, + })); + + // Notify the target event author. + tags.push(Tag::public_key(target.pubkey)); + + // Human-readable alt text. + let label_list = new_labels + .iter() + .map(|l| format!("#{l}")) + .collect::>() + .join(", "); + tags.push(Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("labelled {target_kind} with {label_list}")], + )); + + let label_event = ngit::client::sign_event( + EventBuilder::new(KIND_LABEL, "").tags(tags), + &signer, + format!("label {target_kind}"), + ) + .await?; + + // Save to local cache immediately so subsequent reads reflect the new labels. + save_event_in_local_cache(git_repo_path, &label_event).await?; + + let mut client = client; + client.set_signer(signer).await; + + send_events( + &client, + Some(git_repo_path), + vec![label_event], + user_ref.relays.write(), + repo_ref.relays.clone(), + true, + false, + ) + .await?; + + println!( + "{} {} labelled with {}", + target_kind, + &event_id.to_hex()[..8], + label_list, + ); + Ok(()) +} + +pub async fn launch_issue_label(id: &str, labels: &[String], offline: bool) -> Result<()> { + publish_label_event(id, labels, offline, "issue").await +} + +pub async fn launch_pr_label(id: &str, labels: &[String], offline: bool) -> Result<()> { + publish_label_event(id, labels, offline, "PR").await +} 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; pub mod issue_create; pub mod issue_list; pub mod issue_status; +pub mod label; pub mod list; pub mod login; pub mod logout; -- cgit v1.2.3