use anyhow::{Context, Result, bail};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{Harness, PublishPatchSeriesOpts, PublishRepoOpts, Repo};
const COVER_LETTER_TITLE: &str = "exampletitle";
const COVER_LETTER_DESCRIPTION: &str = "exampledescription";
struct IssueSnapshot {
cover_letter: Event,
issue_event_id: EventId,
}
#[fixture]
async fn issue_snapshot() -> IssueSnapshot {
capture_issue_snapshot()
.await
.expect("send_patch_in_reply_to::issue_snapshot: capture failed")
}
async fn capture_issue_snapshot() -> Result<IssueSnapshot> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (publisher, published) = harness.publish_repo(PublishRepoOpts::default()).await?;
let issue_event_id = ngit_issue_create(&publisher, "test issue", "for in-reply-to").await?;
let issue_id_hex = issue_event_id.to_hex();
let series = harness
.publish_patch_series(
&published,
PublishPatchSeriesOpts {
commits: vec![
("t3.md".into(), "some content\n".into()),
("t4.md".into(), "some content\n".into()),
],
cover_letter: Some((COVER_LETTER_TITLE.into(), COVER_LETTER_DESCRIPTION.into())),
in_reply_to: vec![issue_id_hex],
..Default::default()
},
)
.await?;
let cover_letter = series
.cover_letter_event
.clone()
.context("contributor patch series did not produce a cover letter")?;
Ok(IssueSnapshot {
cover_letter,
issue_event_id,
})
}
#[derive(Debug, Clone, Copy)]
enum IssueCase {
QTagsIssue,
NotTaggedAsRevision,
}
#[rstest]
#[case::q_tags_issue(IssueCase::QTagsIssue)]
#[case::not_tagged_as_revision(IssueCase::NotTaggedAsRevision)]
#[tokio::test]
async fn in_reply_to_issue(
#[future] issue_snapshot: IssueSnapshot,
#[case] case: IssueCase,
) -> Result<()> {
let s = issue_snapshot.await;
match case {
IssueCase::QTagsIssue => {
let q_tags = tag_values(&s.cover_letter, "q");
let want = s.issue_event_id.to_hex();
assert!(
q_tags.iter().any(|v| v == &want),
"cover letter should carry `q <issue_id>`; q tags were {q_tags:?}, \
expected to contain {want}",
);
}
IssueCase::NotTaggedAsRevision => {
let t_values = tag_values(&s.cover_letter, "t");
assert!(
!t_values
.iter()
.any(|v| v == "revision-root" || v == "root-revision"),
"cover letter must not carry `t revision-root` when --in-reply-to is an issue \
rather than a proposal root; t tags were {t_values:?}",
);
}
}
Ok(())
}
#[tokio::test]
async fn in_reply_to_npub_and_nprofile_appear_as_p_tags() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_publisher, published) = harness.publish_repo(PublishRepoOpts::default()).await?;
let npub_target = Keys::generate();
let nprofile_target = Keys::generate();
let npub_string = npub_target
.public_key()
.to_bech32()
.context("encoding npub")?;
let nprofile_string = Nip19Profile {
public_key: nprofile_target.public_key(),
relays: vec![RelayUrl::parse("wss://example.invalid")?],
}
.to_bech32()
.context("encoding nprofile")?;
let series = harness
.publish_patch_series(
&published,
PublishPatchSeriesOpts {
commits: vec![
("t3.md".into(), "some content\n".into()),
("t4.md".into(), "some content\n".into()),
],
cover_letter: Some((COVER_LETTER_TITLE.into(), COVER_LETTER_DESCRIPTION.into())),
in_reply_to: vec![npub_string.clone(), nprofile_string.clone()],
..Default::default()
},
)
.await?;
let cover_letter = series
.cover_letter_event
.clone()
.context("contributor patch series did not produce a cover letter")?;
let p_tags = tag_values(&cover_letter, "p");
let want_npub_hex = npub_target.public_key().to_hex();
let want_nprofile_hex = nprofile_target.public_key().to_hex();
assert!(
p_tags.iter().any(|v| v == &want_npub_hex),
"cover letter should carry `p <pubkey>` for the npub mention; p tags were {p_tags:?}, \
expected to contain {want_npub_hex}",
);
assert!(
p_tags.iter().any(|v| v == &want_nprofile_hex),
"cover letter should carry `p <pubkey>` for the nprofile mention; p tags were {p_tags:?}, \
expected to contain {want_nprofile_hex}",
);
Ok(())
}
async fn ngit_issue_create(repo: &Repo, title: &str, body: &str) -> Result<EventId> {
let out = repo
.ngit(["issue", "create", "--subject", title, "--body", body])
.output()
.await
.context("failed to spawn `ngit issue create`")?;
if !out.status.success() {
bail!(
"`ngit issue create` exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
parse_issue_event_id(&stdout).with_context(|| {
format!(
"no `issue created:` line in `ngit issue create` stdout — has the print format \
changed?\nfull stdout was:\n{stdout}"
)
})
}
fn parse_issue_event_id(stdout: &str) -> Option<EventId> {
for line in stdout.lines() {
let lower = line.to_ascii_lowercase();
if let Some(idx) = lower.find("issue created:") {
let rest = line[idx + "issue created:".len()..].trim();
if let Ok(id) = EventId::from_hex(rest) {
return Some(id);
}
}
}
None
}
fn tag_values(event: &Event, key: &str) -> Vec<String> {
event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
.collect()
}