upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 16:24:29 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 16:24:29 +0000
commitaf016dd23101537ccc8ecd5a992bf3b7c6d3abe9 (patch)
tree15284c3a1301ea738507beadd2687d4dd0d248b5 /src
parent293ef01e141846f7de5af2c8c6be9d6c694083fd (diff)
add NIP-21 content tags (q/p) to issues, comments, PRs and patches
- parse nostr: URI mentions in issue bodies, comment bodies, PR descriptions, patch commit messages and cover letters - npub/nprofile mentions produce p tags; note/nevent/naddr mentions produce q tags per NIP-22 - naddr q tag value uses raw <kind>:<pubkey-hex>:<identifier> format - nevent pubkey field populated from embedded author or local cache lookup - dedup_tags() removes duplicate p tags and suppresses q tags whose event-id is already covered by an existing e threading tag - all parsing errors are non-fatal: invalid nostr: tokens are skipped
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/sub_commands/comment.rs66
-rw-r--r--src/bin/ngit/sub_commands/issue_create.rs10
-rw-r--r--src/lib/content_tags.rs389
-rw-r--r--src/lib/git_events.rs519
-rw-r--r--src/lib/mod.rs1
-rw-r--r--src/lib/push.rs4
6 files changed, 719 insertions, 270 deletions
diff --git a/src/bin/ngit/sub_commands/comment.rs b/src/bin/ngit/sub_commands/comment.rs
index c47a1f0..60626e5 100644
--- a/src/bin/ngit/sub_commands/comment.rs
+++ b/src/bin/ngit/sub_commands/comment.rs
@@ -4,6 +4,7 @@ use ngit::{
4 Params, get_events_from_local_cache, get_issues_from_cache, 4 Params, get_events_from_local_cache, get_issues_from_cache,
5 get_proposals_and_revisions_from_cache, send_events, sign_event, 5 get_proposals_and_revisions_from_cache, send_events, sign_event,
6 }, 6 },
7 content_tags::{dedup_tags, tags_from_content},
7 git_events::KIND_COMMENT, 8 git_events::KIND_COMMENT,
8}; 9};
9use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; 10use nostr::{EventBuilder, Tag, nips::nip19::Nip19};
@@ -102,36 +103,43 @@ async fn publish_comment(args: CommentArgs<'_>) -> Result<()> {
102 let root_kind_str = root_kind.as_u16().to_string(); 103 let root_kind_str = root_kind.as_u16().to_string();
103 let parent_kind_str = parent_kind.as_u16().to_string(); 104 let parent_kind_str = parent_kind.as_u16().to_string();
104 105
105 // NIP-22 compliant tags 106 // NIP-22 compliant threading tags
107 let mut comment_tags: Vec<Tag> = vec![
108 // Root scope: uppercase E with root pubkey as 4th element
109 Tag::parse(vec![
110 "E".to_string(),
111 root_event_id.to_hex(),
112 relay_hint.clone(),
113 root_pubkey.to_hex(),
114 ])?,
115 // Root kind
116 Tag::parse(vec!["K".to_string(), root_kind_str])?,
117 // Root author pubkey
118 Tag::parse(vec![
119 "P".to_string(),
120 root_pubkey.to_hex(),
121 relay_hint.clone(),
122 ])?,
123 // Parent item: lowercase e with parent pubkey as 4th element
124 Tag::parse(vec![
125 "e".to_string(),
126 parent_event_id.to_hex(),
127 relay_hint.clone(),
128 parent_pubkey.to_hex(),
129 ])?,
130 // Parent kind
131 Tag::parse(vec!["k".to_string(), parent_kind_str])?,
132 // Parent author pubkey
133 Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?,
134 ];
135
136 // NIP-21 mention tags: q tags for cited events/addresses, p tags for cited
137 // pubkeys
138 comment_tags.extend(tags_from_content(body, Some(git_repo_path)).await?);
139 let comment_tags = dedup_tags(comment_tags);
140
106 let comment_event = sign_event( 141 let comment_event = sign_event(
107 EventBuilder::new(KIND_COMMENT, body).tags(vec![ 142 EventBuilder::new(KIND_COMMENT, body).tags(comment_tags),
108 // Root scope: uppercase E with root pubkey as 4th element
109 Tag::parse(vec![
110 "E".to_string(),
111 root_event_id.to_hex(),
112 relay_hint.clone(),
113 root_pubkey.to_hex(),
114 ])?,
115 // Root kind
116 Tag::parse(vec!["K".to_string(), root_kind_str])?,
117 // Root author pubkey
118 Tag::parse(vec![
119 "P".to_string(),
120 root_pubkey.to_hex(),
121 relay_hint.clone(),
122 ])?,
123 // Parent item: lowercase e with parent pubkey as 4th element
124 Tag::parse(vec![
125 "e".to_string(),
126 parent_event_id.to_hex(),
127 relay_hint.clone(),
128 parent_pubkey.to_hex(),
129 ])?,
130 // Parent kind
131 Tag::parse(vec!["k".to_string(), parent_kind_str])?,
132 // Parent author pubkey
133 Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?,
134 ]),
135 &signer, 143 &signer,
136 format!("comment on {entity_name}"), 144 format!("comment on {entity_name}"),
137 ) 145 )
diff --git a/src/bin/ngit/sub_commands/issue_create.rs b/src/bin/ngit/sub_commands/issue_create.rs
index 0c4b677..4543823 100644
--- a/src/bin/ngit/sub_commands/issue_create.rs
+++ b/src/bin/ngit/sub_commands/issue_create.rs
@@ -1,5 +1,8 @@
1use anyhow::{Context, Result, bail}; 1use anyhow::{Context, Result, bail};
2use ngit::client::{Params, send_events, sign_event}; 2use ngit::{
3 client::{Params, send_events, sign_event},
4 content_tags::{dedup_tags, tags_from_content},
5};
3use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event}; 6use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event};
4use nostr_sdk::Kind; 7use nostr_sdk::Kind;
5 8
@@ -74,6 +77,11 @@ pub async fn launch(
74 tags.push(Tag::public_key(*pk)); 77 tags.push(Tag::public_key(*pk));
75 } 78 }
76 79
80 // NIP-21 mention tags: q tags for cited events/addresses, p tags for cited
81 // pubkeys
82 tags.extend(tags_from_content(&body, Some(git_repo_path)).await?);
83 let tags = dedup_tags(tags);
84
77 let issue_event = sign_event( 85 let issue_event = sign_event(
78 EventBuilder::new(Kind::GitIssue, body).tags(tags), 86 EventBuilder::new(Kind::GitIssue, body).tags(tags),
79 &signer, 87 &signer,
diff --git a/src/lib/content_tags.rs b/src/lib/content_tags.rs
new file mode 100644
index 0000000..f1b377a
--- /dev/null
+++ b/src/lib/content_tags.rs
@@ -0,0 +1,389 @@
1//! Parse `nostr:` URI mentions (NIP-21) from event content and produce the
2//! corresponding NIP-22 `q` / `p` tags.
3//!
4//! Rules implemented:
5//! - `nostr:npub1…` / `nostr:nprofile1…` → `["p", "<pubkey-hex>", "<relay>"]`
6//! - `nostr:note1…` / `nostr:nevent1…` → `["q", "<event-id-hex>", "<relay>",
7//! "<pubkey>"]`
8//! - `nostr:naddr1…` → `["q",
9//! "<kind>:<pubkey-hex>:<identifier>", "<relay>"]`
10//!
11//! Duplicate tags (same first two elements) are deduplicated within the content
12//! scan. Use [`dedup_tags`] after merging content tags with the rest of the
13//! event's tag list to remove cross-source duplicates.
14
15use std::{collections::HashSet, path::Path};
16
17use anyhow::Result;
18use nostr::{FromBech32, Tag, nips::nip19::Nip19};
19use nostr_sdk::EventId;
20
21use crate::client::get_events_from_local_cache;
22
23/// Regex-free extraction of every `nostr:<bech32>` token from `content`.
24fn extract_nostr_uris(content: &str) -> Vec<&str> {
25 let mut uris = Vec::new();
26 let mut remaining = content;
27 while let Some(start) = remaining.find("nostr:") {
28 let after = &remaining[start + 6..]; // skip "nostr:"
29 // A bech32 token consists of alphanumeric chars (plus the separator '1').
30 // We stop at the first non-bech32 character.
31 let end = after
32 .find(|c: char| !c.is_ascii_alphanumeric())
33 .unwrap_or(after.len());
34 if end > 0 {
35 uris.push(&remaining[start..start + 6 + end]);
36 }
37 remaining = &remaining[start + 6 + end..];
38 }
39 uris
40}
41
42/// Build `q` / `p` tags for every `nostr:` mention found in `content`.
43///
44/// `git_repo_path` is used for the optional local-cache lookup that fills in
45/// the author pubkey of a cited regular event when it is not embedded in the
46/// `nevent` bech32.
47pub async fn tags_from_content(content: &str, git_repo_path: Option<&Path>) -> Result<Vec<Tag>> {
48 let uris = extract_nostr_uris(content);
49 if uris.is_empty() {
50 return Ok(vec![]);
51 }
52
53 // Collect (tag_name, value0, value1_opt) tuples for deduplication.
54 // We use the first two tag elements as the dedup key.
55 let mut seen: HashSet<(String, String)> = HashSet::new();
56 let mut tags: Vec<Tag> = Vec::new();
57
58 for uri in uris {
59 // Strip the "nostr:" prefix to get the raw bech32 string.
60 let bech32 = &uri[6..];
61
62 let Ok(nip19) = Nip19::from_bech32(bech32) else {
63 continue;
64 };
65
66 match nip19 {
67 // ── pubkey references → p tag ─────────────────────────────────
68 Nip19::Pubkey(pk) => {
69 let key = ("p".to_string(), pk.to_hex());
70 if seen.insert(key) {
71 let Ok(tag) = Tag::parse(vec!["p".to_string(), pk.to_hex()]) else {
72 continue;
73 };
74 tags.push(tag);
75 }
76 }
77 Nip19::Profile(profile) => {
78 let key = ("p".to_string(), profile.public_key.to_hex());
79 if seen.insert(key) {
80 let mut parts = vec!["p".to_string(), profile.public_key.to_hex()];
81 if let Some(relay) = profile.relays.first() {
82 parts.push(relay.to_string());
83 }
84 let Ok(tag) = Tag::parse(parts) else { continue };
85 tags.push(tag);
86 }
87 }
88
89 // ── regular event references → q tag ─────────────────────────
90 Nip19::EventId(event_id) => {
91 let key = ("q".to_string(), event_id.to_hex());
92 if seen.insert(key) {
93 // No relay or pubkey info available; attempt cache lookup.
94 let pubkey = lookup_event_pubkey(&event_id, git_repo_path).await;
95 let Ok(tag) = build_q_tag_for_event(event_id, None, pubkey) else {
96 continue;
97 };
98 tags.push(tag);
99 }
100 }
101 Nip19::Event(nevent) => {
102 let key = ("q".to_string(), nevent.event_id.to_hex());
103 if seen.insert(key) {
104 let relay = nevent.relays.first().cloned();
105 // Prefer author embedded in nevent; fall back to cache lookup.
106 let pubkey = if nevent.author.is_some() {
107 nevent.author
108 } else {
109 lookup_event_pubkey(&nevent.event_id, git_repo_path).await
110 };
111 let Ok(tag) = build_q_tag_for_event(nevent.event_id, relay, pubkey) else {
112 continue;
113 };
114 tags.push(tag);
115 }
116 }
117
118 // ── addressable event references → q tag with coordinate ──────
119 Nip19::Coordinate(naddr) => {
120 let coord = &naddr.coordinate;
121 // Format: <kind>:<pubkey-hex>:<identifier>
122 let coord_str = format!(
123 "{}:{}:{}",
124 coord.kind.as_u16(),
125 coord.public_key.to_hex(),
126 coord.identifier
127 );
128 let key = ("q".to_string(), coord_str.clone());
129 if seen.insert(key) {
130 let mut parts = vec!["q".to_string(), coord_str];
131 if let Some(relay) = naddr.relays.first() {
132 parts.push(relay.to_string());
133 }
134 let Ok(tag) = Tag::parse(parts) else { continue };
135 tags.push(tag);
136 }
137 }
138
139 // nsec / ncryptsec — ignore
140 _ => {}
141 }
142 }
143
144 Ok(tags)
145}
146
147/// Deduplicate a merged tag list, removing:
148///
149/// 1. Duplicate `p` tags — keep the first occurrence of each pubkey hex.
150/// 2. Duplicate `q` tags — keep the first occurrence of each value.
151/// 3. `q` tags whose event-id (position `[1]`) is already referenced by an
152/// existing `e` tag — avoids redundant citations when the event is already
153/// part of the threading structure.
154///
155/// All other tags are passed through unchanged and in order.
156pub fn dedup_tags(tags: Vec<Tag>) -> Vec<Tag> {
157 // First pass: collect the set of event IDs already covered by `e` tags.
158 let e_ids: HashSet<String> = tags
159 .iter()
160 .filter(|t| t.as_slice().first().is_some_and(|k| k == "e"))
161 .filter_map(|t| t.as_slice().get(1).cloned())
162 .collect();
163
164 let mut seen_p: HashSet<String> = HashSet::new();
165 let mut seen_q: HashSet<String> = HashSet::new();
166 let mut out: Vec<Tag> = Vec::with_capacity(tags.len());
167
168 for tag in tags {
169 let slice = tag.as_slice();
170 match slice.first().map(String::as_str) {
171 Some("p") => {
172 if let Some(pk) = slice.get(1) {
173 if seen_p.insert(pk.clone()) {
174 out.push(tag);
175 }
176 // else: duplicate p tag — drop it
177 } else {
178 out.push(tag); // malformed, pass through
179 }
180 }
181 Some("q") => {
182 if let Some(val) = slice.get(1) {
183 // Suppress if already covered by an e tag (regular event refs only;
184 // coordinate strings contain ':' so they can never match a plain hex id).
185 if e_ids.contains(val) {
186 continue;
187 }
188 if seen_q.insert(val.clone()) {
189 out.push(tag);
190 }
191 // else: duplicate q tag — drop it
192 } else {
193 out.push(tag);
194 }
195 }
196 _ => out.push(tag),
197 }
198 }
199
200 out
201}
202
203/// Attempt to find the pubkey of `event_id` in the local cache.
204/// Returns `None` if the cache is unavailable or the event is not found.
205async fn lookup_event_pubkey(
206 event_id: &EventId,
207 git_repo_path: Option<&Path>,
208) -> Option<nostr_sdk::PublicKey> {
209 let path = git_repo_path?;
210 let filter = nostr::Filter::default().id(*event_id);
211 let events = get_events_from_local_cache(path, vec![filter]).await.ok()?;
212 events
213 .into_iter()
214 .find(|e| e.id == *event_id)
215 .map(|e| e.pubkey)
216}
217
218/// Build a `["q", "<id-hex>", "<relay>", "<pubkey-hex>"]` tag.
219/// Trailing optional elements are omitted when absent.
220fn build_q_tag_for_event(
221 event_id: EventId,
222 relay: Option<nostr_sdk::RelayUrl>,
223 pubkey: Option<nostr_sdk::PublicKey>,
224) -> Result<Tag> {
225 let mut parts = vec!["q".to_string(), event_id.to_hex()];
226 match (relay, pubkey) {
227 (Some(r), Some(pk)) => {
228 parts.push(r.to_string());
229 parts.push(pk.to_hex());
230 }
231 (Some(r), None) => {
232 parts.push(r.to_string());
233 }
234 (None, Some(pk)) => {
235 // relay is required before pubkey per the tag spec; use empty string
236 parts.push(String::new());
237 parts.push(pk.to_hex());
238 }
239 (None, None) => {}
240 }
241 Ok(Tag::parse(parts)?)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[tokio::test]
249 async fn test_no_mentions() {
250 let tags = tags_from_content("hello world, no mentions here", None)
251 .await
252 .unwrap();
253 assert!(tags.is_empty());
254 }
255
256 #[tokio::test]
257 async fn test_npub_mention() {
258 let content =
259 "hello nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 world";
260 let tags = tags_from_content(content, None).await.unwrap();
261 assert_eq!(tags.len(), 1);
262 let slice = tags[0].as_slice();
263 assert_eq!(slice[0], "p");
264 // pubkey hex should be 64 chars
265 assert_eq!(slice[1].len(), 64);
266 }
267
268 #[tokio::test]
269 async fn test_note_mention() {
270 // note1 encoding of all-zeros event id
271 let content = "see nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn2l0z3";
272 let tags = tags_from_content(content, None).await.unwrap();
273 assert_eq!(tags.len(), 1);
274 let slice = tags[0].as_slice();
275 assert_eq!(slice[0], "q");
276 assert_eq!(slice[1].len(), 64);
277 }
278
279 #[tokio::test]
280 async fn test_naddr_mention() {
281 // naddr for kind 30023 (long-form article)
282 let content = "nostr:naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld";
283 let tags = tags_from_content(content, None).await.unwrap();
284 assert_eq!(tags.len(), 1);
285 let slice = tags[0].as_slice();
286 assert_eq!(slice[0], "q");
287 // format: <kind>:<pubkey-hex>:<identifier>
288 let parts: Vec<&str> = slice[1].splitn(3, ':').collect();
289 assert_eq!(parts.len(), 3);
290 assert!(parts[0].parse::<u16>().is_ok(), "kind should be numeric");
291 assert_eq!(parts[1].len(), 64, "pubkey should be 64 hex chars");
292 }
293
294 #[tokio::test]
295 async fn test_deduplication() {
296 let npub = "nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
297 let content = format!("{npub} and again {npub}");
298 let tags = tags_from_content(&content, None).await.unwrap();
299 assert_eq!(tags.len(), 1);
300 }
301
302 #[tokio::test]
303 async fn test_mixed_mentions() {
304 // note1 encoding of all-zeros event id
305 let content = "nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 and nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn2l0z3";
306 let tags = tags_from_content(content, None).await.unwrap();
307 assert_eq!(tags.len(), 2);
308 let tag_names: Vec<&str> = tags.iter().map(|t| t.as_slice()[0].as_str()).collect();
309 assert!(tag_names.contains(&"p"));
310 assert!(tag_names.contains(&"q"));
311 }
312
313 // ── dedup_tags tests ──────────────────────────────────────────────────────
314
315 #[test]
316 fn dedup_removes_duplicate_p_tags() {
317 let pk = "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca";
318 let tags = vec![
319 Tag::parse(vec!["p".to_string(), pk.to_string()]).unwrap(),
320 Tag::parse(vec!["p".to_string(), pk.to_string()]).unwrap(),
321 ];
322 let result = dedup_tags(tags);
323 assert_eq!(result.len(), 1);
324 assert_eq!(result[0].as_slice()[0], "p");
325 }
326
327 #[test]
328 fn dedup_keeps_different_p_tags() {
329 let pk1 = "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca";
330 let pk2 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
331 let tags = vec![
332 Tag::parse(vec!["p".to_string(), pk1.to_string()]).unwrap(),
333 Tag::parse(vec!["p".to_string(), pk2.to_string()]).unwrap(),
334 ];
335 let result = dedup_tags(tags);
336 assert_eq!(result.len(), 2);
337 }
338
339 #[test]
340 fn dedup_removes_q_tag_when_e_tag_has_same_id() {
341 let id = "0000000000000000000000000000000000000000000000000000000000000000";
342 let tags = vec![
343 Tag::parse(vec!["e".to_string(), id.to_string()]).unwrap(),
344 Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(),
345 ];
346 let result = dedup_tags(tags);
347 // q tag should be suppressed; e tag kept
348 assert_eq!(result.len(), 1);
349 assert_eq!(result[0].as_slice()[0], "e");
350 }
351
352 #[test]
353 fn dedup_keeps_q_tag_for_coordinate_even_if_e_tag_present() {
354 // coordinate strings contain ':' so they can never match a plain hex event id
355 let coord =
356 "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:my-article";
357 let event_id = "0000000000000000000000000000000000000000000000000000000000000000";
358 let tags = vec![
359 Tag::parse(vec!["e".to_string(), event_id.to_string()]).unwrap(),
360 Tag::parse(vec!["q".to_string(), coord.to_string()]).unwrap(),
361 ];
362 let result = dedup_tags(tags);
363 assert_eq!(result.len(), 2);
364 }
365
366 #[test]
367 fn dedup_removes_duplicate_q_tags() {
368 let id = "0000000000000000000000000000000000000000000000000000000000000000";
369 let tags = vec![
370 Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(),
371 Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(),
372 ];
373 let result = dedup_tags(tags);
374 assert_eq!(result.len(), 1);
375 }
376
377 #[test]
378 fn dedup_passes_through_other_tags_unchanged() {
379 let tags = vec![
380 Tag::parse(vec!["subject".to_string(), "hello".to_string()]).unwrap(),
381 Tag::parse(vec!["t".to_string(), "rust".to_string()]).unwrap(),
382 Tag::parse(vec!["t".to_string(), "rust".to_string()]).unwrap(), /* hashtag dup — not
383 * deduped */
384 ];
385 let result = dedup_tags(tags);
386 // only p and q are deduped; other tags pass through as-is
387 assert_eq!(result.len(), 3);
388 }
389}
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index dde0e1a..7c5dda2 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -1,4 +1,4 @@
1use std::{collections::HashMap, str::FromStr, sync::Arc}; 1use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use nostr::{ 4use nostr::{
@@ -13,6 +13,7 @@ use nostr_sdk::{
13use crate::{ 13use crate::{
14 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, 14 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
15 client::sign_event, 15 client::sign_event,
16 content_tags::tags_from_content,
16 git::{Repo, RepoActions}, 17 git::{Repo, RepoActions},
17 repo_ref::RepoRef, 18 repo_ref::RepoRef,
18 utils::get_open_or_draft_proposals, 19 utils::get_open_or_draft_proposals,
@@ -169,6 +170,146 @@ pub async fn generate_patch_event(
169 .context("failed to get parent commit")?; 170 .context("failed to get parent commit")?;
170 let relay_hint = repo_ref.relays.first().cloned(); 171 let relay_hint = repo_ref.relays.first().cloned();
171 172
173 // NIP-21 mention tags from commit message (description tag value, with mbox
174 // fallback)
175 let commit_message = git_repo.get_commit_message(commit).unwrap_or_default();
176 let patch_content_tags = tags_from_content(&commit_message, git_repo.get_path().ok()).await?;
177
178 let patch_tags = crate::content_tags::dedup_tags(
179 [
180 repo_ref
181 .maintainers
182 .iter()
183 .map(|m| {
184 Tag::from_standardized(TagStandard::Coordinate {
185 coordinate: Coordinate {
186 kind: nostr::Kind::GitRepoAnnouncement,
187 public_key: *m,
188 identifier: repo_ref.identifier.to_string(),
189 },
190 relay_url: repo_ref.relays.first().cloned(),
191 uppercase: false,
192 })
193 })
194 .collect::<Vec<Tag>>(),
195 vec![
196 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
197 // commit id reference is a trade-off. its now
198 // unclear which one is the root commit id but it
199 // enables easier location of code comments againt
200 // code that makes it into the main branch, assuming
201 // the commit id is correct
202 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
203 Tag::custom(
204 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
205 vec![format!(
206 "git patch: {}",
207 git_repo
208 .get_commit_message_summary(commit)
209 .unwrap_or_default()
210 )],
211 ),
212 ],
213 if let Some(thread_event_id) = thread_event_id {
214 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
215 event_id: thread_event_id,
216 relay_url: relay_hint.clone(),
217 marker: Some(Marker::Root),
218 public_key: None,
219 uppercase: false,
220 })]
221 } else if let Some(event_ref) = root_proposal_id.clone() {
222 vec![
223 Tag::hashtag("root"),
224 Tag::hashtag("root-revision"),
225 // TODO check if id is for a root proposal (perhaps its for an issue?)
226 event_tag_from_nip19_or_hex(
227 &event_ref,
228 "proposal",
229 EventRefType::Reply,
230 false,
231 false,
232 )?,
233 ]
234 } else {
235 vec![Tag::hashtag("root")]
236 },
237 mentions.to_vec(),
238 if let Some(id) = parent_patch_event_id {
239 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
240 event_id: id,
241 relay_url: relay_hint.clone(),
242 marker: Some(Marker::Reply),
243 public_key: None,
244 uppercase: false,
245 })]
246 } else {
247 vec![]
248 },
249 // see comment on branch names in cover letter event creation
250 if let Some(branch_name) = branch_name {
251 if thread_event_id.is_none() {
252 vec![Tag::custom(
253 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
254 vec![branch_name.chars().take(60).collect::<String>()],
255 )]
256 } else {
257 vec![]
258 }
259 } else {
260 vec![]
261 },
262 // whilst it is in nip34 draft to tag the maintainers
263 // I'm not sure it is a good idea because if they are
264 // interested in all patches then their specialised
265 // client should subscribe to patches tagged with the
266 // repo reference. maintainers of large repos will not
267 // be interested in every patch.
268 repo_ref
269 .maintainers
270 .iter()
271 .map(|pk| Tag::public_key(*pk))
272 .collect(),
273 vec![
274 // a fallback is now in place to extract this from the patch
275 Tag::custom(
276 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
277 vec![commit.to_string()],
278 ),
279 // this is required as patches cannot be relied upon to include the 'base
280 // commit'
281 Tag::custom(
282 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
283 vec![commit_parent.to_string()],
284 ),
285 // this is required to ensure the commit id matches
286 Tag::custom(
287 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
288 vec![
289 git_repo
290 .extract_commit_pgp_signature(commit)
291 .unwrap_or_default(),
292 ],
293 ),
294 // removing description tag will not cause anything to break
295 Tag::from_standardized(nostr_sdk::TagStandard::Description(
296 git_repo.get_commit_message(commit)?.to_string(),
297 )),
298 Tag::custom(
299 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
300 git_repo.get_commit_author(commit)?,
301 ),
302 // this is required to ensure the commit id matches
303 Tag::custom(
304 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
305 git_repo.get_commit_comitter(commit)?,
306 ),
307 ],
308 patch_content_tags,
309 ]
310 .concat(),
311 );
312
172 sign_event( 313 sign_event(
173 EventBuilder::new( 314 EventBuilder::new(
174 nostr::event::Kind::GitPatch, 315 nostr::event::Kind::GitPatch,
@@ -176,139 +317,7 @@ pub async fn generate_patch_event(
176 .make_patch_from_commit(commit, &series_count) 317 .make_patch_from_commit(commit, &series_count)
177 .context(format!("failed to make patch for commit {commit}"))?, 318 .context(format!("failed to make patch for commit {commit}"))?,
178 ) 319 )
179 .tags( 320 .tags(patch_tags),
180 [
181 repo_ref
182 .maintainers
183 .iter()
184 .map(|m| {
185 Tag::from_standardized(TagStandard::Coordinate {
186 coordinate: Coordinate {
187 kind: nostr::Kind::GitRepoAnnouncement,
188 public_key: *m,
189 identifier: repo_ref.identifier.to_string(),
190 },
191 relay_url: repo_ref.relays.first().cloned(),
192 uppercase: false,
193 })
194 })
195 .collect::<Vec<Tag>>(),
196 vec![
197 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
198 // commit id reference is a trade-off. its now
199 // unclear which one is the root commit id but it
200 // enables easier location of code comments againt
201 // code that makes it into the main branch, assuming
202 // the commit id is correct
203 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
204 Tag::custom(
205 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
206 vec![format!(
207 "git patch: {}",
208 git_repo
209 .get_commit_message_summary(commit)
210 .unwrap_or_default()
211 )],
212 ),
213 ],
214 if let Some(thread_event_id) = thread_event_id {
215 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
216 event_id: thread_event_id,
217 relay_url: relay_hint.clone(),
218 marker: Some(Marker::Root),
219 public_key: None,
220 uppercase: false,
221 })]
222 } else if let Some(event_ref) = root_proposal_id.clone() {
223 vec![
224 Tag::hashtag("root"),
225 Tag::hashtag("root-revision"),
226 // TODO check if id is for a root proposal (perhaps its for an issue?)
227 event_tag_from_nip19_or_hex(
228 &event_ref,
229 "proposal",
230 EventRefType::Reply,
231 false,
232 false,
233 )?,
234 ]
235 } else {
236 vec![Tag::hashtag("root")]
237 },
238 mentions.to_vec(),
239 if let Some(id) = parent_patch_event_id {
240 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
241 event_id: id,
242 relay_url: relay_hint.clone(),
243 marker: Some(Marker::Reply),
244 public_key: None,
245 uppercase: false,
246 })]
247 } else {
248 vec![]
249 },
250 // see comment on branch names in cover letter event creation
251 if let Some(branch_name) = branch_name {
252 if thread_event_id.is_none() {
253 vec![Tag::custom(
254 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
255 vec![branch_name.chars().take(60).collect::<String>()],
256 )]
257 } else {
258 vec![]
259 }
260 } else {
261 vec![]
262 },
263 // whilst it is in nip34 draft to tag the maintainers
264 // I'm not sure it is a good idea because if they are
265 // interested in all patches then their specialised
266 // client should subscribe to patches tagged with the
267 // repo reference. maintainers of large repos will not
268 // be interested in every patch.
269 repo_ref
270 .maintainers
271 .iter()
272 .map(|pk| Tag::public_key(*pk))
273 .collect(),
274 vec![
275 // a fallback is now in place to extract this from the patch
276 Tag::custom(
277 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
278 vec![commit.to_string()],
279 ),
280 // this is required as patches cannot be relied upon to include the 'base
281 // commit'
282 Tag::custom(
283 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
284 vec![commit_parent.to_string()],
285 ),
286 // this is required to ensure the commit id matches
287 Tag::custom(
288 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
289 vec![
290 git_repo
291 .extract_commit_pgp_signature(commit)
292 .unwrap_or_default(),
293 ],
294 ),
295 // removing description tag will not cause anything to break
296 Tag::from_standardized(nostr_sdk::TagStandard::Description(
297 git_repo.get_commit_message(commit)?.to_string(),
298 )),
299 Tag::custom(
300 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
301 git_repo.get_commit_author(commit)?,
302 ),
303 // this is required to ensure the commit id matches
304 Tag::custom(
305 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
306 git_repo.get_commit_comitter(commit)?,
307 ),
308 ],
309 ]
310 .concat(),
311 ),
312 signer, 321 signer,
313 if let Some((n, total)) = series_count { 322 if let Some((n, total)) = series_count {
314 format!("commit {n}/{total}") 323 format!("commit {n}/{total}")
@@ -420,7 +429,7 @@ pub fn event_tag_from_nip19_or_hex(
420} 429}
421 430
422#[allow(clippy::too_many_arguments)] 431#[allow(clippy::too_many_arguments)]
423pub fn generate_unsigned_pr_or_update_event( 432pub async fn generate_unsigned_pr_or_update_event(
424 git_repo: &Repo, 433 git_repo: &Repo,
425 repo_ref: &RepoRef, 434 repo_ref: &RepoRef,
426 signing_public_key: &PublicKey, 435 signing_public_key: &PublicKey,
@@ -431,6 +440,7 @@ pub fn generate_unsigned_pr_or_update_event(
431 merge_base: Option<&Sha1Hash>, 440 merge_base: Option<&Sha1Hash>,
432 clone_url_hint: &[&str], 441 clone_url_hint: &[&str],
433 mentions: &[nostr::Tag], 442 mentions: &[nostr::Tag],
443 git_repo_path: Option<&Path>,
434) -> Result<UnsignedEvent> { 444) -> Result<UnsignedEvent> {
435 let root_patch_cover_letter = if let Some(root_proposal) = root_proposal { 445 let root_patch_cover_letter = if let Some(root_proposal) = root_proposal {
436 if root_proposal.kind.eq(&Kind::GitPatch) { 446 if root_proposal.kind.eq(&Kind::GitPatch) {
@@ -526,64 +536,74 @@ pub fn generate_unsigned_pr_or_update_event(
526 vec![] 536 vec![]
527 }; 537 };
528 538
529 Ok( 539 // NIP-21 mention tags from PR description content (only for new PRs, not
530 if root_proposal.is_some() && root_patch_cover_letter.is_none() { 540 // updates)
531 EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "") 541 let is_pr_update = root_proposal.is_some() && root_patch_cover_letter.is_none();
532 } else { 542 let content_mention_tags = if is_pr_update {
533 EventBuilder::new(KIND_PULL_REQUEST, description) 543 vec![]
534 } 544 } else {
535 .tags( 545 tags_from_content(&description, git_repo_path).await?
536 [ 546 };
537 repo_ref 547
538 .maintainers 548 let all_tags = crate::content_tags::dedup_tags(
539 .iter() 549 [
540 .map(|m| { 550 repo_ref
541 Tag::from_standardized(TagStandard::Coordinate { 551 .maintainers
542 coordinate: Coordinate { 552 .iter()
543 kind: nostr::Kind::GitRepoAnnouncement, 553 .map(|m| {
544 public_key: *m, 554 Tag::from_standardized(TagStandard::Coordinate {
545 identifier: repo_ref.identifier.to_string(), 555 coordinate: Coordinate {
546 }, 556 kind: nostr::Kind::GitRepoAnnouncement,
547 relay_url: repo_ref.relays.first().cloned(), 557 public_key: *m,
548 uppercase: false, 558 identifier: repo_ref.identifier.to_string(),
549 }) 559 },
560 relay_url: repo_ref.relays.first().cloned(),
561 uppercase: false,
550 }) 562 })
551 .collect::<Vec<Tag>>(), 563 })
552 mentions.to_vec(), 564 .collect::<Vec<Tag>>(),
553 if let Some(root_proposal) = root_proposal { 565 mentions.to_vec(),
554 if root_patch_cover_letter.is_none() { 566 if let Some(root_proposal) = root_proposal {
555 pr_update_specific_tags(root_proposal) 567 if root_patch_cover_letter.is_none() {
556 } else { 568 pr_update_specific_tags(root_proposal)
557 pr_specific_tags()
558 }
559 } else { 569 } else {
560 pr_specific_tags() 570 pr_specific_tags()
561 }, 571 }
562 vec![ 572 } else {
563 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), 573 pr_specific_tags()
564 Tag::custom( 574 },
565 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("c")), 575 vec![
566 vec![format!("{tip}")], 576 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
567 ), 577 Tag::custom(
568 Tag::custom( 578 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("c")),
569 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), 579 vec![format!("{tip}")],
570 clone_url_hint 580 ),
571 .iter() 581 Tag::custom(
572 .map(|s| s.to_string()) 582 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")),
573 .collect::<Vec<String>>(), 583 clone_url_hint
574 ), 584 .iter()
575 ], 585 .map(|s| s.to_string())
576 merge_base_tag, 586 .collect::<Vec<String>>(),
577 repo_ref 587 ),
578 .maintainers 588 ],
579 .iter() 589 merge_base_tag,
580 .map(|pk| Tag::public_key(*pk)) 590 repo_ref
581 .collect(), 591 .maintainers
582 ] 592 .iter()
583 .concat(), 593 .map(|pk| Tag::public_key(*pk))
584 ) 594 .collect(),
585 .build(*signing_public_key), 595 content_mention_tags,
586 ) 596 ]
597 .concat(),
598 );
599
600 Ok(if is_pr_update {
601 EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "")
602 } else {
603 EventBuilder::new(KIND_PULL_REQUEST, description)
604 }
605 .tags(all_tags)
606 .build(*signing_public_key))
587} 607}
588 608
589fn make_branch_name_tag_from_check_out_branch(git_repo: &Repo) -> Option<Tag> { 609fn make_branch_name_tag_from_check_out_branch(git_repo: &Repo) -> Option<Tag> {
@@ -624,6 +644,7 @@ pub async fn generate_cover_letter_and_patch_events(
624 root_proposal_id: &Option<String>, 644 root_proposal_id: &Option<String>,
625 mentions: &[nostr::Tag], 645 mentions: &[nostr::Tag],
626) -> Result<Vec<nostr::Event>> { 646) -> Result<Vec<nostr::Event>> {
647 let git_repo_path = git_repo.get_path().ok();
627 let root_commit = git_repo 648 let root_commit = git_repo
628 .get_root_commit() 649 .get_root_commit()
629 .context("failed to get root commit of the repository")?; 650 .context("failed to get root commit of the repository")?;
@@ -631,6 +652,74 @@ pub async fn generate_cover_letter_and_patch_events(
631 let mut events = vec![]; 652 let mut events = vec![];
632 653
633 if let Some((title, description)) = cover_letter_title_description { 654 if let Some((title, description)) = cover_letter_title_description {
655 // NIP-21 mention tags from cover letter title and description
656 let cover_letter_text = format!("{title}\n\n{description}");
657 let cover_letter_content_tags =
658 tags_from_content(&cover_letter_text, git_repo_path).await?;
659
660 let cover_letter_tags = crate::content_tags::dedup_tags(
661 [
662 repo_ref
663 .maintainers
664 .iter()
665 .map(|m| {
666 Tag::from_standardized(TagStandard::Coordinate {
667 coordinate: Coordinate {
668 kind: nostr::Kind::GitRepoAnnouncement,
669 public_key: *m,
670 identifier: repo_ref.identifier.to_string(),
671 },
672 relay_url: repo_ref.relays.first().cloned(),
673 uppercase: false,
674 })
675 })
676 .collect::<Vec<Tag>>(),
677 vec![
678 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
679 Tag::hashtag("cover-letter"),
680 Tag::custom(
681 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
682 vec![format!("git patch cover letter: {}", title.clone())],
683 ),
684 ],
685 if let Some(event_ref) = root_proposal_id.clone() {
686 vec![
687 Tag::hashtag("root"),
688 Tag::hashtag("root-revision"),
689 // TODO check if id is for a root proposal (perhaps its for an issue?)
690 event_tag_from_nip19_or_hex(
691 &event_ref,
692 "proposal",
693 EventRefType::Reply,
694 false,
695 false,
696 )?,
697 ]
698 } else {
699 vec![Tag::hashtag("root")]
700 },
701 mentions.to_vec(),
702 // this is not strictly needed but makes for prettier branch names
703 // eventually a prefix will be needed of the event id to stop 2 proposals with the
704 // same name colliding a change like this, or the removal of this
705 // tag will require the actual branch name to be tracked so pulling
706 // and pushing still work
707 if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo)
708 {
709 vec![branch_name_tag]
710 } else {
711 vec![]
712 },
713 repo_ref
714 .maintainers
715 .iter()
716 .map(|pk| Tag::public_key(*pk))
717 .collect(),
718 cover_letter_content_tags,
719 ]
720 .concat(),
721 );
722
634 events.push(sign_event(EventBuilder::new( 723 events.push(sign_event(EventBuilder::new(
635 nostr::event::Kind::GitPatch, 724 nostr::event::Kind::GitPatch,
636 format!( 725 format!(
@@ -638,55 +727,7 @@ pub async fn generate_cover_letter_and_patch_events(
638 commits.last().unwrap(), 727 commits.last().unwrap(),
639 commits.len() 728 commits.len()
640 )) 729 ))
641 .tags( 730 .tags(cover_letter_tags),
642 [
643 repo_ref.maintainers.iter().map(|m|
644 Tag::from_standardized(TagStandard::Coordinate {
645 coordinate: Coordinate {
646 kind: nostr::Kind::GitRepoAnnouncement,
647 public_key: *m,
648 identifier: repo_ref.identifier.to_string(),
649 },
650 relay_url: repo_ref.relays.first().cloned(),
651 uppercase: false,
652 })
653 ).collect::<Vec<Tag>>(),
654 vec![
655 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
656 Tag::hashtag("cover-letter"),
657 Tag::custom(
658 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
659 vec![format!("git patch cover letter: {}", title.clone())],
660 ),
661 ],
662 if let Some(event_ref) = root_proposal_id.clone() {
663 vec![
664 Tag::hashtag("root"),
665 Tag::hashtag("root-revision"),
666 // TODO check if id is for a root proposal (perhaps its for an issue?)
667 event_tag_from_nip19_or_hex(&event_ref,"proposal",EventRefType::Reply, false, false)?,
668 ]
669 } else {
670 vec![
671 Tag::hashtag("root"),
672 ]
673 },
674 mentions.to_vec(),
675 // this is not strictly needed but makes for prettier branch names
676 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
677 // a change like this, or the removal of this tag will require the actual branch name to be tracked
678 // so pulling and pushing still work
679 if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo) {
680 vec![branch_name_tag]
681 } else {
682 vec![]
683 },
684 repo_ref.maintainers
685 .iter()
686 .map(|pk| Tag::public_key(*pk))
687 .collect(),
688 ].concat(),
689 ),
690 signer, 731 signer,
691 format!("commit 0/{}",commits.len()), 732 format!("commit 0/{}",commits.len()),
692).await 733).await
diff --git a/src/lib/mod.rs b/src/lib/mod.rs
index f839e7f..9768343 100644
--- a/src/lib/mod.rs
+++ b/src/lib/mod.rs
@@ -1,6 +1,7 @@
1pub mod accept_maintainership; 1pub mod accept_maintainership;
2pub mod cli_interactor; 2pub mod cli_interactor;
3pub mod client; 3pub mod client;
4pub mod content_tags;
4pub mod fetch; 5pub mod fetch;
5pub mod git; 6pub mod git;
6pub mod git_events; 7pub mod git_events;
diff --git a/src/lib/push.rs b/src/lib/push.rs
index 7374fb0..2f9a26a 100644
--- a/src/lib/push.rs
+++ b/src/lib/push.rs
@@ -745,7 +745,9 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event(
745 merge_base, 745 merge_base,
746 &[clone_url], 746 &[clone_url],
747 &[], 747 &[],
748 )? 748 git_repo.get_path().ok(),
749 )
750 .await?
749 }; 751 };
750 752
751 let git_ref_used = git_ref 753 let git_ref_used = git_ref