upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/git_events.rs
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/lib/git_events.rs
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/lib/git_events.rs')
-rw-r--r--src/lib/git_events.rs519
1 files changed, 280 insertions, 239 deletions
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