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 --- src/lib/client.rs | 38 ++++++++++++++++++++-- src/lib/git_events.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) (limited to 'src/lib') 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