From 0e493c455a0345c206dd1c5b0dfb5322b8a4e3e9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 11:28:36 +0000 Subject: feat(labels): fetch and apply NIP-32 kind-1985 label events - Add KIND_LABEL (kind 1985) constant to git_events.rs - Add get_labels() merging inline t-tags with external kind-1985 events, gating each on author-or-maintainer permission - Extend get_fetch_filters() to request kind-1985 events for all known issue and proposal IDs - Track label event counts in FetchReport (field + Display + consolidation) - Update issue_list.rs and list.rs to fetch label events from cache and pass them through get_labels() instead of reading t-tags inline --- CHANGELOG.md | 3 ++ src/bin/ngit/sub_commands/issue_list.rs | 27 +++++----- src/bin/ngit/sub_commands/list.rs | 83 ++++++++++++------------------- src/lib/client.rs | 38 ++++++++++++-- src/lib/git_events.rs | 88 +++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 182fb6c..5a9fc51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 +- `ngit issue label --label [--label ...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event +- `ngit pr label --label [--label ...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event - `ngit account whoami` — show the currently logged-in account(s) - `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 - `ngit pr view ` — view a PR with its full details and all comments (author, timestamp, body) in chronological order diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs index d7c8ac9..22b1b8a 100644 --- a/src/bin/ngit/sub_commands/issue_list.rs +++ b/src/bin/ngit/sub_commands/issue_list.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use anyhow::{Context, Result, bail}; use ngit::{ client::{Params, get_events_from_local_cache, get_issues_from_cache}, - git_events::{KIND_COMMENT, get_status, status_kinds, tag_value}, + git_events::{KIND_COMMENT, KIND_LABEL, get_labels, get_status, status_kinds, tag_value}, }; use nostr::{ FromBech32, ToBech32, @@ -38,17 +38,7 @@ fn get_issue_title(event: &nostr::Event) -> String { }) } -fn get_issue_labels(event: &nostr::Event) -> Vec { - event - .tags - .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0].eq("t") - }) - .map(|t| t.as_slice()[1].clone()) - .collect() -} + fn status_kind_to_str(kind: Kind) -> &'static str { match kind { @@ -184,6 +174,17 @@ pub async fn launch( statuses }; + // Fetch NIP-32 kind-1985 label events for all issues. + let label_events: Vec = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .events(issues.iter().map(|e| e.id)) + .kind(KIND_LABEL), + ], + ) + .await?; + let comment_counts = get_comment_counts(git_repo_path, &issues).await?; let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect(); @@ -203,7 +204,7 @@ pub async fn launch( if !status_filter.contains(status_str) && !status_filter.contains("unknown") { return None; } - let issue_labels = get_issue_labels(issue); + let issue_labels = get_labels(issue, &repo_ref, &label_events); if !label_filter.is_empty() { let issue_labels_lower: HashSet = issue_labels.iter().map(|t| t.to_lowercase()).collect(); diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index 547c051..404b25e 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -15,7 +15,8 @@ use ngit::{ }, fetch::fetch_from_git_server, git_events::{ - KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch, + KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + get_commit_id_from_patch, get_labels, get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value, }, repo_ref::{RepoRef, is_grasp_server_in_list}, @@ -162,6 +163,17 @@ pub async fn launch( statuses }; + // Fetch NIP-32 kind-1985 label events for all proposals. + let label_events: Vec = get_events_from_local_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .events(proposals_and_revisions.iter().map(|e| e.id)) + .kind(KIND_LABEL), + ], + ) + .await?; + let mut open_proposals: Vec<&nostr::Event> = vec![]; let mut draft_proposals: Vec<&nostr::Event> = vec![]; let mut closed_proposals: Vec<&nostr::Event> = vec![]; @@ -191,7 +203,7 @@ pub async fn launch( // OR filter: proposal must have at least one of the requested labels. let label_filter: HashSet = labels.iter().map(|l| l.trim().to_lowercase()).collect(); - let filtered_proposals: Vec<(&nostr::Event, Kind)> = proposals + let filtered_proposals: Vec<(&nostr::Event, Kind, Vec)> = proposals .iter() .filter_map(|p| { let status_kind = get_status(p, &repo_ref, &statuses, &proposals); @@ -205,21 +217,15 @@ pub async fn launch( if !status_filter.contains(status_str) && !status_filter.contains("unknown") { return None; } + let proposal_labels = get_labels(p, &repo_ref, &label_events); if !label_filter.is_empty() { - let proposal_labels: HashSet = p - .tags - .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0].eq("t") - }) - .map(|t| t.as_slice()[1].to_lowercase()) - .collect(); - if !label_filter.iter().any(|l| proposal_labels.contains(l)) { + let proposal_labels_lower: HashSet = + proposal_labels.iter().map(|l| l.to_lowercase()).collect(); + if !label_filter.iter().any(|l| proposal_labels_lower.contains(l)) { return None; } } - Some((p, status_kind)) + Some((p, status_kind, proposal_labels)) }) .collect(); @@ -241,7 +247,6 @@ pub async fn launch( }; return show_proposal_details( &filtered_proposals, - &repo_ref, event_id_or_nevent, json, show_comments, @@ -251,9 +256,9 @@ pub async fn launch( } if json { - output_json(&filtered_proposals, &repo_ref)?; + output_json(&filtered_proposals)?; } else { - output_table(&filtered_proposals, &repo_ref, &status, &label_filter); + output_table(&filtered_proposals, &status, &label_filter); } Ok(()) @@ -317,8 +322,7 @@ fn status_kind_to_str(kind: Kind) -> &'static str { } fn output_table( - proposals: &[(&nostr::Event, Kind)], - _repo_ref: &RepoRef, + proposals: &[(&nostr::Event, Kind, Vec)], status_filter: &str, label_filter: &HashSet, ) { @@ -328,7 +332,7 @@ fn output_table( } println!("{:<66} {:<8} TITLE LABELS", "ID", "STATUS"); - for (proposal, status_kind) in proposals { + for (proposal, status_kind, proposal_labels) in proposals { let id = proposal.id.to_string(); let status = status_kind_to_str(*status_kind); let title = if let Ok(cl) = event_to_cover_letter(proposal) { @@ -338,14 +342,9 @@ fn output_table( } else { proposal.id.to_string() }; - let labels_str: String = proposal - .tags + let labels_str: String = proposal_labels .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0].eq("t") - }) - .map(|t| format!("#{}", t.as_slice()[1])) + .map(|l| format!("#{l}")) .collect::>() .join(" "); if labels_str.is_empty() { @@ -377,10 +376,10 @@ fn output_table( ); } -fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Result<()> { +fn output_json(proposals: &[(&nostr::Event, Kind, Vec)]) -> Result<()> { let json_output: Vec = proposals .iter() - .map(|(proposal, status_kind)| { + .map(|(proposal, status_kind, proposal_labels)| { let id = proposal.id.to_string(); let status = status_kind_to_str(*status_kind).to_string(); let (title, author, branch) = if let Ok(cl) = event_to_cover_letter(proposal) { @@ -401,22 +400,13 @@ fn output_json(proposals: &[(&nostr::Event, Kind)], _repo_ref: &RepoRef) -> Resu String::new(), ) }; - let labels: Vec = proposal - .tags - .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0].eq("t") - }) - .map(|t| t.as_slice()[1].clone()) - .collect(); serde_json::json!({ "id": id, "status": status, "title": title, "author": author, "branch": branch, - "labels": labels, + "labels": proposal_labels, }) }) .collect(); @@ -454,8 +444,7 @@ fn comment_reply_to(comment: &nostr::Event) -> Option { #[allow(clippy::too_many_lines)] fn show_proposal_details( - proposals: &[(&nostr::Event, Kind)], - _repo_ref: &RepoRef, + proposals: &[(&nostr::Event, Kind, Vec)], event_id_or_nevent: &str, json: bool, show_comments: bool, @@ -466,24 +455,14 @@ fn show_proposal_details( let target_id = resolve_event_id(event_id_or_nevent)?; - let (proposal, status_kind) = proposals + let (proposal, status_kind, proposal_labels) = proposals .iter() - .find(|(p, _)| p.id == target_id) + .find(|(p, _, _)| p.id == target_id) .context("proposal not found")?; let cover_letter = event_to_cover_letter(proposal) .context("failed to extract proposal details from proposal root event")?; - let proposal_labels: Vec = proposal - .tags - .iter() - .filter(|t| { - let s = t.as_slice(); - s.len() >= 2 && s[0].eq("t") - }) - .map(|t| t.as_slice()[1].clone()) - .collect(); - if json { let json_output = if show_comments { let comments_json: Vec = comments diff --git a/src/lib/client.rs b/src/lib/client.rs index 8501a1f..94a173f 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -56,9 +56,9 @@ use crate::{ get_dirs, git::{Repo, RepoActions, get_git_config_item}, git_events::{ - KIND_COMMENT, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, - event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, - event_is_valid_pr_or_pr_update, status_kinds, + KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, + event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, }, login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, repo_ref::{RepoRef, normalize_grasp_server_url}, @@ -1998,6 +1998,8 @@ async fn process_fetched_events( } } else if event.kind.eq(&KIND_COMMENT) { report.comments.insert(event.id); + } else if event.kind.eq(&KIND_LABEL) { + report.labels.insert(event.id); } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) { if request.missing_contributor_profiles.contains(&event.pubkey) { @@ -2121,6 +2123,9 @@ pub fn consolidate_fetch_reports(reports: Vec>) -> FetchRepo for c in relay_report.comments { report.comments.insert(c); } + for c in relay_report.labels { + report.labels.insert(c); + } report.deletions += relay_report.deletions; for c in relay_report.contributor_profiles { report.contributor_profiles.insert(c); @@ -2245,6 +2250,24 @@ pub fn get_fetch_filters( ] } }, + // Fetch NIP-32 kind-1985 label events for issues and proposals. + // Label events reference the target via a lowercase `e` tag. + { + let all_root_ids: HashSet = issue_ids + .iter() + .chain(proposal_ids.iter()) + .copied() + .collect(); + if all_root_ids.is_empty() { + vec![] + } else { + vec![ + nostr::Filter::default() + .events(all_root_ids) + .kind(KIND_LABEL), + ] + } + }, // Request kind-5 deletions for state events and repo announcements by // their event ID (#e tag), as per NIP-09. The #a-tagged filter above // covers addressable-event deletions; this covers the specific event IDs @@ -2333,6 +2356,8 @@ pub struct FetchReport { issue_statuses: HashSet, /// NIP-22 kind-1111 comments against issues, patches, and PRs. comments: HashSet, + /// NIP-32 kind-1985 label events for issues and proposals. + labels: HashSet, /// Count of kind-5 deletion events received (for display purposes). deletions: u32, contributor_profiles: HashSet, @@ -2421,6 +2446,13 @@ impl Display for FetchReport { if self.comments.len() > 1 { "s" } else { "" }, )); } + if !self.labels.is_empty() { + display_items.push(format!( + "{} label{}", + self.labels.len(), + if self.labels.len() > 1 { "s" } else { "" }, + )); + } if self.deletions > 0 { display_items.push(format!( "{} deletion{}", diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index 7c5dda2..a5aef12 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -91,6 +91,9 @@ pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); /// NIP-22 comment (kind 1111) — threaded comments on any event. pub const KIND_COMMENT: Kind = Kind::Custom(1111); +/// NIP-32 label event (kind 1985) — applies hashtag labels to an existing +/// event. Used to add labels to issues, patches and PRs after the fact. +pub const KIND_LABEL: Kind = Kind::Custom(1985); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) @@ -975,6 +978,91 @@ pub fn is_event_proposal_root_for_branch( )) } +/// Compute the effective set of labels for `event`. +/// +/// Labels come from two sources, both subject to the same permission check: +/// +/// 1. `t` tags on the event itself (self-reported by the event author). +/// 2. NIP-32 kind-1985 label events in `all_label_events` that reference +/// `event` via a lowercase `e` tag and carry `["L", "#t"]` + +/// `["l", "", "#t"]` tags. +/// +/// A label is only applied when the author of the source event (the original +/// event for inline `t` tags, or the kind-1985 event for external labels) is +/// either the author of `event` itself or one of the repository maintainers. +pub fn get_labels( + event: &Event, + repo_ref: &RepoRef, + all_label_events: &[Event], +) -> Vec { + let is_permitted = |pubkey: &PublicKey| -> bool { + pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) + }; + + // 1. Inline `t` tags on the event itself — only if the event author is + // permitted (they always are, since they authored the event, but we + // keep the check symmetric with the external-label path). + let mut labels: Vec = if is_permitted(&event.pubkey) { + event + .tags + .iter() + .filter(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("t") + }) + .map(|t| t.as_slice()[1].clone()) + .collect() + } else { + vec![] + }; + + // 2. External NIP-32 kind-1985 label events. + // + // A valid label event must: + // - be kind 1985 + // - reference `event` via a lowercase `e` tag + // - have `["L", "#t"]` (namespace declaration) + // - have at least one `["l", "", "#t"]` tag + // - be authored by a permitted pubkey + let event_id_str = event.id.to_string(); + for label_event in all_label_events { + if !label_event.kind.eq(&KIND_LABEL) { + continue; + } + if !is_permitted(&label_event.pubkey) { + continue; + } + // Must reference our event via a lowercase `e` tag. + let references_event = label_event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) + }); + if !references_event { + continue; + } + // Must declare the `#t` namespace. + let has_namespace = label_event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("L") && s[1].eq("#t") + }); + if !has_namespace { + continue; + } + // Collect all `["l", "", "#t"]` labels from this event. + for tag in label_event.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 3 && s[0].eq("l") && s[2].eq("#t") && !s[1].is_empty() { + let label = s[1].clone(); + if !labels.contains(&label) { + labels.push(label); + } + } + } + } + + labels +} + pub fn get_status( proposal: &Event, repo_ref: &RepoRef, -- cgit v1.2.3