upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/set_cover_note.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 14:19:49 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-05 14:23:07 +0000
commit37244449d6d0d58bb639f181bd15092de1acaaee (patch)
tree7de03867a1a9578e32fdbdbb2be63e863cea57a4 /src/bin/ngit/sub_commands/set_cover_note.rs
parent609f3c3db02d437222e2c8e171189179d06c3e9c (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/bin/ngit/sub_commands/set_cover_note.rs')
-rw-r--r--src/bin/ngit/sub_commands/set_cover_note.rs202
1 files changed, 202 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/set_cover_note.rs b/src/bin/ngit/sub_commands/set_cover_note.rs
new file mode 100644
index 0000000..49d4f34
--- /dev/null
+++ b/src/bin/ngit/sub_commands/set_cover_note.rs
@@ -0,0 +1,202 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events},
4 content_tags::{dedup_tags, tags_from_content},
5 git_events::{KIND_COVER_NOTE, process_cover_note},
6};
7use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
8use nostr_sdk::{EventId, FromBech32};
9
10use crate::{
11 client::{
12 Client, Connect, fetching_with_report, get_events_from_local_cache,
13 get_repo_ref_from_cache, save_event_in_local_cache,
14 },
15 git::{Repo, RepoActions},
16 login,
17 repo_ref::get_repo_coordinates_when_remote_unknown,
18};
19
20fn parse_event_id(id: &str) -> Result<EventId> {
21 if let Ok(nip19) = Nip19::from_bech32(id) {
22 match nip19 {
23 Nip19::Event(e) => return Ok(e.event_id),
24 Nip19::EventId(event_id) => return Ok(event_id),
25 _ => {}
26 }
27 }
28 if let Ok(event_id) = EventId::from_hex(id) {
29 return Ok(event_id);
30 }
31 bail!("invalid event-id or nevent: {id}")
32}
33
34/// Shared implementation: publish a kind-1624 cover note event for `target`.
35///
36/// A cover note is a markdown body that replaces the displayed description of a
37/// PR, patch or issue. Only the author of the target event or a repository
38/// maintainer may set it. The latest authorised event wins (replaceable
39/// semantics with hex-id tiebreak).
40///
41/// The `body` is processed for `nostr:` mentions (NIP-21), which are converted
42/// to `q` (event) and `p` (pubkey) tags — the same rules as `--body` in issue
43/// creation.
44#[allow(clippy::too_many_lines)]
45async fn publish_set_cover_note_event(
46 id: &str,
47 body: &str,
48 offline: bool,
49 target_kind: &str, // "issue" or "PR" — used in error messages
50) -> Result<()> {
51 let body = body.trim();
52 if body.is_empty() {
53 bail!("--body value must not be empty");
54 }
55
56 let event_id = parse_event_id(id)?;
57
58 let git_repo = Repo::discover().context("failed to find a git repository")?;
59 let git_repo_path = git_repo.get_path()?;
60
61 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
62 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
63
64 if !offline {
65 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
66 }
67
68 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
69
70 // Resolve the target event from cache.
71 let target = if target_kind == "issue" {
72 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
73 issues
74 .into_iter()
75 .find(|e| e.id == event_id)
76 .context(format!(
77 "issue with id {} not found in cache",
78 event_id.to_hex()
79 ))?
80 } else {
81 let proposals =
82 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
83 proposals
84 .into_iter()
85 .find(|e| e.id == event_id)
86 .context(format!(
87 "PR with id {} not found in cache",
88 event_id.to_hex()
89 ))?
90 };
91
92 // Login — we need the signer and user pubkey.
93 let (signer, user_ref, _) =
94 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
95
96 let user_pubkey = signer.get_public_key().await?;
97
98 // Permission check: only the author or a maintainer may set a cover note.
99 if target.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
100 bail!(
101 "only the {target_kind} author or a repository maintainer can set the cover note of a {target_kind}"
102 );
103 }
104
105 // Fetch existing cover note events so we can check whether the body is
106 // already set to the requested value.
107 let existing_cover_note_events = get_events_from_local_cache(
108 git_repo_path,
109 vec![
110 nostr::Filter::default()
111 .event(event_id)
112 .kind(KIND_COVER_NOTE),
113 ],
114 )
115 .await?;
116
117 if let Some((existing_cn, _)) =
118 process_cover_note(&target, &repo_ref, &existing_cover_note_events)
119 {
120 if existing_cn.content.trim() == body {
121 println!(
122 "{target_kind} {} already has this cover note",
123 &event_id.to_hex()[..8],
124 );
125 return Ok(());
126 }
127 }
128
129 // Build the kind-1624 cover note event.
130 //
131 // Shape:
132 // content: "<markdown>"
133 // tags:
134 // ["e", "<pr-issue-or-patch-id>", "<relay-hint>"] — reference to target
135 // ["p", "<author-pubkey>"] — notify the author
136 // ["q", "<referenced-event>", ...] — from body mentions
137 // ["p", "<referenced-pubkey>", ...] — from body mentions
138 // ["alt", "cover note for <target_kind>"]
139 let relay_hint = repo_ref.relays.first().cloned();
140
141 let mut tags: Vec<Tag> = vec![];
142
143 // Reference the target event (lowercase `e`).
144 tags.push(Tag::from_standardized(TagStandard::Event {
145 event_id: target.id,
146 relay_url: relay_hint.clone(),
147 marker: None,
148 public_key: None,
149 uppercase: false,
150 }));
151
152 // Notify the target event author.
153 tags.push(Tag::public_key(target.pubkey));
154
155 // Human-readable alt text.
156 tags.push(Tag::custom(
157 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
158 vec![format!("cover note for {target_kind}")],
159 ));
160
161 // Process body for nostr: mentions → q and p tags (same as --body in issue
162 // creation).
163 let mention_tags = tags_from_content(body, Some(git_repo_path)).await?;
164 tags.extend(mention_tags);
165 let tags = dedup_tags(tags);
166
167 let cover_note_event = ngit::client::sign_event(
168 EventBuilder::new(KIND_COVER_NOTE, body).tags(tags),
169 &signer,
170 format!("set {target_kind} cover note"),
171 )
172 .await?;
173
174 // Save to local cache immediately so subsequent reads reflect the new cover
175 // note.
176 save_event_in_local_cache(git_repo_path, &cover_note_event).await?;
177
178 let mut client = client;
179 client.set_signer(signer).await;
180
181 send_events(
182 &client,
183 Some(git_repo_path),
184 vec![cover_note_event],
185 user_ref.relays.write(),
186 repo_ref.relays.clone(),
187 true,
188 false,
189 )
190 .await?;
191
192 println!("{} {} cover note set", target_kind, &event_id.to_hex()[..8],);
193 Ok(())
194}
195
196pub async fn launch_issue_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> {
197 publish_set_cover_note_event(id, body, offline, "issue").await
198}
199
200pub async fn launch_pr_set_cover_note(id: &str, body: &str, offline: bool) -> Result<()> {
201 publish_set_cover_note_event(id, body, offline, "PR").await
202}