From af016dd23101537ccc8ecd5a992bf3b7c6d3abe9 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Mar 2026 16:24:29 +0000 Subject: 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 :: 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 --- src/bin/ngit/sub_commands/comment.rs | 66 ++-- src/bin/ngit/sub_commands/issue_create.rs | 10 +- src/lib/content_tags.rs | 389 ++++++++++++++++++++++ src/lib/git_events.rs | 519 ++++++++++++++++-------------- src/lib/mod.rs | 1 + src/lib/push.rs | 4 +- 6 files changed, 719 insertions(+), 270 deletions(-) create mode 100644 src/lib/content_tags.rs 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::{ Params, get_events_from_local_cache, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events, sign_event, }, + content_tags::{dedup_tags, tags_from_content}, git_events::KIND_COMMENT, }; use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; @@ -102,36 +103,43 @@ async fn publish_comment(args: CommentArgs<'_>) -> Result<()> { let root_kind_str = root_kind.as_u16().to_string(); let parent_kind_str = parent_kind.as_u16().to_string(); - // NIP-22 compliant tags + // NIP-22 compliant threading tags + let mut comment_tags: Vec = vec![ + // Root scope: uppercase E with root pubkey as 4th element + Tag::parse(vec![ + "E".to_string(), + root_event_id.to_hex(), + relay_hint.clone(), + root_pubkey.to_hex(), + ])?, + // Root kind + Tag::parse(vec!["K".to_string(), root_kind_str])?, + // Root author pubkey + Tag::parse(vec![ + "P".to_string(), + root_pubkey.to_hex(), + relay_hint.clone(), + ])?, + // Parent item: lowercase e with parent pubkey as 4th element + Tag::parse(vec![ + "e".to_string(), + parent_event_id.to_hex(), + relay_hint.clone(), + parent_pubkey.to_hex(), + ])?, + // Parent kind + Tag::parse(vec!["k".to_string(), parent_kind_str])?, + // Parent author pubkey + Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?, + ]; + + // NIP-21 mention tags: q tags for cited events/addresses, p tags for cited + // pubkeys + comment_tags.extend(tags_from_content(body, Some(git_repo_path)).await?); + let comment_tags = dedup_tags(comment_tags); + let comment_event = sign_event( - EventBuilder::new(KIND_COMMENT, body).tags(vec![ - // Root scope: uppercase E with root pubkey as 4th element - Tag::parse(vec![ - "E".to_string(), - root_event_id.to_hex(), - relay_hint.clone(), - root_pubkey.to_hex(), - ])?, - // Root kind - Tag::parse(vec!["K".to_string(), root_kind_str])?, - // Root author pubkey - Tag::parse(vec![ - "P".to_string(), - root_pubkey.to_hex(), - relay_hint.clone(), - ])?, - // Parent item: lowercase e with parent pubkey as 4th element - Tag::parse(vec![ - "e".to_string(), - parent_event_id.to_hex(), - relay_hint.clone(), - parent_pubkey.to_hex(), - ])?, - // Parent kind - Tag::parse(vec!["k".to_string(), parent_kind_str])?, - // Parent author pubkey - Tag::parse(vec!["p".to_string(), parent_pubkey.to_hex(), relay_hint])?, - ]), + EventBuilder::new(KIND_COMMENT, body).tags(comment_tags), &signer, format!("comment on {entity_name}"), ) 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 @@ use anyhow::{Context, Result, bail}; -use ngit::client::{Params, send_events, sign_event}; +use ngit::{ + client::{Params, send_events, sign_event}, + content_tags::{dedup_tags, tags_from_content}, +}; use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event}; use nostr_sdk::Kind; @@ -74,6 +77,11 @@ pub async fn launch( tags.push(Tag::public_key(*pk)); } + // NIP-21 mention tags: q tags for cited events/addresses, p tags for cited + // pubkeys + tags.extend(tags_from_content(&body, Some(git_repo_path)).await?); + let tags = dedup_tags(tags); + let issue_event = sign_event( EventBuilder::new(Kind::GitIssue, body).tags(tags), &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 @@ +//! Parse `nostr:` URI mentions (NIP-21) from event content and produce the +//! corresponding NIP-22 `q` / `p` tags. +//! +//! Rules implemented: +//! - `nostr:npub1…` / `nostr:nprofile1…` → `["p", "", ""]` +//! - `nostr:note1…` / `nostr:nevent1…` → `["q", "", "", +//! ""]` +//! - `nostr:naddr1…` → `["q", +//! "::", ""]` +//! +//! Duplicate tags (same first two elements) are deduplicated within the content +//! scan. Use [`dedup_tags`] after merging content tags with the rest of the +//! event's tag list to remove cross-source duplicates. + +use std::{collections::HashSet, path::Path}; + +use anyhow::Result; +use nostr::{FromBech32, Tag, nips::nip19::Nip19}; +use nostr_sdk::EventId; + +use crate::client::get_events_from_local_cache; + +/// Regex-free extraction of every `nostr:` token from `content`. +fn extract_nostr_uris(content: &str) -> Vec<&str> { + let mut uris = Vec::new(); + let mut remaining = content; + while let Some(start) = remaining.find("nostr:") { + let after = &remaining[start + 6..]; // skip "nostr:" + // A bech32 token consists of alphanumeric chars (plus the separator '1'). + // We stop at the first non-bech32 character. + let end = after + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(after.len()); + if end > 0 { + uris.push(&remaining[start..start + 6 + end]); + } + remaining = &remaining[start + 6 + end..]; + } + uris +} + +/// Build `q` / `p` tags for every `nostr:` mention found in `content`. +/// +/// `git_repo_path` is used for the optional local-cache lookup that fills in +/// the author pubkey of a cited regular event when it is not embedded in the +/// `nevent` bech32. +pub async fn tags_from_content(content: &str, git_repo_path: Option<&Path>) -> Result> { + let uris = extract_nostr_uris(content); + if uris.is_empty() { + return Ok(vec![]); + } + + // Collect (tag_name, value0, value1_opt) tuples for deduplication. + // We use the first two tag elements as the dedup key. + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut tags: Vec = Vec::new(); + + for uri in uris { + // Strip the "nostr:" prefix to get the raw bech32 string. + let bech32 = &uri[6..]; + + let Ok(nip19) = Nip19::from_bech32(bech32) else { + continue; + }; + + match nip19 { + // ── pubkey references → p tag ───────────────────────────────── + Nip19::Pubkey(pk) => { + let key = ("p".to_string(), pk.to_hex()); + if seen.insert(key) { + let Ok(tag) = Tag::parse(vec!["p".to_string(), pk.to_hex()]) else { + continue; + }; + tags.push(tag); + } + } + Nip19::Profile(profile) => { + let key = ("p".to_string(), profile.public_key.to_hex()); + if seen.insert(key) { + let mut parts = vec!["p".to_string(), profile.public_key.to_hex()]; + if let Some(relay) = profile.relays.first() { + parts.push(relay.to_string()); + } + let Ok(tag) = Tag::parse(parts) else { continue }; + tags.push(tag); + } + } + + // ── regular event references → q tag ───────────────────────── + Nip19::EventId(event_id) => { + let key = ("q".to_string(), event_id.to_hex()); + if seen.insert(key) { + // No relay or pubkey info available; attempt cache lookup. + let pubkey = lookup_event_pubkey(&event_id, git_repo_path).await; + let Ok(tag) = build_q_tag_for_event(event_id, None, pubkey) else { + continue; + }; + tags.push(tag); + } + } + Nip19::Event(nevent) => { + let key = ("q".to_string(), nevent.event_id.to_hex()); + if seen.insert(key) { + let relay = nevent.relays.first().cloned(); + // Prefer author embedded in nevent; fall back to cache lookup. + let pubkey = if nevent.author.is_some() { + nevent.author + } else { + lookup_event_pubkey(&nevent.event_id, git_repo_path).await + }; + let Ok(tag) = build_q_tag_for_event(nevent.event_id, relay, pubkey) else { + continue; + }; + tags.push(tag); + } + } + + // ── addressable event references → q tag with coordinate ────── + Nip19::Coordinate(naddr) => { + let coord = &naddr.coordinate; + // Format: :: + let coord_str = format!( + "{}:{}:{}", + coord.kind.as_u16(), + coord.public_key.to_hex(), + coord.identifier + ); + let key = ("q".to_string(), coord_str.clone()); + if seen.insert(key) { + let mut parts = vec!["q".to_string(), coord_str]; + if let Some(relay) = naddr.relays.first() { + parts.push(relay.to_string()); + } + let Ok(tag) = Tag::parse(parts) else { continue }; + tags.push(tag); + } + } + + // nsec / ncryptsec — ignore + _ => {} + } + } + + Ok(tags) +} + +/// Deduplicate a merged tag list, removing: +/// +/// 1. Duplicate `p` tags — keep the first occurrence of each pubkey hex. +/// 2. Duplicate `q` tags — keep the first occurrence of each value. +/// 3. `q` tags whose event-id (position `[1]`) is already referenced by an +/// existing `e` tag — avoids redundant citations when the event is already +/// part of the threading structure. +/// +/// All other tags are passed through unchanged and in order. +pub fn dedup_tags(tags: Vec) -> Vec { + // First pass: collect the set of event IDs already covered by `e` tags. + let e_ids: HashSet = tags + .iter() + .filter(|t| t.as_slice().first().is_some_and(|k| k == "e")) + .filter_map(|t| t.as_slice().get(1).cloned()) + .collect(); + + let mut seen_p: HashSet = HashSet::new(); + let mut seen_q: HashSet = HashSet::new(); + let mut out: Vec = Vec::with_capacity(tags.len()); + + for tag in tags { + let slice = tag.as_slice(); + match slice.first().map(String::as_str) { + Some("p") => { + if let Some(pk) = slice.get(1) { + if seen_p.insert(pk.clone()) { + out.push(tag); + } + // else: duplicate p tag — drop it + } else { + out.push(tag); // malformed, pass through + } + } + Some("q") => { + if let Some(val) = slice.get(1) { + // Suppress if already covered by an e tag (regular event refs only; + // coordinate strings contain ':' so they can never match a plain hex id). + if e_ids.contains(val) { + continue; + } + if seen_q.insert(val.clone()) { + out.push(tag); + } + // else: duplicate q tag — drop it + } else { + out.push(tag); + } + } + _ => out.push(tag), + } + } + + out +} + +/// Attempt to find the pubkey of `event_id` in the local cache. +/// Returns `None` if the cache is unavailable or the event is not found. +async fn lookup_event_pubkey( + event_id: &EventId, + git_repo_path: Option<&Path>, +) -> Option { + let path = git_repo_path?; + let filter = nostr::Filter::default().id(*event_id); + let events = get_events_from_local_cache(path, vec![filter]).await.ok()?; + events + .into_iter() + .find(|e| e.id == *event_id) + .map(|e| e.pubkey) +} + +/// Build a `["q", "", "", ""]` tag. +/// Trailing optional elements are omitted when absent. +fn build_q_tag_for_event( + event_id: EventId, + relay: Option, + pubkey: Option, +) -> Result { + let mut parts = vec!["q".to_string(), event_id.to_hex()]; + match (relay, pubkey) { + (Some(r), Some(pk)) => { + parts.push(r.to_string()); + parts.push(pk.to_hex()); + } + (Some(r), None) => { + parts.push(r.to_string()); + } + (None, Some(pk)) => { + // relay is required before pubkey per the tag spec; use empty string + parts.push(String::new()); + parts.push(pk.to_hex()); + } + (None, None) => {} + } + Ok(Tag::parse(parts)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_no_mentions() { + let tags = tags_from_content("hello world, no mentions here", None) + .await + .unwrap(); + assert!(tags.is_empty()); + } + + #[tokio::test] + async fn test_npub_mention() { + let content = + "hello nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 world"; + let tags = tags_from_content(content, None).await.unwrap(); + assert_eq!(tags.len(), 1); + let slice = tags[0].as_slice(); + assert_eq!(slice[0], "p"); + // pubkey hex should be 64 chars + assert_eq!(slice[1].len(), 64); + } + + #[tokio::test] + async fn test_note_mention() { + // note1 encoding of all-zeros event id + let content = "see nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn2l0z3"; + let tags = tags_from_content(content, None).await.unwrap(); + assert_eq!(tags.len(), 1); + let slice = tags[0].as_slice(); + assert_eq!(slice[0], "q"); + assert_eq!(slice[1].len(), 64); + } + + #[tokio::test] + async fn test_naddr_mention() { + // naddr for kind 30023 (long-form article) + let content = "nostr:naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld"; + let tags = tags_from_content(content, None).await.unwrap(); + assert_eq!(tags.len(), 1); + let slice = tags[0].as_slice(); + assert_eq!(slice[0], "q"); + // format: :: + let parts: Vec<&str> = slice[1].splitn(3, ':').collect(); + assert_eq!(parts.len(), 3); + assert!(parts[0].parse::().is_ok(), "kind should be numeric"); + assert_eq!(parts[1].len(), 64, "pubkey should be 64 hex chars"); + } + + #[tokio::test] + async fn test_deduplication() { + let npub = "nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + let content = format!("{npub} and again {npub}"); + let tags = tags_from_content(&content, None).await.unwrap(); + assert_eq!(tags.len(), 1); + } + + #[tokio::test] + async fn test_mixed_mentions() { + // note1 encoding of all-zeros event id + let content = "nostr:npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6 and nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn2l0z3"; + let tags = tags_from_content(content, None).await.unwrap(); + assert_eq!(tags.len(), 2); + let tag_names: Vec<&str> = tags.iter().map(|t| t.as_slice()[0].as_str()).collect(); + assert!(tag_names.contains(&"p")); + assert!(tag_names.contains(&"q")); + } + + // ── dedup_tags tests ────────────────────────────────────────────────────── + + #[test] + fn dedup_removes_duplicate_p_tags() { + let pk = "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca"; + let tags = vec![ + Tag::parse(vec!["p".to_string(), pk.to_string()]).unwrap(), + Tag::parse(vec!["p".to_string(), pk.to_string()]).unwrap(), + ]; + let result = dedup_tags(tags); + assert_eq!(result.len(), 1); + assert_eq!(result[0].as_slice()[0], "p"); + } + + #[test] + fn dedup_keeps_different_p_tags() { + let pk1 = "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca"; + let pk2 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; + let tags = vec![ + Tag::parse(vec!["p".to_string(), pk1.to_string()]).unwrap(), + Tag::parse(vec!["p".to_string(), pk2.to_string()]).unwrap(), + ]; + let result = dedup_tags(tags); + assert_eq!(result.len(), 2); + } + + #[test] + fn dedup_removes_q_tag_when_e_tag_has_same_id() { + let id = "0000000000000000000000000000000000000000000000000000000000000000"; + let tags = vec![ + Tag::parse(vec!["e".to_string(), id.to_string()]).unwrap(), + Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(), + ]; + let result = dedup_tags(tags); + // q tag should be suppressed; e tag kept + assert_eq!(result.len(), 1); + assert_eq!(result[0].as_slice()[0], "e"); + } + + #[test] + fn dedup_keeps_q_tag_for_coordinate_even_if_e_tag_present() { + // coordinate strings contain ':' so they can never match a plain hex event id + let coord = + "30023:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:my-article"; + let event_id = "0000000000000000000000000000000000000000000000000000000000000000"; + let tags = vec![ + Tag::parse(vec!["e".to_string(), event_id.to_string()]).unwrap(), + Tag::parse(vec!["q".to_string(), coord.to_string()]).unwrap(), + ]; + let result = dedup_tags(tags); + assert_eq!(result.len(), 2); + } + + #[test] + fn dedup_removes_duplicate_q_tags() { + let id = "0000000000000000000000000000000000000000000000000000000000000000"; + let tags = vec![ + Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(), + Tag::parse(vec!["q".to_string(), id.to_string()]).unwrap(), + ]; + let result = dedup_tags(tags); + assert_eq!(result.len(), 1); + } + + #[test] + fn dedup_passes_through_other_tags_unchanged() { + let tags = vec![ + Tag::parse(vec!["subject".to_string(), "hello".to_string()]).unwrap(), + Tag::parse(vec!["t".to_string(), "rust".to_string()]).unwrap(), + Tag::parse(vec!["t".to_string(), "rust".to_string()]).unwrap(), /* hashtag dup — not + * deduped */ + ]; + let result = dedup_tags(tags); + // only p and q are deduped; other tags pass through as-is + assert_eq!(result.len(), 3); + } +} 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 @@ -use std::{collections::HashMap, str::FromStr, sync::Arc}; +use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc}; use anyhow::{Context, Result, bail}; use nostr::{ @@ -13,6 +13,7 @@ use nostr_sdk::{ use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, client::sign_event, + content_tags::tags_from_content, git::{Repo, RepoActions}, repo_ref::RepoRef, utils::get_open_or_draft_proposals, @@ -169,6 +170,146 @@ pub async fn generate_patch_event( .context("failed to get parent commit")?; let relay_hint = repo_ref.relays.first().cloned(); + // NIP-21 mention tags from commit message (description tag value, with mbox + // fallback) + let commit_message = git_repo.get_commit_message(commit).unwrap_or_default(); + let patch_content_tags = tags_from_content(&commit_message, git_repo.get_path().ok()).await?; + + let patch_tags = crate::content_tags::dedup_tags( + [ + repo_ref + .maintainers + .iter() + .map(|m| { + Tag::from_standardized(TagStandard::Coordinate { + coordinate: Coordinate { + kind: nostr::Kind::GitRepoAnnouncement, + public_key: *m, + identifier: repo_ref.identifier.to_string(), + }, + relay_url: repo_ref.relays.first().cloned(), + uppercase: false, + }) + }) + .collect::>(), + vec![ + Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), + // commit id reference is a trade-off. its now + // unclear which one is the root commit id but it + // enables easier location of code comments againt + // code that makes it into the main branch, assuming + // the commit id is correct + Tag::from_standardized(TagStandard::Reference(commit.to_string())), + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!( + "git patch: {}", + git_repo + .get_commit_message_summary(commit) + .unwrap_or_default() + )], + ), + ], + if let Some(thread_event_id) = thread_event_id { + vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: thread_event_id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Root), + public_key: None, + uppercase: false, + })] + } else if let Some(event_ref) = root_proposal_id.clone() { + vec![ + Tag::hashtag("root"), + Tag::hashtag("root-revision"), + // TODO check if id is for a root proposal (perhaps its for an issue?) + event_tag_from_nip19_or_hex( + &event_ref, + "proposal", + EventRefType::Reply, + false, + false, + )?, + ] + } else { + vec![Tag::hashtag("root")] + }, + mentions.to_vec(), + if let Some(id) = parent_patch_event_id { + vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Reply), + public_key: None, + uppercase: false, + })] + } else { + vec![] + }, + // see comment on branch names in cover letter event creation + if let Some(branch_name) = branch_name { + if thread_event_id.is_none() { + vec![Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), + vec![branch_name.chars().take(60).collect::()], + )] + } else { + vec![] + } + } else { + vec![] + }, + // whilst it is in nip34 draft to tag the maintainers + // I'm not sure it is a good idea because if they are + // interested in all patches then their specialised + // client should subscribe to patches tagged with the + // repo reference. maintainers of large repos will not + // be interested in every patch. + repo_ref + .maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + vec![ + // a fallback is now in place to extract this from the patch + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("commit")), + vec![commit.to_string()], + ), + // this is required as patches cannot be relied upon to include the 'base + // commit' + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), + vec![commit_parent.to_string()], + ), + // this is required to ensure the commit id matches + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), + vec![ + git_repo + .extract_commit_pgp_signature(commit) + .unwrap_or_default(), + ], + ), + // removing description tag will not cause anything to break + Tag::from_standardized(nostr_sdk::TagStandard::Description( + git_repo.get_commit_message(commit)?.to_string(), + )), + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("author")), + git_repo.get_commit_author(commit)?, + ), + // this is required to ensure the commit id matches + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("committer")), + git_repo.get_commit_comitter(commit)?, + ), + ], + patch_content_tags, + ] + .concat(), + ); + sign_event( EventBuilder::new( nostr::event::Kind::GitPatch, @@ -176,139 +317,7 @@ pub async fn generate_patch_event( .make_patch_from_commit(commit, &series_count) .context(format!("failed to make patch for commit {commit}"))?, ) - .tags( - [ - repo_ref - .maintainers - .iter() - .map(|m| { - Tag::from_standardized(TagStandard::Coordinate { - coordinate: Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - }, - relay_url: repo_ref.relays.first().cloned(), - uppercase: false, - }) - }) - .collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), - // commit id reference is a trade-off. its now - // unclear which one is the root commit id but it - // enables easier location of code comments againt - // code that makes it into the main branch, assuming - // the commit id is correct - Tag::from_standardized(TagStandard::Reference(commit.to_string())), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!( - "git patch: {}", - git_repo - .get_commit_message_summary(commit) - .unwrap_or_default() - )], - ), - ], - if let Some(thread_event_id) = thread_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: thread_event_id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Root), - public_key: None, - uppercase: false, - })] - } else if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("root-revision"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex( - &event_ref, - "proposal", - EventRefType::Reply, - false, - false, - )?, - ] - } else { - vec![Tag::hashtag("root")] - }, - mentions.to_vec(), - if let Some(id) = parent_patch_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - })] - } else { - vec![] - }, - // see comment on branch names in cover letter event creation - if let Some(branch_name) = branch_name { - if thread_event_id.is_none() { - vec![Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![branch_name.chars().take(60).collect::()], - )] - } else { - vec![] - } - } else { - vec![] - }, - // whilst it is in nip34 draft to tag the maintainers - // I'm not sure it is a good idea because if they are - // interested in all patches then their specialised - // client should subscribe to patches tagged with the - // repo reference. maintainers of large repos will not - // be interested in every patch. - repo_ref - .maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - vec![ - // a fallback is now in place to extract this from the patch - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit")), - vec![commit.to_string()], - ), - // this is required as patches cannot be relied upon to include the 'base - // commit' - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), - vec![commit_parent.to_string()], - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), - vec![ - git_repo - .extract_commit_pgp_signature(commit) - .unwrap_or_default(), - ], - ), - // removing description tag will not cause anything to break - Tag::from_standardized(nostr_sdk::TagStandard::Description( - git_repo.get_commit_message(commit)?.to_string(), - )), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("author")), - git_repo.get_commit_author(commit)?, - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("committer")), - git_repo.get_commit_comitter(commit)?, - ), - ], - ] - .concat(), - ), + .tags(patch_tags), signer, if let Some((n, total)) = series_count { format!("commit {n}/{total}") @@ -420,7 +429,7 @@ pub fn event_tag_from_nip19_or_hex( } #[allow(clippy::too_many_arguments)] -pub fn generate_unsigned_pr_or_update_event( +pub async fn generate_unsigned_pr_or_update_event( git_repo: &Repo, repo_ref: &RepoRef, signing_public_key: &PublicKey, @@ -431,6 +440,7 @@ pub fn generate_unsigned_pr_or_update_event( merge_base: Option<&Sha1Hash>, clone_url_hint: &[&str], mentions: &[nostr::Tag], + git_repo_path: Option<&Path>, ) -> Result { let root_patch_cover_letter = if let Some(root_proposal) = root_proposal { if root_proposal.kind.eq(&Kind::GitPatch) { @@ -526,64 +536,74 @@ pub fn generate_unsigned_pr_or_update_event( vec![] }; - Ok( - if root_proposal.is_some() && root_patch_cover_letter.is_none() { - EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "") - } else { - EventBuilder::new(KIND_PULL_REQUEST, description) - } - .tags( - [ - repo_ref - .maintainers - .iter() - .map(|m| { - Tag::from_standardized(TagStandard::Coordinate { - coordinate: Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - }, - relay_url: repo_ref.relays.first().cloned(), - uppercase: false, - }) + // NIP-21 mention tags from PR description content (only for new PRs, not + // updates) + let is_pr_update = root_proposal.is_some() && root_patch_cover_letter.is_none(); + let content_mention_tags = if is_pr_update { + vec![] + } else { + tags_from_content(&description, git_repo_path).await? + }; + + let all_tags = crate::content_tags::dedup_tags( + [ + repo_ref + .maintainers + .iter() + .map(|m| { + Tag::from_standardized(TagStandard::Coordinate { + coordinate: Coordinate { + kind: nostr::Kind::GitRepoAnnouncement, + public_key: *m, + identifier: repo_ref.identifier.to_string(), + }, + relay_url: repo_ref.relays.first().cloned(), + uppercase: false, }) - .collect::>(), - mentions.to_vec(), - if let Some(root_proposal) = root_proposal { - if root_patch_cover_letter.is_none() { - pr_update_specific_tags(root_proposal) - } else { - pr_specific_tags() - } + }) + .collect::>(), + mentions.to_vec(), + if let Some(root_proposal) = root_proposal { + if root_patch_cover_letter.is_none() { + pr_update_specific_tags(root_proposal) } else { pr_specific_tags() - }, - vec![ - Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("c")), - vec![format!("{tip}")], - ), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), - clone_url_hint - .iter() - .map(|s| s.to_string()) - .collect::>(), - ), - ], - merge_base_tag, - repo_ref - .maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ] - .concat(), - ) - .build(*signing_public_key), - ) + } + } else { + pr_specific_tags() + }, + vec![ + Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("c")), + vec![format!("{tip}")], + ), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), + clone_url_hint + .iter() + .map(|s| s.to_string()) + .collect::>(), + ), + ], + merge_base_tag, + repo_ref + .maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + content_mention_tags, + ] + .concat(), + ); + + Ok(if is_pr_update { + EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "") + } else { + EventBuilder::new(KIND_PULL_REQUEST, description) + } + .tags(all_tags) + .build(*signing_public_key)) } fn make_branch_name_tag_from_check_out_branch(git_repo: &Repo) -> Option { @@ -624,6 +644,7 @@ pub async fn generate_cover_letter_and_patch_events( root_proposal_id: &Option, mentions: &[nostr::Tag], ) -> Result> { + let git_repo_path = git_repo.get_path().ok(); let root_commit = git_repo .get_root_commit() .context("failed to get root commit of the repository")?; @@ -631,6 +652,74 @@ pub async fn generate_cover_letter_and_patch_events( let mut events = vec![]; if let Some((title, description)) = cover_letter_title_description { + // NIP-21 mention tags from cover letter title and description + let cover_letter_text = format!("{title}\n\n{description}"); + let cover_letter_content_tags = + tags_from_content(&cover_letter_text, git_repo_path).await?; + + let cover_letter_tags = crate::content_tags::dedup_tags( + [ + repo_ref + .maintainers + .iter() + .map(|m| { + Tag::from_standardized(TagStandard::Coordinate { + coordinate: Coordinate { + kind: nostr::Kind::GitRepoAnnouncement, + public_key: *m, + identifier: repo_ref.identifier.to_string(), + }, + relay_url: repo_ref.relays.first().cloned(), + uppercase: false, + }) + }) + .collect::>(), + vec![ + Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), + Tag::hashtag("cover-letter"), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("git patch cover letter: {}", title.clone())], + ), + ], + if let Some(event_ref) = root_proposal_id.clone() { + vec![ + Tag::hashtag("root"), + Tag::hashtag("root-revision"), + // TODO check if id is for a root proposal (perhaps its for an issue?) + event_tag_from_nip19_or_hex( + &event_ref, + "proposal", + EventRefType::Reply, + false, + false, + )?, + ] + } else { + vec![Tag::hashtag("root")] + }, + mentions.to_vec(), + // this is not strictly needed but makes for prettier branch names + // eventually a prefix will be needed of the event id to stop 2 proposals with the + // same name colliding a change like this, or the removal of this + // tag will require the actual branch name to be tracked so pulling + // and pushing still work + if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo) + { + vec![branch_name_tag] + } else { + vec![] + }, + repo_ref + .maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + cover_letter_content_tags, + ] + .concat(), + ); + events.push(sign_event(EventBuilder::new( nostr::event::Kind::GitPatch, format!( @@ -638,55 +727,7 @@ pub async fn generate_cover_letter_and_patch_events( commits.last().unwrap(), commits.len() )) - .tags( - [ - repo_ref.maintainers.iter().map(|m| - Tag::from_standardized(TagStandard::Coordinate { - coordinate: Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - }, - relay_url: repo_ref.relays.first().cloned(), - uppercase: false, - }) - ).collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), - Tag::hashtag("cover-letter"), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!("git patch cover letter: {}", title.clone())], - ), - ], - if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("root-revision"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex(&event_ref,"proposal",EventRefType::Reply, false, false)?, - ] - } else { - vec![ - Tag::hashtag("root"), - ] - }, - mentions.to_vec(), - // this is not strictly needed but makes for prettier branch names - // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding - // a change like this, or the removal of this tag will require the actual branch name to be tracked - // so pulling and pushing still work - if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo) { - vec![branch_name_tag] - } else { - vec![] - }, - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ].concat(), - ), + .tags(cover_letter_tags), signer, format!("commit 0/{}",commits.len()), ).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 @@ pub mod accept_maintainership; pub mod cli_interactor; pub mod client; +pub mod content_tags; pub mod fetch; pub mod git; pub 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( merge_base, &[clone_url], &[], - )? + git_repo.get_path().ok(), + ) + .await? }; let git_ref_used = git_ref -- cgit v1.2.3