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<String> {
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<String> {
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<String> {
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<EventId> {
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<Kind> {
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);
pub const KIND_COMMENT: Kind = Kind::Custom(1111);
pub const KIND_LABEL: Kind = Kind::Custom(1985);
pub const KIND_COVER_NOTE: Kind = Kind::Custom(1624);
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<nostr::EventId>,
signer: &Arc<dyn NostrSigner>,
repo_ref: &RepoRef,
parent_patch_event_id: Option<nostr::EventId>,
series_count: Option<(u64, u64)>,
branch_name: Option<String>,
root_proposal_id: &Option<String>,
mentions: &[nostr::Tag],
) -> Result<nostr::Event> {
let commit_parent = git_repo
.get_commit_parent(commit)
.context("failed to get parent commit")?;
let relay_hint = repo_ref.relays.first().cloned();
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>>(),
vec![
Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
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"),
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![]
},
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::<String>()],
)]
} else {
vec![]
}
} else {
vec![]
},
repo_ref
.maintainers
.iter()
.map(|pk| Tag::public_key(*pk))
.collect(),
vec![
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
vec![commit.to_string()],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
vec![commit_parent.to_string()],
),
Tag::custom(
TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
vec![
git_repo
.extract_commit_pgp_signature(commit)
.unwrap_or_default(),
],
),
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)?,
),
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<nostr::Tag> {
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<UnsignedEvent> {
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![]
};
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::<Vec<Tag>>(),
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::<Vec<String>>(),
),
],
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<Tag> {
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::<String>(),
],
))
} 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<dyn NostrSigner>,
repo_ref: &RepoRef,
root_proposal_id: &Option<String>,
mentions: &[nostr::Tag],
) -> Result<Vec<nostr::Event>> {
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 {
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>>(),
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"),
event_tag_from_nip19_or_hex(
&event_ref,
"proposal",
EventRefType::Reply,
false,
false,
)?,
]
} else {
vec![Tag::hashtag("root")]
},
mentions.to_vec(),
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::<String>(),
)
} 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<nostr::EventId>,
}
impl CoverLetter {
pub fn get_branch_name_with_pr_prefix_and_shorthand_id(&self) -> Result<String> {
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 {
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<String> {
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<String> {
Ok(commit_msg_from_patch(patch)?
.split('\n')
.collect::<Vec<&str>>()[0]
.to_string())
}
pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
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<nostr::Event>,
) -> Result<Vec<nostr::Event>> {
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<String> {
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<bool> {
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))
}) && (
!event_is_revision_root(e)
))
}
pub fn process_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> {
let is_permitted = |pubkey: &PublicKey| -> bool {
pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey)
};
let mut labels: Vec<String> = 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![]
};
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;
}
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;
}
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;
}
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
}
pub fn process_subject(
event: &Event,
repo_ref: &RepoRef,
label_events: &[Event],
) -> Option<String> {
let is_permitted = |pubkey: &PublicKey| -> bool {
pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey)
};
let event_id_str = event.id.to_string();
let winner = label_events
.iter()
.filter(|le| {
if !le.kind.eq(&KIND_LABEL) {
return false;
}
if !is_permitted(&le.pubkey) {
return false;
}
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;
}
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;
}
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| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})?;
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
}
})
}
pub fn get_labels_and_subject(
event: &Event,
repo_ref: &RepoRef,
label_events: &[Event],
) -> (Vec<String>, Option<String>) {
(
process_labels(event, repo_ref, label_events),
process_subject(event, repo_ref, label_events),
)
}
pub fn get_labels(event: &Event, repo_ref: &RepoRef, label_events: &[Event]) -> Vec<String> {
process_labels(event, repo_ref, label_events)
}
pub fn process_cover_note(
event: &Event,
repo_ref: &RepoRef,
cover_note_events: &[Event],
) -> Option<(Event, bool)> {
let is_permitted = |pubkey: &PublicKey| -> bool {
pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey)
};
let event_id_str = event.id.to_string();
let winner = cover_note_events
.iter()
.filter(|cn| {
if !cn.kind.eq(&KIND_COVER_NOTE) {
return false;
}
if !is_permitted(&cn.pubkey) {
return false;
}
cn.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str)
})
})
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})?;
let by_different_author = winner.pubkey != event.pubkey;
Some((winner.clone(), by_different_author))
}
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::<Vec<&nostr::Event>>()
.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<HashMap<String, Vec<String>>> {
let mut map: HashMap<String, Vec<String>> = 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<nostr::Event> {
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(())
}
}
}
}