From 37244449d6d0d58bb639f181bd15092de1acaaee Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 14:19:49 +0000 Subject: feat(cover-note): add kind-1624 cover notes for PRs, patches, and issues Implements experimental kind-1624 cover note events: - KIND_COVER_NOTE constant and process_cover_note() in git_events.rs; replaceable semantics (latest created_at, hex-id tiebreak), author or maintainer only - kind-1624 events fetched alongside labels in the fetch pipeline; cover_notes count added to FetchReport display - ngit pr/issue view: cover note displayed in place of description with a clear 'Cover Note:' header; maintainer-authored notes identify the author; original description shown only with --comments; cover_note object included in --json output - ngit pr set-cover-note / ngit issue set-cover-note: publish a kind-1624 event; nostr: mentions in --body converted to q/p tags via tags_from_content (same rules as issue --body) - Fix pre-existing clippy::too_many_lines on repo/mod.rs show_info --- src/lib/client.rs | 34 +++++++++++++++++++++++++- src/lib/git_events.rs | 67 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 89 insertions(+), 12 deletions(-) (limited to 'src/lib') diff --git a/src/lib/client.rs b/src/lib/client.rs index 94a173f..d5597fa 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -56,7 +56,7 @@ use crate::{ get_dirs, git::{Repo, RepoActions, get_git_config_item}, git_events::{ - KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + KIND_COMMENT, KIND_COVER_NOTE, 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, }, @@ -2000,6 +2000,8 @@ async fn process_fetched_events( report.comments.insert(event.id); } else if event.kind.eq(&KIND_LABEL) { report.labels.insert(event.id); + } else if event.kind.eq(&KIND_COVER_NOTE) { + report.cover_notes.insert(event.id); } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) { if request.missing_contributor_profiles.contains(&event.pubkey) { @@ -2126,6 +2128,9 @@ pub fn consolidate_fetch_reports(reports: Vec>) -> FetchRepo for c in relay_report.labels { report.labels.insert(c); } + for c in relay_report.cover_notes { + report.cover_notes.insert(c); + } report.deletions += relay_report.deletions; for c in relay_report.contributor_profiles { report.contributor_profiles.insert(c); @@ -2268,6 +2273,24 @@ pub fn get_fetch_filters( ] } }, + // Fetch kind-1624 cover note events for issues and proposals. + // Cover notes 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_COVER_NOTE), + ] + } + }, // 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 @@ -2358,6 +2381,8 @@ pub struct FetchReport { comments: HashSet, /// NIP-32 kind-1985 label events for issues and proposals. labels: HashSet, + /// Kind-1624 cover note events for issues, patches, and PRs. + cover_notes: HashSet, /// Count of kind-5 deletion events received (for display purposes). deletions: u32, contributor_profiles: HashSet, @@ -2453,6 +2478,13 @@ impl Display for FetchReport { if self.labels.len() > 1 { "s" } else { "" }, )); } + if !self.cover_notes.is_empty() { + display_items.push(format!( + "{} cover note{}", + self.cover_notes.len(), + if self.cover_notes.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 b512e44..a5793a5 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -94,6 +94,10 @@ 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); +/// Cover note event (kind 1624) — a markdown note attached to a PR, patch or +/// issue by its author or a repository maintainer. Only the latest authorised +/// event is displayed (replaceable semantics with hex-id tiebreak). +pub const KIND_COVER_NOTE: Kind = Kind::Custom(1624); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) @@ -985,19 +989,15 @@ pub fn is_event_proposal_root_for_branch( /// /// 1. `t` tags on the event itself (self-reported by the event author). /// 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. +/// via a lowercase `e` tag and carry `["L", "#t"]` + `["l", "", +/// "#t"]` tags. /// /// 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, - label_events: &[Event], -) -> Vec { +pub fn process_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec { let is_permitted = |pubkey: &PublicKey| -> bool { pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) }; @@ -1162,12 +1162,57 @@ pub fn get_labels_and_subject( /// Compatibility wrapper — returns only the hashtag labels. /// /// Prefer [`get_labels_and_subject`] when the subject override is also needed. -pub fn get_labels( +pub fn get_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec { + process_labels(event, repo_ref, label_events) +} + +/// The effective cover note for `event`, selected from a pre-fetched set of +/// kind-1624 events. +/// +/// A cover note is a markdown body attached to a PR, patch or issue by its +/// author or a repository maintainer. Only the latest authorised event wins +/// (replaceable semantics: newest `created_at`, tiebreak by lexicographically +/// larger event ID). Events authored by other pubkeys are ignored. +/// +/// Returns `None` when no valid cover note exists. +pub fn process_cover_note( event: &Event, repo_ref: &RepoRef, - label_events: &[Event], -) -> Vec { - process_labels(event, repo_ref, label_events) + cover_note_events: &[Event], +) -> Option<(Event, bool)> { + 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 cover note: latest created_at, tiebreak by + // lexicographically larger event ID (NIP-1 replaceable event semantics). + let winner = cover_note_events + .iter() + .filter(|cn| { + if !cn.kind.eq(&KIND_COVER_NOTE) { + return false; + } + if !is_permitted(&cn.pubkey) { + return false; + } + // Must reference our event via a lowercase `e` tag. + cn.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) + }) + }) + .max_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.to_string().cmp(&b.id.to_string())) + })?; + + // True when the cover note author differs from the original event author + // (i.e. a maintainer wrote it, not the PR/issue author). + let by_different_author = winner.pubkey != event.pubkey; + Some((winner.clone(), by_different_author)) } pub fn get_status( -- cgit v1.2.3