use std::{
borrow::Cow,
collections::HashMap,
convert::TryFrom,
str::FromStr,
sync::Arc,
};
use anyhow::{Context, Result, bail};
use gnostr_asyncgit::{
blockheight::blockheight_sync,
weeble::weeble_sync,
wobble::wobble_sync,
};
use gnostr_asyncgit::sync::{commit::padded_note_id, default_notes_ref, list_notes, NoteInfo, RepoPath};
use nostr::{
event::UnsignedEvent,
nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19},
};
use nostr_sdk::{
Event, EventBuilder, EventId, FromBech32, Kind, Keys, NostrSigner, PublicKey, Tag, TagKind,
TagStandard, Timestamp, hashes::sha1::Hash as Sha1Hash,
};
use crate::{
cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
client::sign_event,
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 fn git_note_event_id(commit_id: &str) -> Result<EventId> {
let padded_commit_id = padded_note_id(commit_id.to_string());
let keys = Keys::parse(&padded_commit_id)
.context("failed to derive keys from padded commit id")?;
Ok(EventId::from_hex(&keys.public_key().to_string())
.context("failed to convert derived public key into event id")?)
}
fn git_note_runtime_values() -> Result<(String, f64, f64)> {
let blockheight = blockheight_sync();
let weeble =
weeble_sync().map_err(|err| anyhow::anyhow!("failed to calculate weeble: {err:?}"))?;
let wobble =
wobble_sync().map_err(|err| anyhow::anyhow!("failed to calculate wobble: {err:?}"))?;
Ok((blockheight, weeble, wobble))
}
pub fn git_note_tags(note: &NoteInfo) -> Result<Vec<Tag>> {
let event_id = git_note_event_id(¬e.annotated_id.to_string())?;
let (blockheight, weeble, wobble) = git_note_runtime_values()?;
let mut tags = vec![
Tag::from_standardized(TagStandard::Event {
event_id,
relay_url: None,
marker: Some(Marker::Root),
public_key: None,
uppercase: false,
}),
Tag::custom(
TagKind::Custom(Cow::Borrowed("commit")),
vec![note.annotated_id.to_string()],
),
];
if let Some(notes_ref) = ¬e.notes_ref {
tags.push(Tag::custom(
TagKind::Custom(Cow::Borrowed("notes-ref")),
vec![notes_ref.clone()],
));
}
tags.push(Tag::custom(
TagKind::Custom(Cow::Borrowed("weeble")),
vec![weeble.to_string()],
));
tags.push(Tag::custom(
TagKind::Custom(Cow::Borrowed("blockheight")),
vec![blockheight],
));
tags.push(Tag::custom(
TagKind::Custom(Cow::Borrowed("wobble")),
vec![wobble.to_string()],
));
Ok(tags)
}
pub async fn generate_git_note_event(
note: &NoteInfo,
signer: &Arc<dyn NostrSigner>,
) -> Result<Event> {
let created_at = Timestamp::from(
u64::try_from(note.committer_time).context("git note committer time must be non-negative")?,
);
println!(
"building git note event as kind GitPatch; NIP-34 metadata is carried in tags for commit {}",
note.annotated_id
);
let event = sign_event(
EventBuilder::new(Kind::GitPatch, note.message.clone())
.tags(git_note_tags(note)?)
.custom_created_at(created_at),
signer,
"git note".to_string(),
)
.await
.context("failed to sign git note event")?;
println!(
"nip34 git note event created: kind={:?} id={} pubkey={} created_at={} nonce_tag={:?}",
event.kind,
event.id.to_hex(),
event.pubkey.to_hex(),
event.created_at,
event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("nonce"))
.map(|tag| tag.as_slice().to_vec())
);
println!(
"nip34 git note event payload: kind={:?} id={} pubkey={} created_at={} content={}",
event.kind,
event.id.to_hex(),
event.pubkey.to_hex(),
event.created_at,
event.content
);
println!("nip34 git note event tags: {:?}", event.tags);
Ok(event)
}
pub async fn generate_git_note_event_with_pow(
note: &NoteInfo,
keys: &Keys,
difficulty: u8,
) -> Result<Event> {
let created_at = Timestamp::from(
u64::try_from(note.committer_time).context("git note committer time must be non-negative")?,
);
let base_tags = git_note_tags(note)?;
let mut nonce: u128 = 0;
loop {
let mut tags = base_tags.clone();
tags.push(Tag::custom(
TagKind::Custom(Cow::Borrowed("nonce")),
vec![nonce.to_string(), difficulty.to_string()],
));
let event = EventBuilder::new(Kind::GitPatch, note.message.clone())
.tags(tags)
.custom_created_at(created_at)
.sign_with_keys(keys)
.context("failed to sign pow git note event")?;
if event.id.to_hex().starts_with('0') {
println!(
"nip34 git note event created: kind={:?} id={} pubkey={} created_at={} nonce_tag={:?}",
event.kind,
event.id.to_hex(),
event.pubkey.to_hex(),
event.created_at,
event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("nonce"))
.map(|tag| tag.as_slice().to_vec())
);
println!(
"nip34 git note event payload: kind={:?} id={} pubkey={} created_at={} content={}",
event.kind,
event.id.to_hex(),
event.pubkey.to_hex(),
event.created_at,
event.content
);
println!("nip34 git note event tags: {:?}", event.tags);
return Ok(event);
}
nonce = nonce.wrapping_add(1);
}
}
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 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();
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(
[
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)?,
),
],
]
.concat(),
),
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 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],
) -> 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![]
};
Ok(
if root_proposal.is_some() && root_patch_cover_letter.is_none() {
EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "")
} else {
EventBuilder::new(KIND_PULL_REQUEST, description)
}
.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(),
]
.concat(),
)
.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 root_commit = git_repo
.get_root_commit()
.context("failed to get root commit of the repository")?;
let repo_path = RepoPath::Path(git_repo.get_path()?.to_path_buf());
let notes_ref = default_notes_ref(&repo_path)?;
let notes = list_notes(&repo_path, Some(notes_ref.as_str()))?;
let notes_by_commit = notes
.into_iter()
.map(|note| (note.annotated_id.to_string(), note))
.collect::<HashMap<_, _>>();
let mut events = vec![];
if let Some((title, description)) = cover_letter_title_description {
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(
[
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(),
].concat(),
),
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")?,
);
if let Some(note) = notes_by_commit.get(&commit.to_string()) {
events.push(generate_git_note_event(note, signer).await?);
}
}
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 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(())
}
}
}
mod git_notes {
use super::*;
use std::sync::Arc;
fn note_fixture() -> NoteInfo {
let committer_time = nostr_sdk::Timestamp::now().as_u64() as i64;
NoteInfo {
note_id: git2::Oid::from_str("89abcdef0123456789abcdef0123456789abcdef")
.expect("valid note id"),
annotated_id: git2::Oid::from_str("0123456789abcdef0123456789abcdef01234567")
.expect("valid commit id"),
notes_ref: Some("refs/notes/commits".to_string()),
message: "nip34:git note protocol example:deterministically linked git note".to_string(),
author: "author".to_string(),
committer: "committer".to_string(),
committer_time,
}
}
#[test]
#[ignore]
fn padded_commit_hash_becomes_the_link_event_id() -> Result<()> {
let note = note_fixture();
let event_id = git_note_event_id(¬e.annotated_id.to_string())?;
assert_eq!(
event_id.to_hex(),
format!("{:0>64}", note.annotated_id.to_string())
);
Ok(())
}
#[test]
#[ignore]
fn git_note_tags_reference_commit_and_notes_ref() -> Result<()> {
let note = note_fixture();
let commit_id = note.annotated_id.to_string();
let padded_commit_id = format!("{:0>64}", commit_id);
let tags = git_note_tags(¬e)?;
let (blockheight, weeble, wobble) = git_note_runtime_values()?;
let weeble_str = weeble.to_string();
let wobble_str = wobble.to_string();
let e_tag = tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("e"))
.context("missing e tag")?;
assert_eq!(e_tag.as_slice()[1], padded_commit_id);
assert!(e_tag.is_root());
assert!(tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("commit")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(commit_id.as_str())
}));
assert!(tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("notes-ref")
&& tag.as_slice().get(1).map(|s| s.as_str())
== Some("refs/notes/commits")
}));
assert!(tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("weeble")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(weeble_str.as_str())
}));
assert!(tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("blockheight")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(blockheight.as_str())
}));
assert!(tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("wobble")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(wobble_str.as_str())
}));
Ok(())
}
#[tokio::test]
async fn generate_git_note_event_uses_the_note_message() -> Result<()> {
let note = note_fixture();
let signer: Arc<dyn NostrSigner> = Arc::new(nostr_sdk::Keys::generate());
let event = generate_git_note_event(¬e, &signer).await?;
println!("git note event: {event:#?}");
assert_eq!(event.kind, Kind::GitPatch);
assert_eq!(
event.content,
"nip34:git note protocol example:deterministically linked git note"
);
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("e")));
let (blockheight, weeble, wobble) = git_note_runtime_values()?;
let weeble_str = weeble.to_string();
let wobble_str = wobble.to_string();
assert!(event.tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("weeble")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(weeble_str.as_str())
}));
assert!(event.tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("blockheight")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some(blockheight.as_str())
}));
Ok(())
}
#[tokio::test]
#[ignore]
async fn padded_commit_hex_can_seed_the_git_note_signer() -> Result<()> {
let note = note_fixture();
let padded_commit = format!("{:0>64}", note.annotated_id);
let signer: Arc<dyn NostrSigner> = Arc::new(nostr_sdk::Keys::parse(&padded_commit)?);
let event = generate_git_note_event(¬e, &signer).await?;
println!("git note event seeded from padded commit: {event:#?}");
assert_eq!(event.kind, Kind::GitPatch);
assert_eq!(event.content, note.message);
assert_eq!(
event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("e"))
.expect("e tag")
.as_slice()[1],
padded_commit
);
Ok(())
}
}
}