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>2024-09-04 11:32:05 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 14:23:54 +0100
commit771f944af447c202eba045936a36dee71ab797ac (patch)
treee691de4ebc8dde7ac4855e139881ff923bc254ce /src/lib/git_events.rs
parent949c6459aa7683453a7160423b689ceadb08954b (diff)
refactor: fix imports, etc based on restructure
move some functions out of ngit and into lib/mod and lib/git_events remove MockConnect from binaries so it is only used in the library. this was done: * mainly because automocks were not being imported from lib into each binary * but also because the these functions were being tested with MockConnect
Diffstat (limited to 'src/lib/git_events.rs')
-rw-r--r--src/lib/git_events.rs692
1 files changed, 692 insertions, 0 deletions
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
new file mode 100644
index 0000000..8689b33
--- /dev/null
+++ b/src/lib/git_events.rs
@@ -0,0 +1,692 @@
1use std::str::FromStr;
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19};
5use nostr_sdk::{
6 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, FromBech32, Kind, Tag, TagKind,
7 TagStandard, UncheckedUrl,
8};
9use nostr_signer::NostrSigner;
10
11use crate::{
12 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
13 client::sign_event,
14 git::{Repo, RepoActions},
15 repo_ref::RepoRef,
16};
17
18pub fn tag_value(event: &Event, tag_name: &str) -> Result<String> {
19 Ok(event
20 .tags
21 .iter()
22 .find(|t| t.as_vec()[0].eq(tag_name))
23 .context(format!("tag '{tag_name}'not present"))?
24 .as_vec()[1]
25 .clone())
26}
27
28pub fn get_commit_id_from_patch(event: &Event) -> Result<String> {
29 let value = tag_value(event, "commit");
30
31 if value.is_ok() {
32 value
33 } else if event.content.starts_with("From ") && event.content.len().gt(&45) {
34 Ok(event.content[5..45].to_string())
35 } else {
36 bail!("event is not a patch")
37 }
38}
39
40pub fn status_kinds() -> Vec<Kind> {
41 vec![
42 Kind::GitStatusOpen,
43 Kind::GitStatusApplied,
44 Kind::GitStatusClosed,
45 Kind::GitStatusDraft,
46 ]
47}
48
49pub fn event_is_patch_set_root(event: &Event) -> bool {
50 event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
51}
52
53pub fn event_is_revision_root(event: &Event) -> bool {
54 event.kind.eq(&Kind::GitPatch)
55 && event
56 .tags()
57 .iter()
58 .any(|t| t.as_vec()[1].eq("revision-root"))
59}
60
61pub fn patch_supports_commit_ids(event: &Event) -> bool {
62 event.kind.eq(&Kind::GitPatch)
63 && event
64 .tags()
65 .iter()
66 .any(|t| t.as_vec()[0].eq("commit-pgp-sig"))
67}
68
69#[allow(clippy::too_many_arguments)]
70#[allow(clippy::too_many_lines)]
71pub async fn generate_patch_event(
72 git_repo: &Repo,
73 root_commit: &Sha1Hash,
74 commit: &Sha1Hash,
75 thread_event_id: Option<nostr::EventId>,
76 signer: &nostr_sdk::NostrSigner,
77 repo_ref: &RepoRef,
78 parent_patch_event_id: Option<nostr::EventId>,
79 series_count: Option<(u64, u64)>,
80 branch_name: Option<String>,
81 root_proposal_id: &Option<String>,
82 mentions: &[nostr::Tag],
83) -> Result<nostr::Event> {
84 let commit_parent = git_repo
85 .get_commit_parent(commit)
86 .context("failed to get parent commit")?;
87 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
88
89 sign_event(
90 EventBuilder::new(
91 nostr::event::Kind::GitPatch,
92 git_repo
93 .make_patch_from_commit(commit, &series_count)
94 .context(format!("cannot make patch for commit {commit}"))?,
95 [
96 repo_ref
97 .maintainers
98 .iter()
99 .map(|m| {
100 Tag::coordinate(Coordinate {
101 kind: nostr::Kind::GitRepoAnnouncement,
102 public_key: *m,
103 identifier: repo_ref.identifier.to_string(),
104 relays: repo_ref.relays.clone(),
105 })
106 })
107 .collect::<Vec<Tag>>(),
108 vec![
109 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
110 // commit id reference is a trade-off. its now
111 // unclear which one is the root commit id but it
112 // enables easier location of code comments againt
113 // code that makes it into the main branch, assuming
114 // the commit id is correct
115 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
116 Tag::custom(
117 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
118 vec![format!(
119 "git patch: {}",
120 git_repo
121 .get_commit_message_summary(commit)
122 .unwrap_or_default()
123 )],
124 ),
125 ],
126 if let Some(thread_event_id) = thread_event_id {
127 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
128 event_id: thread_event_id,
129 relay_url: relay_hint.clone(),
130 marker: Some(Marker::Root),
131 public_key: None,
132 })]
133 } else if let Some(event_ref) = root_proposal_id.clone() {
134 vec![
135 Tag::hashtag("root"),
136 Tag::hashtag("revision-root"),
137 // TODO check if id is for a root proposal (perhaps its for an issue?)
138 event_tag_from_nip19_or_hex(
139 &event_ref,
140 "proposal",
141 Marker::Reply,
142 false,
143 false,
144 )?,
145 ]
146 } else {
147 vec![Tag::hashtag("root")]
148 },
149 mentions.to_vec(),
150 if let Some(id) = parent_patch_event_id {
151 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
152 event_id: id,
153 relay_url: relay_hint.clone(),
154 marker: Some(Marker::Reply),
155 public_key: None,
156 })]
157 } else {
158 vec![]
159 },
160 // see comment on branch names in cover letter event creation
161 if let Some(branch_name) = branch_name {
162 if thread_event_id.is_none() {
163 vec![Tag::custom(
164 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
165 vec![branch_name.to_string()],
166 )]
167 } else {
168 vec![]
169 }
170 } else {
171 vec![]
172 },
173 // whilst it is in nip34 draft to tag the maintainers
174 // I'm not sure it is a good idea because if they are
175 // interested in all patches then their specialised
176 // client should subscribe to patches tagged with the
177 // repo reference. maintainers of large repos will not
178 // be interested in every patch.
179 repo_ref
180 .maintainers
181 .iter()
182 .map(|pk| Tag::public_key(*pk))
183 .collect(),
184 vec![
185 // a fallback is now in place to extract this from the patch
186 Tag::custom(
187 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
188 vec![commit.to_string()],
189 ),
190 // this is required as patches cannot be relied upon to include the 'base
191 // commit'
192 Tag::custom(
193 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
194 vec![commit_parent.to_string()],
195 ),
196 // this is required to ensure the commit id matches
197 Tag::custom(
198 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
199 vec![
200 git_repo
201 .extract_commit_pgp_signature(commit)
202 .unwrap_or_default(),
203 ],
204 ),
205 // removing description tag will not cause anything to break
206 Tag::from_standardized(nostr_sdk::TagStandard::Description(
207 git_repo.get_commit_message(commit)?.to_string(),
208 )),
209 Tag::custom(
210 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
211 git_repo.get_commit_author(commit)?,
212 ),
213 // this is required to ensure the commit id matches
214 Tag::custom(
215 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
216 git_repo.get_commit_comitter(commit)?,
217 ),
218 ],
219 ]
220 .concat(),
221 ),
222 signer,
223 )
224 .await
225 .context("failed to sign event")
226}
227
228pub fn event_tag_from_nip19_or_hex(
229 reference: &str,
230 reference_name: &str,
231 marker: Marker,
232 allow_npub_reference: bool,
233 prompt_for_correction: bool,
234) -> Result<nostr::Tag> {
235 let mut bech32 = reference.to_string();
236 loop {
237 if bech32.is_empty() {
238 bech32 = Interactor::default().input(
239 PromptInputParms::default().with_prompt(&format!("{reference_name} reference")),
240 )?;
241 }
242 if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) {
243 match nip19 {
244 Nip19::Event(n) => {
245 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
246 event_id: n.event_id,
247 relay_url: n.relays.first().map(UncheckedUrl::new),
248 marker: Some(marker),
249 public_key: None,
250 }));
251 }
252 Nip19::EventId(id) => {
253 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
254 event_id: id,
255 relay_url: None,
256 marker: Some(marker),
257 public_key: None,
258 }));
259 }
260 Nip19::Coordinate(coordinate) => {
261 break Ok(Tag::coordinate(coordinate));
262 }
263 Nip19::Profile(profile) => {
264 if allow_npub_reference {
265 break Ok(Tag::public_key(profile.public_key));
266 }
267 }
268 Nip19::Pubkey(public_key) => {
269 if allow_npub_reference {
270 break Ok(Tag::public_key(public_key));
271 }
272 }
273 _ => {}
274 }
275 }
276 if let Ok(id) = nostr::EventId::from_str(&bech32) {
277 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
278 event_id: id,
279 relay_url: None,
280 marker: Some(marker),
281 public_key: None,
282 }));
283 }
284 if prompt_for_correction {
285 println!("not a valid {reference_name} event reference");
286 } else {
287 bail!(format!("not a valid {reference_name} event reference"));
288 }
289
290 bech32 = String::new();
291 }
292}
293
294#[allow(clippy::too_many_lines)]
295pub async fn generate_cover_letter_and_patch_events(
296 cover_letter_title_description: Option<(String, String)>,
297 git_repo: &Repo,
298 commits: &[Sha1Hash],
299 signer: &NostrSigner,
300 repo_ref: &RepoRef,
301 root_proposal_id: &Option<String>,
302 mentions: &[nostr::Tag],
303) -> Result<Vec<nostr::Event>> {
304 let root_commit = git_repo
305 .get_root_commit()
306 .context("failed to get root commit of the repository")?;
307
308 let mut events = vec![];
309
310 if let Some((title, description)) = cover_letter_title_description {
311 events.push(sign_event(EventBuilder::new(
312 nostr::event::Kind::GitPatch,
313 format!(
314 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
315 commits.last().unwrap(),
316 commits.len()
317 ),
318 [
319 repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate {
320 kind: nostr::Kind::GitRepoAnnouncement,
321 public_key: *m,
322 identifier: repo_ref.identifier.to_string(),
323 relays: repo_ref.relays.clone(),
324 })).collect::<Vec<Tag>>(),
325 vec![
326 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
327 Tag::hashtag("cover-letter"),
328 Tag::custom(
329 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
330 vec![format!("git patch cover letter: {}", title.clone())],
331 ),
332 ],
333 if let Some(event_ref) = root_proposal_id.clone() {
334 vec![
335 Tag::hashtag("root"),
336 Tag::hashtag("revision-root"),
337 // TODO check if id is for a root proposal (perhaps its for an issue?)
338 event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?,
339 ]
340 } else {
341 vec![
342 Tag::hashtag("root"),
343 ]
344 },
345 mentions.to_vec(),
346 // this is not strictly needed but makes for prettier branch names
347 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
348 // a change like this, or the removal of this tag will require the actual branch name to be tracked
349 // so pulling and pushing still work
350 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
351 if !branch_name.eq("main")
352 && !branch_name.eq("master")
353 && !branch_name.eq("origin/main")
354 && !branch_name.eq("origin/master")
355 {
356 vec![
357 Tag::custom(
358 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
359 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
360 branch_name.to_string()
361 } else {
362 branch_name
363 }],
364 ),
365 ]
366 }
367 else { vec![] }
368 } else {
369 vec![]
370 },
371 repo_ref.maintainers
372 .iter()
373 .map(|pk| Tag::public_key(*pk))
374 .collect(),
375 ].concat(),
376 ), signer).await
377 .context("failed to create cover-letter event")?);
378 }
379
380 for (i, commit) in commits.iter().enumerate() {
381 events.push(
382 generate_patch_event(
383 git_repo,
384 &root_commit,
385 commit,
386 events.first().map(|event| event.id),
387 signer,
388 repo_ref,
389 events.last().map(nostr::Event::id),
390 if events.is_empty() && commits.len().eq(&1) {
391 None
392 } else {
393 Some(((i + 1).try_into()?, commits.len().try_into()?))
394 },
395 if events.is_empty() {
396 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
397 if !branch_name.eq("main")
398 && !branch_name.eq("master")
399 && !branch_name.eq("origin/main")
400 && !branch_name.eq("origin/master")
401 {
402 Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") {
403 branch_name.to_string()
404 } else {
405 branch_name
406 })
407 } else {
408 None
409 }
410 } else {
411 None
412 }
413 } else {
414 None
415 },
416 root_proposal_id,
417 if events.is_empty() { mentions } else { &[] },
418 )
419 .await
420 .context("failed to generate patch event")?,
421 );
422 }
423 Ok(events)
424}
425
426pub struct CoverLetter {
427 pub title: String,
428 pub description: String,
429 pub branch_name: String,
430 pub event_id: Option<nostr::EventId>,
431}
432
433impl CoverLetter {
434 pub fn get_branch_name(&self) -> Result<String> {
435 Ok(format!(
436 "pr/{}({})",
437 self.branch_name,
438 &self
439 .event_id
440 .context("proposal root event_id must be know to get it's branch name")?
441 .to_hex()
442 .as_str()[..8],
443 ))
444 }
445}
446pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
447 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
448 // [PATCH v1 0/n ] or
449 // [PATCH subsystem v2 0/n ]
450 event.kind.eq(&Kind::GitPatch)
451 && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
452 && event
453 .tags()
454 .iter()
455 .any(|t| t.as_vec()[1].eq("cover-letter"))
456}
457
458pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> {
459 if let Ok(msg) = tag_value(patch, "description") {
460 Ok(msg)
461 } else {
462 let start_index = patch
463 .content
464 .find("] ")
465 .context("event is not formatted as a patch or cover letter")?
466 + 2;
467 let end_index = patch.content[start_index..]
468 .find("\ndiff --git")
469 .unwrap_or(patch.content.len());
470 Ok(patch.content[start_index..end_index].to_string())
471 }
472}
473
474pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
475 Ok(commit_msg_from_patch(patch)?
476 .split('\n')
477 .collect::<Vec<&str>>()[0]
478 .to_string())
479}
480
481pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
482 if !event_is_patch_set_root(event) {
483 bail!("event is not a patch set root event (root patch or cover letter)")
484 }
485
486 let title = commit_msg_from_patch_oneliner(event)?;
487 let full = commit_msg_from_patch(event)?;
488 let description = full[title.len()..].trim().to_string();
489
490 Ok(CoverLetter {
491 title: title.clone(),
492 description,
493 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
494 branch_name: if let Ok(name) = match tag_value(event, "branch-name") {
495 Ok(name) => {
496 if !name.eq("main") && !name.eq("master") {
497 Ok(name)
498 } else {
499 Err(())
500 }
501 }
502 _ => Err(()),
503 } {
504 name
505 } else {
506 let s = title
507 .replace(' ', "-")
508 .chars()
509 .map(|c| {
510 if c.is_ascii_alphanumeric() || c.eq(&'/') {
511 c
512 } else {
513 '-'
514 }
515 })
516 .collect();
517 s
518 },
519 event_id: Some(event.id()),
520 })
521}
522
523pub fn get_most_recent_patch_with_ancestors(
524 mut patches: Vec<nostr::Event>,
525) -> Result<Vec<nostr::Event>> {
526 patches.sort_by_key(|e| e.created_at);
527
528 let youngest_patch = patches.last().context("no patches found")?;
529
530 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches
531 .iter()
532 .filter(|p| p.created_at.eq(&youngest_patch.created_at))
533 .collect();
534
535 let mut res = vec![];
536
537 let mut event_id_to_search = patches_with_youngest_created_at
538 .clone()
539 .iter()
540 .find(|p| {
541 !patches_with_youngest_created_at.iter().any(|p2| {
542 if let Ok(reply_to) = get_event_parent_id(p2) {
543 reply_to.eq(&p.id.to_string())
544 } else {
545 false
546 }
547 })
548 })
549 .context("cannot find patches_with_youngest_created_at")?
550 .id
551 .to_string();
552
553 while let Some(event) = patches
554 .iter()
555 .find(|e| e.id.to_string().eq(&event_id_to_search))
556 {
557 res.push(event.clone());
558 if event_is_patch_set_root(event) {
559 break;
560 }
561 event_id_to_search = get_event_parent_id(event).unwrap_or_default();
562 }
563 Ok(res)
564}
565
566fn get_event_parent_id(event: &nostr::Event) -> Result<String> {
567 Ok(if let Some(reply_tag) = event
568 .tags
569 .iter()
570 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply"))
571 {
572 reply_tag
573 } else {
574 event
575 .tags
576 .iter()
577 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root"))
578 .context("no reply or root e tag present".to_string())?
579 }
580 .as_vec()[1]
581 .clone())
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 mod event_to_cover_letter {
589 use super::*;
590
591 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
592 Ok(nostr::event::EventBuilder::new(
593 nostr::event::Kind::GitPatch,
594 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
595 [
596 Tag::hashtag("cover-letter"),
597 Tag::hashtag("root"),
598 ],
599 )
600 .to_event(&nostr::Keys::generate())?)
601 }
602
603 #[test]
604 fn basic_title() -> Result<()> {
605 assert_eq!(
606 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
607 .title,
608 "the title",
609 );
610 Ok(())
611 }
612
613 #[test]
614 fn basic_description() -> Result<()> {
615 assert_eq!(
616 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
617 .description,
618 "description here",
619 );
620 Ok(())
621 }
622
623 #[test]
624 fn description_trimmed() -> Result<()> {
625 assert_eq!(
626 event_to_cover_letter(&generate_cover_letter(
627 "the title",
628 " \n \ndescription here\n\n "
629 )?)?
630 .description,
631 "description here",
632 );
633 Ok(())
634 }
635
636 #[test]
637 fn multi_line_description() -> Result<()> {
638 assert_eq!(
639 event_to_cover_letter(&generate_cover_letter(
640 "the title",
641 "description here\n\nmore here\nmore"
642 )?)?
643 .description,
644 "description here\n\nmore here\nmore",
645 );
646 Ok(())
647 }
648
649 #[test]
650 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
651 assert_eq!(
652 event_to_cover_letter(&generate_cover_letter(
653 "the title\nwith new line",
654 "description here\n\nmore here\nmore"
655 )?)?
656 .title,
657 "the title",
658 );
659 assert_eq!(
660 event_to_cover_letter(&generate_cover_letter(
661 "the title\nwith new line",
662 "description here\n\nmore here\nmore"
663 )?)?
664 .description,
665 "with new line\n\ndescription here\n\nmore here\nmore",
666 );
667 Ok(())
668 }
669
670 mod blank_description {
671 use super::*;
672
673 #[test]
674 fn title_correct() -> Result<()> {
675 assert_eq!(
676 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
677 "the title",
678 );
679 Ok(())
680 }
681
682 #[test]
683 fn description_is_empty_string() -> Result<()> {
684 assert_eq!(
685 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
686 "",
687 );
688 Ok(())
689 }
690 }
691 }
692}