From f3fcf863aae000964753f574b00e9fb9f5fcd452 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 13:03:50 +0000 Subject: feat(subject): add pr/issue set-subject via NIP-32 kind-1985 labels Adds the ability to update the displayed title of a PR or issue after creation using a kind-1985 label event with the #subject namespace. Only the author or a repository maintainer may set the subject. The latest authorised event wins with tiebreak by lexicographically larger event ID (NIP-1 replaceable event semantics). Branch names and commit messages are never affected. - Split get_labels() into process_labels() (additive #t) and process_subject() (replaceable-style #subject), with a shared get_labels_and_subject() entry point that processes both from a single pre-fetched slice of kind-1985 events - All list/view/JSON display paths apply the subject override silently - New ngit pr set-subject --subject command - New ngit issue set-subject --subject command --- src/lib/git_events.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 120 insertions(+), 13 deletions(-) (limited to 'src/lib') diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index a5aef12..b512e44 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -978,30 +978,31 @@ pub fn is_event_proposal_root_for_branch( )) } -/// Compute the effective set of labels for `event`. +/// Process hashtag labels (`#t` namespace) from a pre-fetched set of kind-1985 +/// events. /// /// 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"]` + +/// 2. NIP-32 kind-1985 label events in `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( +/// A label is only applied when the author of the source event is either the +/// author of `event` itself or one of the repository maintainers. +/// +/// Labels are additive — all valid label events contribute; there is no +/// "latest wins" replacement semantics. +pub fn process_labels( event: &Event, repo_ref: &RepoRef, - all_label_events: &[Event], + 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). + // 1. Inline `t` tags on the event itself. let mut labels: Vec = if is_permitted(&event.pubkey) { event .tags @@ -1016,7 +1017,7 @@ pub fn get_labels( vec![] }; - // 2. External NIP-32 kind-1985 label events. + // 2. External NIP-32 kind-1985 label events (`#t` namespace). // // A valid label event must: // - be kind 1985 @@ -1025,7 +1026,7 @@ pub fn get_labels( // - 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 { + for label_event in label_events { if !label_event.kind.eq(&KIND_LABEL) { continue; } @@ -1063,6 +1064,112 @@ pub fn get_labels( labels } +/// Process the effective subject/title override for `event` from a pre-fetched +/// set of kind-1985 events. +/// +/// Subject overrides use the `#subject` namespace: +/// `["L", "#subject"]` + `["l", "", "#subject"]` +/// +/// Unlike hashtag labels, subject overrides are replaceable-style: only the +/// latest authorised event wins, with tiebreak by lexicographically larger +/// event ID (consistent with NIP-1 replaceable event semantics). +/// +/// Only the author of `event` or a repository maintainer may set the subject. +/// Returns `None` when no valid subject override exists. +pub fn process_subject( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> Option { + let is_permitted = |pubkey: &PublicKey| -> bool { + pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) + }; + + let event_id_str = event.id.to_string(); + + // Find the winning subject label event: latest created_at, tiebreak by + // lexicographically larger event ID (NIP-1 replaceable event semantics). + let winner = label_events + .iter() + .filter(|le| { + if !le.kind.eq(&KIND_LABEL) { + return false; + } + if !is_permitted(&le.pubkey) { + return false; + } + // Must reference our event via a lowercase `e` tag. + let references_event = le.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 { + return false; + } + // Must declare the `#subject` namespace. + let has_namespace = le.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("L") && s[1].eq("#subject") + }); + if !has_namespace { + return false; + } + // Must have at least one non-empty `["l", "", "#subject"]` tag. + le.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() + }) + }) + .max_by(|a, b| { + // Primary: newer created_at wins. + // Tiebreak: lexicographically larger event ID wins (NIP-1). + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.to_string().cmp(&b.id.to_string())) + })?; + + // Extract the subject value from the winning event. + winner.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() { + Some(s[1].clone()) + } else { + None + } + }) +} + +/// Compute both the effective hashtag labels and the subject/title override for +/// `event` from a pre-fetched set of kind-1985 events. +/// +/// This is the primary entry point: callers should fetch label events once +/// (covering both `#t` and `#subject` namespaces) and pass them here to get +/// both results in a single pass. +/// +/// Returns `(labels, subject_override)` where `subject_override` is `None` +/// when no authorised `#subject` label exists. +pub fn get_labels_and_subject( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> (Vec, Option) { + ( + process_labels(event, repo_ref, label_events), + process_subject(event, repo_ref, label_events), + ) +} + +/// Compatibility wrapper — returns only the hashtag labels. +/// +/// Prefer [`get_labels_and_subject`] when the subject override is also needed. +pub fn get_labels( + event: &Event, + repo_ref: &RepoRef, + label_events: &[Event], +) -> Vec { + process_labels(event, repo_ref, label_events) +} + pub fn get_status( proposal: &Event, repo_ref: &RepoRef, -- cgit v1.2.3