use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc}; use anyhow::{Context, Result, bail}; use nostr::{ event::UnsignedEvent, nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19}, }; use nostr_sdk::{ Event, EventBuilder, EventId, FromBech32, Kind, NostrSigner, PublicKey, Tag, TagKind, TagStandard, hashes::sha1::Hash as Sha1Hash, }; 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, }; pub fn tag_value(event: &Event, tag_name: &str) -> Result { Ok(event .tags .iter() .find(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq(tag_name)) .context(format!("tag '{tag_name}'not present"))? .as_slice()[1] .clone()) } pub fn get_commit_id_from_patch(event: &Event) -> Result { let value = tag_value(event, "commit"); if value.is_ok() { value } else if event.content.starts_with("From ") && event.content.len().gt(&45) { Ok(event.content[5..45].to_string()) } else { bail!("event is not a patch") } } pub fn get_parent_commit_from_patch(event: &Event, git_repo: Option<&Repo>) -> Result { if let Ok(parent) = tag_value(event, "parent-commit") { return Ok(parent); } let metadata = crate::mbox_parser::parse_mbox_patch(&event.content) .context("failed to parse patch for timestamp")?; let timestamp = metadata .committer_timestamp .unwrap_or(metadata.author_timestamp); if let Some(repo) = git_repo { if let Some(best_guess) = repo .find_best_guess_parent_commit(timestamp) .context("failed to find best guess parent commit")? { return Ok(best_guess.to_string()); } } bail!("no parent-commit tag and could not determine best guess parent") } pub fn get_event_root(event: &nostr::Event) -> Result { Ok(EventId::parse( event .tags .iter() .find(|t| t.is_root()) .context("no thread root in event")? .as_slice() .get(1) .unwrap(), )?) } pub fn status_kinds() -> Vec { vec![ Kind::GitStatusOpen, Kind::GitStatusApplied, Kind::GitStatusClosed, Kind::GitStatusDraft, ] } pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); /// NIP-22 comment (kind 1111) — threaded comments on any event. pub const KIND_COMMENT: Kind = Kind::Custom(1111); /// NIP-32 label event (kind 1985) — applies hashtag labels to an existing /// event. Used to add labels to issues, patches and PRs after the fact. pub const KIND_LABEL: Kind = Kind::Custom(1985); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) && event .tags .iter() .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("root")) } pub fn event_is_revision_root(event: &Event) -> bool { (event.kind.eq(&Kind::GitPatch) && event.tags.iter().any(|t| { t.as_slice().len() > 1 && ["revision-root", "root-revision"].contains(&t.as_slice()[1].as_str()) })) || (event.kind.eq(&KIND_PULL_REQUEST) && event .tags .iter() .any(|t| t.as_slice().len() > 1 && t.as_slice()[0].eq("e"))) } pub fn patch_supports_commit_ids(event: &Event) -> bool { if !event.kind.eq(&Kind::GitPatch) { return false; } if event .tags .iter() .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) { return true; } if event .tags .iter() .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("parent-commit")) { return true; } crate::mbox_parser::parse_mbox_patch(&event.content).is_ok() } pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool { [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) && event.tags.iter().any(|t| { t.as_slice().len().gt(&1) && t.as_slice()[0].eq("c") && git2::Oid::from_str(&t.as_slice()[1]).is_ok() }) && event .tags .iter() .any(|t| t.as_slice().len().gt(&1) && t.as_slice()[0].eq("clone")) } #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] pub async fn generate_patch_event( git_repo: &Repo, root_commit: &Sha1Hash, commit: &Sha1Hash, thread_event_id: Option, signer: &Arc, repo_ref: &RepoRef, parent_patch_event_id: Option, series_count: Option<(u64, u64)>, branch_name: Option, root_proposal_id: &Option, mentions: &[nostr::Tag], ) -> Result { let commit_parent = git_repo .get_commit_parent(commit) .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, git_repo .make_patch_from_commit(commit, &series_count) .context(format!("failed to make patch for commit {commit}"))?, ) .tags(patch_tags), signer, if let Some((n, total)) = series_count { format!("commit {n}/{total}") } else { "commit 1/1".to_string() }, ) .await .context("failed to sign event") } #[derive(Debug, PartialEq)] pub enum EventRefType { Root, Reply, Quote, } pub fn event_tag_from_nip19_or_hex( reference: &str, reference_name: &str, ref_type: EventRefType, allow_npub_reference: bool, prompt_for_correction: bool, ) -> Result { let mut bech32 = reference.to_string(); loop { if bech32.is_empty() { bech32 = Interactor::default().input( PromptInputParms::default().with_prompt(format!("{reference_name} reference")), )?; } let marker = match ref_type { EventRefType::Root => Some(Marker::Root), EventRefType::Reply => Some(Marker::Reply), EventRefType::Quote => None, }; if let Ok(nip19) = Nip19::from_bech32(&bech32) { match nip19 { Nip19::Event(n) => { if ref_type == EventRefType::Quote { break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Quote { event_id: n.event_id, relay_url: n.relays.first().cloned(), public_key: None, })); } break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { event_id: n.event_id, relay_url: n.relays.first().cloned(), marker, public_key: None, uppercase: false, })); } Nip19::EventId(id) => { if ref_type == EventRefType::Quote { break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Quote { event_id: id, relay_url: None, public_key: None, })); } break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { event_id: id, relay_url: None, marker, public_key: None, uppercase: false, })); } Nip19::Coordinate(coordinate) => { break Ok(Tag::from_standardized(TagStandard::Coordinate { coordinate: coordinate.coordinate, relay_url: coordinate.relays.first().cloned(), uppercase: false, })); } Nip19::Profile(profile) => { if allow_npub_reference { break Ok(Tag::public_key(profile.public_key)); } } Nip19::Pubkey(public_key) => { if allow_npub_reference { break Ok(Tag::public_key(public_key)); } } _ => {} } } if let Ok(id) = nostr::EventId::from_str(&bech32) { break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { event_id: id, relay_url: None, marker, public_key: None, uppercase: false, })); } if prompt_for_correction { println!("not a valid {reference_name} event reference"); } else { bail!(format!("not a valid {reference_name} event reference")); } bech32 = String::new(); } } #[allow(clippy::too_many_arguments)] pub async fn generate_unsigned_pr_or_update_event( git_repo: &Repo, repo_ref: &RepoRef, signing_public_key: &PublicKey, root_proposal: Option<&Event>, title_description_overide: &Option<(String, String)>, tip: &Sha1Hash, first_commit: &Sha1Hash, 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) { Some(event_to_cover_letter(root_proposal)?) } else { None } } else { None }; let title = if let Some((title, _)) = &title_description_overide { title.clone() } else if let Some(cl) = &root_patch_cover_letter { cl.title.clone() } else { git_repo.get_commit_message_summary(first_commit)? }; let description = if let Some((_, description)) = &title_description_overide { description.clone() } else if let Some(cl) = &root_patch_cover_letter { cl.description.clone() } else { let mut description = git_repo .get_commit_message(first_commit)? .trim() .to_string(); if let Some(remaining_description) = description.strip_prefix(&title) { description = remaining_description.trim().to_string(); } description }; let root_commit = git_repo .get_root_commit() .context("failed to get root commit of the repository")?; let pr_update_specific_tags = |root_proposal: &Event| { vec![ Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), vec![format!("git Pull Request Update")], ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("E")), vec![root_proposal.id], ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("P")), vec![root_proposal.pubkey], ), ] }; let pr_specific_tags = || { [ vec![ Tag::from_standardized(TagStandard::Subject(title.clone())), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), vec![format!("git Pull Request: {}", title.clone())], ), ], if let Some(cl) = &root_patch_cover_letter { vec![ Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("e")), vec![root_proposal.unwrap().id], ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), vec![cl.branch_name_without_id_or_prefix.clone()], ), Tag::public_key(root_proposal.unwrap().pubkey), ] } else if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo) { vec![branch_name_tag] } else { vec![] }, ] .concat() }; let merge_base_tag = if let Some(merge_base) = merge_base { vec![Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-base")), vec![format!("{merge_base}")], )] } else { vec![] }; // 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() } } 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 { if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { if !branch_name.eq("main") && !branch_name.eq("master") && !branch_name.eq("origin/main") && !branch_name.eq("origin/master") { Some(Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), vec![ if let Some(branch_name) = branch_name.strip_prefix("pr/") { branch_name.to_string() } else { branch_name } .chars() .take(60) .collect::(), ], )) } else { None } } else { None } } #[allow(clippy::too_many_lines)] pub async fn generate_cover_letter_and_patch_events( cover_letter_title_description: Option<(String, String)>, git_repo: &Repo, commits: &[Sha1Hash], signer: &Arc, repo_ref: &RepoRef, 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")?; 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!( "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", commits.last().unwrap(), commits.len() )) .tags(cover_letter_tags), signer, format!("commit 0/{}",commits.len()), ).await .context("failed to create cover-letter event")?); } for (i, commit) in commits.iter().enumerate() { events.push( generate_patch_event( git_repo, &root_commit, commit, events.first().map(|event| event.id), signer, repo_ref, events.last().map(|e| e.id), if events.is_empty() && commits.len().eq(&1) { None } else { Some(((i + 1).try_into()?, commits.len().try_into()?)) }, if events.is_empty() { if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { if !branch_name.eq("main") && !branch_name.eq("master") && !branch_name.eq("origin/main") && !branch_name.eq("origin/master") { Some( if let Some(branch_name) = branch_name.strip_prefix("pr/") { branch_name.to_string() } else { branch_name } .chars() .take(60) .collect::(), ) } else { None } } else { None } } else { None }, root_proposal_id, if events.is_empty() { mentions } else { &[] }, ) .await .context("failed to generate patch event")?, ); } Ok(events) } pub struct CoverLetter { pub title: String, pub description: String, pub branch_name_without_id_or_prefix: String, pub event_id: Option, } impl CoverLetter { pub fn get_branch_name_with_pr_prefix_and_shorthand_id(&self) -> Result { Ok(format!( "pr/{}({})", self.branch_name_without_id_or_prefix, &self .event_id .context("proposal root event_id must be know to get it's branch name")? .to_hex() .as_str()[..8], )) } } pub fn event_is_cover_letter(event: &nostr::Event) -> bool { // TODO: look for Subject:[ PATCH 0/n ] but watch out for: // [PATCH v1 0/n ] or // [PATCH subsystem v2 0/n ] event.kind.eq(&Kind::GitPatch) && event .tags .iter() .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("root")) && event .tags .iter() .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("cover-letter")) } pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result { if let Ok(msg) = tag_value(patch, "description") { Ok(msg) } else { let start_index = patch .content .find("] ") .context("event is not formatted as a patch or cover letter")? + 2; let end_index = patch.content[start_index..] .find("\ndiff --git") .unwrap_or(patch.content.len()); Ok(patch.content[start_index..end_index].to_string()) } } pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result { Ok(commit_msg_from_patch(patch)? .split('\n') .collect::>()[0] .to_string()) } pub fn event_to_cover_letter(event: &nostr::Event) -> Result { if !event.kind.eq(&KIND_PULL_REQUEST) && !event_is_patch_set_root(event) { bail!("event is not a patch set root event (root patch or cover letter)") } let title = if event.kind.eq(&KIND_PULL_REQUEST) { tag_value(event, "subject").unwrap_or("untitled".to_owned()) } else { commit_msg_from_patch_oneliner(event)? }; let description = if event.kind.eq(&KIND_PULL_REQUEST) { event.content.clone() } else { commit_msg_from_patch(event)?[title.len()..] .trim() .to_string() }; Ok(CoverLetter { title: title.clone(), description, branch_name_without_id_or_prefix: if let Ok(name) = tag_value(event, "branch-name") { if !name.eq("main") && !name.eq("master") { safe_branch_name_for_pr(&name) } else { safe_branch_name_for_pr(&title) } } else { safe_branch_name_for_pr(&title) }, event_id: Some(event.id), }) } fn safe_branch_name_for_pr(s: &str) -> String { s.replace(' ', "-") .chars() .map(|c| { if c.is_ascii_alphanumeric() || c.eq(&'/') { c } else { '-' } }) .take(60) .collect() } pub fn get_pr_tip_event_or_most_recent_patch_with_ancestors( mut proposal_events: Vec, ) -> Result> { proposal_events.sort_by_key(|e| e.created_at); let youngest = proposal_events.last().context("no proposal events found")?; let events_with_youngest_created_at: Vec<&nostr::Event> = proposal_events .iter() .filter(|p| p.created_at.eq(&youngest.created_at)) .collect(); let mut res = vec![]; let mut event_id_to_search = events_with_youngest_created_at .clone() .iter() .find(|p| { !events_with_youngest_created_at.iter().any(|p2| { if let Ok(reply_to) = get_event_parent_id(p2) { reply_to.eq(&p.id.to_string()) } else { false } }) }) .context("failed to find events_with_youngest_created_at")? .id .to_string(); while let Some(event) = proposal_events .iter() .find(|e| e.id.to_string().eq(&event_id_to_search)) { res.push(event.clone()); if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) || event_is_patch_set_root(event) { break; } event_id_to_search = get_event_parent_id(event).unwrap_or_default(); } Ok(res) } fn get_event_parent_id(event: &nostr::Event) -> Result { Ok(if let Some(reply_tag) = event .tags .iter() .find(|t| t.as_slice().len().gt(&3) && t.as_slice()[3].eq("reply")) { reply_tag } else { event .tags .iter() .find(|t| t.as_slice().len().gt(&3) && t.as_slice()[3].eq("root")) .context("no reply or root e tag present".to_string())? } .as_slice()[1] .clone()) } pub fn is_event_proposal_root_for_branch( e: &Event, branch_name_or_refstr: &str, logged_in_user: Option<&PublicKey>, ) -> Result { let branch_name = branch_name_or_refstr.replace("refs/heads/", ""); Ok(event_to_cover_letter(e).is_ok_and(|cl| { (logged_in_user.is_some_and(|public_key| e.pubkey.eq(public_key)) && branch_name.eq(&format!("pr/{}", cl.branch_name_without_id_or_prefix))) || cl .get_branch_name_with_pr_prefix_and_shorthand_id() .is_ok_and(|s| s.eq(&branch_name)) }) && ( // If we wanted to treat to list Pull Requests that revise a Patch we would do this: // Note: whilst this the the case elsewhere event_is_revision_root is used, there is more to // think about here? // e.kind.eq(&KIND_PULL_REQUEST) || !event_is_revision_root(e) )) } /// Process hashtag labels (`#t` namespace) from a pre-fetched set of kind-1985 /// events. /// /// Labels come from two sources, both subject to the same permission check: /// /// 1. `t` tags on the event itself (self-reported by the event author). /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` /// via a lowercase `e` tag and carry `["L", "#t"]` + /// `["l", "", "#t"]` tags. /// /// A label is only applied when the author of the source event is either the /// author of `event` itself or one of the repository maintainers. /// /// Labels are additive — all valid label events contribute; there is no /// "latest wins" replacement semantics. pub fn process_labels( event: &Event, repo_ref: &RepoRef, label_events: &[Event], ) -> Vec { let is_permitted = |pubkey: &PublicKey| -> bool { pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) }; // 1. Inline `t` tags on the event itself. let mut labels: Vec = if is_permitted(&event.pubkey) { event .tags .iter() .filter(|t| { let s = t.as_slice(); s.len() >= 2 && s[0].eq("t") }) .map(|t| t.as_slice()[1].clone()) .collect() } else { vec![] }; // 2. External NIP-32 kind-1985 label events (`#t` namespace). // // A valid label event must: // - be kind 1985 // - reference `event` via a lowercase `e` tag // - have `["L", "#t"]` (namespace declaration) // - have at least one `["l", "", "#t"]` tag // - be authored by a permitted pubkey let event_id_str = event.id.to_string(); for label_event in label_events { if !label_event.kind.eq(&KIND_LABEL) { continue; } if !is_permitted(&label_event.pubkey) { continue; } // Must reference our event via a lowercase `e` tag. let references_event = label_event.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) }); if !references_event { continue; } // Must declare the `#t` namespace. let has_namespace = label_event.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0].eq("L") && s[1].eq("#t") }); if !has_namespace { continue; } // Collect all `["l", "", "#t"]` labels from this event. for tag in label_event.tags.iter() { let s = tag.as_slice(); if s.len() >= 3 && s[0].eq("l") && s[2].eq("#t") && !s[1].is_empty() { let label = s[1].clone(); if !labels.contains(&label) { labels.push(label); } } } } labels } /// Process the effective subject/title override for `event` from a pre-fetched /// set of kind-1985 events. /// /// Subject overrides use the `#subject` namespace: /// `["L", "#subject"]` + `["l", "", "#subject"]` /// /// Unlike hashtag labels, subject overrides are replaceable-style: only the /// latest authorised event wins, with tiebreak by lexicographically larger /// event ID (consistent with NIP-1 replaceable event semantics). /// /// Only the author of `event` or a repository maintainer may set the subject. /// Returns `None` when no valid subject override exists. pub fn process_subject( event: &Event, repo_ref: &RepoRef, label_events: &[Event], ) -> Option { let is_permitted = |pubkey: &PublicKey| -> bool { pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) }; let event_id_str = event.id.to_string(); // Find the winning subject label event: latest created_at, tiebreak by // lexicographically larger event ID (NIP-1 replaceable event semantics). let winner = label_events .iter() .filter(|le| { if !le.kind.eq(&KIND_LABEL) { return false; } if !is_permitted(&le.pubkey) { return false; } // Must reference our event via a lowercase `e` tag. let references_event = le.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) }); if !references_event { return false; } // Must declare the `#subject` namespace. let has_namespace = le.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0].eq("L") && s[1].eq("#subject") }); if !has_namespace { return false; } // Must have at least one non-empty `["l", "", "#subject"]` tag. le.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() }) }) .max_by(|a, b| { // Primary: newer created_at wins. // Tiebreak: lexicographically larger event ID wins (NIP-1). a.created_at .cmp(&b.created_at) .then_with(|| a.id.to_string().cmp(&b.id.to_string())) })?; // Extract the subject value from the winning event. winner.tags.iter().find_map(|t| { let s = t.as_slice(); if s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() { Some(s[1].clone()) } else { None } }) } /// Compute both the effective hashtag labels and the subject/title override for /// `event` from a pre-fetched set of kind-1985 events. /// /// This is the primary entry point: callers should fetch label events once /// (covering both `#t` and `#subject` namespaces) and pass them here to get /// both results in a single pass. /// /// Returns `(labels, subject_override)` where `subject_override` is `None` /// when no authorised `#subject` label exists. pub fn get_labels_and_subject( event: &Event, repo_ref: &RepoRef, label_events: &[Event], ) -> (Vec, Option) { ( process_labels(event, repo_ref, label_events), process_subject(event, repo_ref, label_events), ) } /// Compatibility wrapper — returns only the hashtag labels. /// /// Prefer [`get_labels_and_subject`] when the subject override is also needed. pub fn get_labels( event: &Event, repo_ref: &RepoRef, label_events: &[Event], ) -> Vec { process_labels(event, repo_ref, label_events) } pub fn get_status( proposal: &Event, repo_ref: &RepoRef, all_status_in_repo: &[Event], all_pr_roots_in_repo: &[Event], ) -> Kind { let get_direct_status = |proposal: &Event| { if let Some(e) = all_status_in_repo .iter() .filter(|e| { status_kinds().contains(&e.kind) && e.tags.iter().any(|t| { t.as_slice().len() > 1 && t.as_slice()[1].eq(&proposal.id.to_string()) }) && (proposal.pubkey.eq(&e.pubkey) || repo_ref.maintainers.contains(&e.pubkey)) }) .collect::>() .first() { e.kind } else { Kind::GitStatusOpen } }; let is_proposal_pr_revision_of_patch = |proposal: &Event, patch: &Event| { proposal.kind.eq(&KIND_PULL_REQUEST) && proposal.tags.clone().into_iter().any(|t| { t.as_slice().len() > 1 && t.as_slice()[0].eq("e") && t.as_slice()[1].eq(&patch.id.to_string()) }) }; let direct_status = get_direct_status(proposal); if direct_status.eq(&Kind::GitStatusClosed) && proposal.kind.eq(&Kind::GitPatch) { if let Some(pr_revision) = all_pr_roots_in_repo .iter() .find(|p| is_proposal_pr_revision_of_patch(p, proposal)) { get_direct_status(pr_revision) } else { direct_status } } else { direct_status } } pub async fn identify_clone_urls_for_oids_from_pr_pr_update_events( oids: Vec<&String>, git_repo: &Repo, repo_ref: &RepoRef, ) -> Result>> { let mut map: HashMap> = HashMap::new(); let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?; for (_, (_, events)) in open_and_draft_proposals { for event in events { if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) { if let Ok(c) = tag_value(&event, "c") { if oids.contains(&&c) { for tag in event.tags.as_slice() { if tag.kind().eq(&nostr::event::TagKind::Clone) { for clone_url in tag.as_slice().iter().skip(1) { map.entry(c.clone()).or_default().push(clone_url.clone()); } } } } } } } } Ok(map) } #[cfg(test)] mod tests { use super::*; mod event_to_cover_letter { use super::*; fn generate_cover_letter(title: &str, description: &str) -> Result { Ok(nostr::event::EventBuilder::new( nostr::event::Kind::GitPatch, format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), ) .tags([ Tag::hashtag("cover-letter"), Tag::hashtag("root"), ], ) .sign_with_keys(&nostr::Keys::generate())?) } #[test] fn basic_title() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? .title, "the title", ); Ok(()) } #[test] fn basic_description() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? .description, "description here", ); Ok(()) } #[test] fn description_trimmed() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter( "the title", " \n \ndescription here\n\n " )?)? .description, "description here", ); Ok(()) } #[test] fn multi_line_description() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter( "the title", "description here\n\nmore here\nmore" )?)? .description, "description here\n\nmore here\nmore", ); Ok(()) } #[test] fn new_lines_in_title_forms_part_of_description() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter( "the title\nwith new line", "description here\n\nmore here\nmore" )?)? .title, "the title", ); assert_eq!( event_to_cover_letter(&generate_cover_letter( "the title\nwith new line", "description here\n\nmore here\nmore" )?)? .description, "with new line\n\ndescription here\n\nmore here\nmore", ); Ok(()) } mod blank_description { use super::*; #[test] fn title_correct() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, "the title", ); Ok(()) } #[test] fn description_is_empty_string() -> Result<()> { assert_eq!( event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, "", ); Ok(()) } } } }