diff options
| -rw-r--r-- | src/bin/ngit/sub_commands/comment.rs | 66 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/issue_create.rs | 10 | ||||
| -rw-r--r-- | src/lib/content_tags.rs | 389 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 519 | ||||
| -rw-r--r-- | src/lib/mod.rs | 1 | ||||
| -rw-r--r-- | src/lib/push.rs | 4 |
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 | }; |
| 9 | use nostr::{EventBuilder, Tag, nips::nip19::Nip19}; | 10 | use 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 @@ | |||
| 1 | use anyhow::{Context, Result, bail}; | 1 | use anyhow::{Context, Result, bail}; |
| 2 | use ngit::client::{Params, send_events, sign_event}; | 2 | use ngit::{ |
| 3 | client::{Params, send_events, sign_event}, | ||
| 4 | content_tags::{dedup_tags, tags_from_content}, | ||
| 5 | }; | ||
| 3 | use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event}; | 6 | use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event}; |
| 4 | use nostr_sdk::Kind; | 7 | use 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 | |||
| 15 | use std::{collections::HashSet, path::Path}; | ||
| 16 | |||
| 17 | use anyhow::Result; | ||
| 18 | use nostr::{FromBech32, Tag, nips::nip19::Nip19}; | ||
| 19 | use nostr_sdk::EventId; | ||
| 20 | |||
| 21 | use crate::client::get_events_from_local_cache; | ||
| 22 | |||
| 23 | /// Regex-free extraction of every `nostr:<bech32>` token from `content`. | ||
| 24 | fn 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. | ||
| 47 | pub 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. | ||
| 156 | pub 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. | ||
| 205 | async 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. | ||
| 220 | fn 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)] | ||
| 245 | mod 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 @@ | |||
| 1 | use std::{collections::HashMap, str::FromStr, sync::Arc}; | 1 | use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc}; |
| 2 | 2 | ||
| 3 | use anyhow::{Context, Result, bail}; | 3 | use anyhow::{Context, Result, bail}; |
| 4 | use nostr::{ | 4 | use nostr::{ |
| @@ -13,6 +13,7 @@ use nostr_sdk::{ | |||
| 13 | use crate::{ | 13 | use 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)] |
| 423 | pub fn generate_unsigned_pr_or_update_event( | 432 | pub 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 | ||
| 589 | fn make_branch_name_tag_from_check_out_branch(git_repo: &Repo) -> Option<Tag> { | 609 | fn 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 @@ | |||
| 1 | pub mod accept_maintainership; | 1 | pub mod accept_maintainership; |
| 2 | pub mod cli_interactor; | 2 | pub mod cli_interactor; |
| 3 | pub mod client; | 3 | pub mod client; |
| 4 | pub mod content_tags; | ||
| 4 | pub mod fetch; | 5 | pub mod fetch; |
| 5 | pub mod git; | 6 | pub mod git; |
| 6 | pub mod git_events; | 7 | 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( | |||
| 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 |