diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 14:19:49 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 14:23:07 +0000 |
| commit | 37244449d6d0d58bb639f181bd15092de1acaaee (patch) | |
| tree | 7de03867a1a9578e32fdbdbb2be63e863cea57a4 /src/lib | |
| parent | 609f3c3db02d437222e2c8e171189179d06c3e9c (diff) | |
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
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/client.rs | 34 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 67 |
2 files changed, 89 insertions, 12 deletions
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::{ | |||
| 56 | get_dirs, | 56 | get_dirs, |
| 57 | git::{Repo, RepoActions, get_git_config_item}, | 57 | git::{Repo, RepoActions, get_git_config_item}, |
| 58 | git_events::{ | 58 | git_events::{ |
| 59 | KIND_COMMENT, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, | 59 | KIND_COMMENT, KIND_COVER_NOTE, KIND_LABEL, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, |
| 60 | KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, | 60 | KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, |
| 61 | event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, | 61 | event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, |
| 62 | }, | 62 | }, |
| @@ -2000,6 +2000,8 @@ async fn process_fetched_events( | |||
| 2000 | report.comments.insert(event.id); | 2000 | report.comments.insert(event.id); |
| 2001 | } else if event.kind.eq(&KIND_LABEL) { | 2001 | } else if event.kind.eq(&KIND_LABEL) { |
| 2002 | report.labels.insert(event.id); | 2002 | report.labels.insert(event.id); |
| 2003 | } else if event.kind.eq(&KIND_COVER_NOTE) { | ||
| 2004 | report.cover_notes.insert(event.id); | ||
| 2003 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) | 2005 | } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) |
| 2004 | { | 2006 | { |
| 2005 | if request.missing_contributor_profiles.contains(&event.pubkey) { | 2007 | if request.missing_contributor_profiles.contains(&event.pubkey) { |
| @@ -2126,6 +2128,9 @@ pub fn consolidate_fetch_reports(reports: Vec<Result<FetchReport>>) -> FetchRepo | |||
| 2126 | for c in relay_report.labels { | 2128 | for c in relay_report.labels { |
| 2127 | report.labels.insert(c); | 2129 | report.labels.insert(c); |
| 2128 | } | 2130 | } |
| 2131 | for c in relay_report.cover_notes { | ||
| 2132 | report.cover_notes.insert(c); | ||
| 2133 | } | ||
| 2129 | report.deletions += relay_report.deletions; | 2134 | report.deletions += relay_report.deletions; |
| 2130 | for c in relay_report.contributor_profiles { | 2135 | for c in relay_report.contributor_profiles { |
| 2131 | report.contributor_profiles.insert(c); | 2136 | report.contributor_profiles.insert(c); |
| @@ -2268,6 +2273,24 @@ pub fn get_fetch_filters( | |||
| 2268 | ] | 2273 | ] |
| 2269 | } | 2274 | } |
| 2270 | }, | 2275 | }, |
| 2276 | // Fetch kind-1624 cover note events for issues and proposals. | ||
| 2277 | // Cover notes reference the target via a lowercase `e` tag. | ||
| 2278 | { | ||
| 2279 | let all_root_ids: HashSet<EventId> = issue_ids | ||
| 2280 | .iter() | ||
| 2281 | .chain(proposal_ids.iter()) | ||
| 2282 | .copied() | ||
| 2283 | .collect(); | ||
| 2284 | if all_root_ids.is_empty() { | ||
| 2285 | vec![] | ||
| 2286 | } else { | ||
| 2287 | vec![ | ||
| 2288 | nostr::Filter::default() | ||
| 2289 | .events(all_root_ids) | ||
| 2290 | .kind(KIND_COVER_NOTE), | ||
| 2291 | ] | ||
| 2292 | } | ||
| 2293 | }, | ||
| 2271 | // Request kind-5 deletions for state events and repo announcements by | 2294 | // Request kind-5 deletions for state events and repo announcements by |
| 2272 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above | 2295 | // their event ID (#e tag), as per NIP-09. The #a-tagged filter above |
| 2273 | // covers addressable-event deletions; this covers the specific event IDs | 2296 | // covers addressable-event deletions; this covers the specific event IDs |
| @@ -2358,6 +2381,8 @@ pub struct FetchReport { | |||
| 2358 | comments: HashSet<EventId>, | 2381 | comments: HashSet<EventId>, |
| 2359 | /// NIP-32 kind-1985 label events for issues and proposals. | 2382 | /// NIP-32 kind-1985 label events for issues and proposals. |
| 2360 | labels: HashSet<EventId>, | 2383 | labels: HashSet<EventId>, |
| 2384 | /// Kind-1624 cover note events for issues, patches, and PRs. | ||
| 2385 | cover_notes: HashSet<EventId>, | ||
| 2361 | /// Count of kind-5 deletion events received (for display purposes). | 2386 | /// Count of kind-5 deletion events received (for display purposes). |
| 2362 | deletions: u32, | 2387 | deletions: u32, |
| 2363 | contributor_profiles: HashSet<PublicKey>, | 2388 | contributor_profiles: HashSet<PublicKey>, |
| @@ -2453,6 +2478,13 @@ impl Display for FetchReport { | |||
| 2453 | if self.labels.len() > 1 { "s" } else { "" }, | 2478 | if self.labels.len() > 1 { "s" } else { "" }, |
| 2454 | )); | 2479 | )); |
| 2455 | } | 2480 | } |
| 2481 | if !self.cover_notes.is_empty() { | ||
| 2482 | display_items.push(format!( | ||
| 2483 | "{} cover note{}", | ||
| 2484 | self.cover_notes.len(), | ||
| 2485 | if self.cover_notes.len() > 1 { "s" } else { "" }, | ||
| 2486 | )); | ||
| 2487 | } | ||
| 2456 | if self.deletions > 0 { | 2488 | if self.deletions > 0 { |
| 2457 | display_items.push(format!( | 2489 | display_items.push(format!( |
| 2458 | "{} deletion{}", | 2490 | "{} 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); | |||
| 94 | /// NIP-32 label event (kind 1985) — applies hashtag labels to an existing | 94 | /// NIP-32 label event (kind 1985) — applies hashtag labels to an existing |
| 95 | /// event. Used to add labels to issues, patches and PRs after the fact. | 95 | /// event. Used to add labels to issues, patches and PRs after the fact. |
| 96 | pub const KIND_LABEL: Kind = Kind::Custom(1985); | 96 | pub const KIND_LABEL: Kind = Kind::Custom(1985); |
| 97 | /// Cover note event (kind 1624) — a markdown note attached to a PR, patch or | ||
| 98 | /// issue by its author or a repository maintainer. Only the latest authorised | ||
| 99 | /// event is displayed (replaceable semantics with hex-id tiebreak). | ||
| 100 | pub const KIND_COVER_NOTE: Kind = Kind::Custom(1624); | ||
| 97 | 101 | ||
| 98 | pub fn event_is_patch_set_root(event: &Event) -> bool { | 102 | pub fn event_is_patch_set_root(event: &Event) -> bool { |
| 99 | event.kind.eq(&Kind::GitPatch) | 103 | event.kind.eq(&Kind::GitPatch) |
| @@ -985,19 +989,15 @@ pub fn is_event_proposal_root_for_branch( | |||
| 985 | /// | 989 | /// |
| 986 | /// 1. `t` tags on the event itself (self-reported by the event author). | 990 | /// 1. `t` tags on the event itself (self-reported by the event author). |
| 987 | /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` | 991 | /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` |
| 988 | /// via a lowercase `e` tag and carry `["L", "#t"]` + | 992 | /// via a lowercase `e` tag and carry `["L", "#t"]` + `["l", "<value>", |
| 989 | /// `["l", "<value>", "#t"]` tags. | 993 | /// "#t"]` tags. |
| 990 | /// | 994 | /// |
| 991 | /// A label is only applied when the author of the source event is either the | 995 | /// A label is only applied when the author of the source event is either the |
| 992 | /// author of `event` itself or one of the repository maintainers. | 996 | /// author of `event` itself or one of the repository maintainers. |
| 993 | /// | 997 | /// |
| 994 | /// Labels are additive — all valid label events contribute; there is no | 998 | /// Labels are additive — all valid label events contribute; there is no |
| 995 | /// "latest wins" replacement semantics. | 999 | /// "latest wins" replacement semantics. |
| 996 | pub fn process_labels( | 1000 | pub fn process_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> { |
| 997 | event: &Event, | ||
| 998 | repo_ref: &RepoRef, | ||
| 999 | label_events: &[Event], | ||
| 1000 | ) -> Vec<String> { | ||
| 1001 | let is_permitted = |pubkey: &PublicKey| -> bool { | 1001 | let is_permitted = |pubkey: &PublicKey| -> bool { |
| 1002 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | 1002 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) |
| 1003 | }; | 1003 | }; |
| @@ -1162,12 +1162,57 @@ pub fn get_labels_and_subject( | |||
| 1162 | /// Compatibility wrapper — returns only the hashtag labels. | 1162 | /// Compatibility wrapper — returns only the hashtag labels. |
| 1163 | /// | 1163 | /// |
| 1164 | /// Prefer [`get_labels_and_subject`] when the subject override is also needed. | 1164 | /// Prefer [`get_labels_and_subject`] when the subject override is also needed. |
| 1165 | pub fn get_labels( | 1165 | pub fn get_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> { |
| 1166 | process_labels(event, repo_ref, label_events) | ||
| 1167 | } | ||
| 1168 | |||
| 1169 | /// The effective cover note for `event`, selected from a pre-fetched set of | ||
| 1170 | /// kind-1624 events. | ||
| 1171 | /// | ||
| 1172 | /// A cover note is a markdown body attached to a PR, patch or issue by its | ||
| 1173 | /// author or a repository maintainer. Only the latest authorised event wins | ||
| 1174 | /// (replaceable semantics: newest `created_at`, tiebreak by lexicographically | ||
| 1175 | /// larger event ID). Events authored by other pubkeys are ignored. | ||
| 1176 | /// | ||
| 1177 | /// Returns `None` when no valid cover note exists. | ||
| 1178 | pub fn process_cover_note( | ||
| 1166 | event: &Event, | 1179 | event: &Event, |
| 1167 | repo_ref: &RepoRef, | 1180 | repo_ref: &RepoRef, |
| 1168 | label_events: &[Event], | 1181 | cover_note_events: &[Event], |
| 1169 | ) -> Vec<String> { | 1182 | ) -> Option<(Event, bool)> { |
| 1170 | process_labels(event, repo_ref, label_events) | 1183 | let is_permitted = |pubkey: &PublicKey| -> bool { |
| 1184 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | ||
| 1185 | }; | ||
| 1186 | |||
| 1187 | let event_id_str = event.id.to_string(); | ||
| 1188 | |||
| 1189 | // Find the winning cover note: latest created_at, tiebreak by | ||
| 1190 | // lexicographically larger event ID (NIP-1 replaceable event semantics). | ||
| 1191 | let winner = cover_note_events | ||
| 1192 | .iter() | ||
| 1193 | .filter(|cn| { | ||
| 1194 | if !cn.kind.eq(&KIND_COVER_NOTE) { | ||
| 1195 | return false; | ||
| 1196 | } | ||
| 1197 | if !is_permitted(&cn.pubkey) { | ||
| 1198 | return false; | ||
| 1199 | } | ||
| 1200 | // Must reference our event via a lowercase `e` tag. | ||
| 1201 | cn.tags.iter().any(|t| { | ||
| 1202 | let s = t.as_slice(); | ||
| 1203 | s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) | ||
| 1204 | }) | ||
| 1205 | }) | ||
| 1206 | .max_by(|a, b| { | ||
| 1207 | a.created_at | ||
| 1208 | .cmp(&b.created_at) | ||
| 1209 | .then_with(|| a.id.to_string().cmp(&b.id.to_string())) | ||
| 1210 | })?; | ||
| 1211 | |||
| 1212 | // True when the cover note author differs from the original event author | ||
| 1213 | // (i.e. a maintainer wrote it, not the PR/issue author). | ||
| 1214 | let by_different_author = winner.pubkey != event.pubkey; | ||
| 1215 | Some((winner.clone(), by_different_author)) | ||
| 1171 | } | 1216 | } |
| 1172 | 1217 | ||
| 1173 | pub fn get_status( | 1218 | pub fn get_status( |