Add NIP-34 git pull request CLI support (#1279)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-06-25 11:39:54 -04:00
committed by GitHub
parent d05f122d8a
commit 5fef9b727f
4 changed files with 805 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ pub mod messages;
pub mod notes;
pub mod pack;
pub mod patches;
pub mod pr;
pub mod reactions;
pub mod repos;
pub mod social;

View File

@@ -0,0 +1,338 @@
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::{
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
};
use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta};
fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
match (body, body_file) {
(Some(_), Some(_)) => Err(CliError::Usage(
"--body and --body-file are mutually exclusive".into(),
)),
(Some(value), None) => read_or_stdin(value),
(None, Some(path)) => read_file_or_stdin(path),
(None, None) => Ok(String::new()),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_open_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
subject: &str,
body: Option<&str>,
body_file: Option<&str>,
commit: &str,
clone_urls: &[String],
branch_name: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
labels: &[String],
to: &[String],
revision_of: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPullRequestMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
subject: subject.to_string(),
labels: labels.to_vec(),
commit: commit.to_string(),
clone_urls: clone_urls.to_vec(),
branch_name: branch_name.map(str::to_string),
merge_base: merge_base.map(str::to_string),
revision_of: revision_of.map(str::to_string),
};
let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
pr: &str,
pr_author: &str,
commit: &str,
clone_urls: &[String],
body: Option<&str>,
body_file: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
validate_hex64(pr)?;
validate_hex64(pr_author)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPrUpdateMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
pr_event: pr.to_string(),
pr_author: pr_author.to_string(),
commit: commit.to_string(),
clone_urls: clone_urls.to_vec(),
merge_base: merge_base.map(str::to_string),
};
let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_pr(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1618],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_prs(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
label: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1618],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(l) = label {
filter["#t"] = serde_json::json!([l]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_pr_status(
client: &BuzzClient,
pr: &str,
status: &str,
body: Option<&str>,
body_file: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
merge_commit: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pr)?;
let status = crate::commands::patches::parse_status(status)?;
let content = read_optional_body(body, body_file)?;
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// Mirrors patch/issue status: default a `p` tag to the repo owner when
// known; callers can add PR author/reviewers with repeated `--to`.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let meta = GitStatusMeta {
root_event: pr.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: merge_commit.map(str::to_string),
applied_as_commits: vec![],
};
let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PrCmd;
match cmd {
PrCmd::Open {
repo_owner,
repo_id,
subject,
body,
body_file,
commit,
clone,
branch_name,
merge_base,
euc,
label,
to,
revision_of,
} => {
cmd_open_pr(
client,
&repo_owner,
&repo_id,
&subject,
body.as_deref(),
body_file.as_deref(),
&commit,
&clone,
branch_name.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&label,
&to,
revision_of.as_deref(),
)
.await
}
PrCmd::Update {
repo_owner,
repo_id,
pr,
pr_author,
commit,
clone,
body,
body_file,
merge_base,
euc,
to,
} => {
cmd_update_pr(
client,
&repo_owner,
&repo_id,
&pr,
&pr_author,
&commit,
&clone,
body.as_deref(),
body_file.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&to,
)
.await
}
PrCmd::Get { event } => cmd_get_pr(client, &event).await,
PrCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_prs(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
PrCmd::Status {
pr,
status,
body,
body_file,
repo_owner,
repo_id,
euc,
to,
merge_commit,
} => {
cmd_pr_status(
client,
&pr,
&status,
body.as_deref(),
body_file.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
merge_commit.as_deref(),
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_optional_body_rejects_body_and_body_file_together() {
assert!(read_optional_body(Some("body"), Some("file.md")).is_err());
}
#[test]
fn read_optional_body_defaults_empty() {
assert_eq!(read_optional_body(None, None).unwrap(), "");
}
}

View File

@@ -201,6 +201,9 @@ enum Cmd {
/// Create, get, list, and set status on git issues (NIP-34)
#[command(subcommand)]
Issues(IssuesCmd),
/// Open, update, list, and set status on git pull requests (NIP-34)
#[command(subcommand)]
Pr(PrCmd),
/// Upload files to the relay's Blossom store
#[command(subcommand)]
Upload(UploadCmd),
@@ -1081,6 +1084,147 @@ pub enum PatchesCmd {
},
}
#[derive(Subcommand)]
pub enum PrCmd {
/// Open a git pull request (NIP-34 kind:1618)
#[command(
after_help = "Examples:\n buzz pr open --repo-owner <hex> --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner <hex> --repo-id myrepo --pr <event> --pr-author <hex> --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo"
)]
Open {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Pull request subject/header
#[arg(long, alias = "title")]
subject: String,
/// Pull request body markdown. Use '-' to read from stdin.
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
/// Path to pull request body markdown, or '-' to read from stdin.
#[arg(long, conflicts_with = "body")]
body_file: Option<String>,
/// Tip commit of the PR branch
#[arg(long)]
commit: String,
/// Clone URL where the tip commit can be fetched — can be specified multiple times
#[arg(long = "clone", required = true)]
clone: Vec<String>,
/// Recommended branch name
#[arg(long)]
branch_name: Option<String>,
/// Most recent common ancestor with the target branch
#[arg(long)]
merge_base: Option<String>,
/// Earliest-unique-commit of the repo
#[arg(long)]
euc: Option<String>,
/// Label — can be specified multiple times
#[arg(long = "label")]
label: Vec<String>,
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
/// Root patch event id this PR revises
#[arg(long)]
revision_of: Option<String>,
},
/// Update a git pull request tip (NIP-34 kind:1619)
Update {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Pull request event id being updated
#[arg(long)]
pr: String,
/// Pull request author's pubkey
#[arg(long)]
pr_author: String,
/// Updated tip commit of the PR branch
#[arg(long)]
commit: String,
/// Clone URL where the updated tip commit can be fetched — can be specified multiple times
#[arg(long = "clone", required = true)]
clone: Vec<String>,
/// Markdown context for the update. Use '-' to read from stdin.
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
/// Path to markdown context for the update, or '-' to read from stdin.
#[arg(long, conflicts_with = "body")]
body_file: Option<String>,
/// Most recent common ancestor with the target branch
#[arg(long)]
merge_base: Option<String>,
/// Earliest-unique-commit of the repo
#[arg(long)]
euc: Option<String>,
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
},
/// Get a PR by event id
Get {
/// PR event id (64-char hex)
#[arg(long)]
event: String,
},
/// List PRs for a repo
List {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Filter by PR author pubkey
#[arg(long)]
author: Option<String>,
/// Filter by label
#[arg(long)]
label: Option<String>,
/// Maximum number of results
#[arg(long)]
limit: Option<u32>,
},
/// Set status on a PR (open/merged/closed/draft — NIP-34 kind:1630-1633)
Status {
/// Pull request event id
#[arg(long)]
pr: String,
/// New status
#[arg(long, value_parser = ["open", "merged", "closed", "draft"])]
status: String,
/// Markdown context for the status change. Use '-' to read from stdin.
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
/// Path to markdown context for the status change, or '-' to read from stdin.
#[arg(long, conflicts_with = "body")]
body_file: Option<String>,
/// Repo owner pubkey — requires --repo-id
#[arg(long, requires = "repo_id")]
repo_owner: Option<String>,
/// Repo identifier (d-tag) — requires --repo-owner
#[arg(long, requires = "repo_owner")]
repo_id: Option<String>,
/// Earliest-unique-commit of the repo
#[arg(long)]
euc: Option<String>,
/// Additional recipient pubkey(s) for the status event (besides the
/// repo owner, which is tagged automatically when --repo-owner is
/// given) — e.g. PR author/reviewers. Can be specified multiple times.
#[arg(long = "to")]
to: Vec<String>,
/// Merge commit id (status=merged only)
#[arg(long)]
merge_commit: Option<String>,
},
}
#[derive(Subcommand)]
pub enum IssuesCmd {
/// Create a git issue (NIP-34 kind:1621)
@@ -1316,6 +1460,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await,
Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await,
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
Cmd::Pack(_) => unreachable!("handled above"),
@@ -1347,6 +1492,7 @@ mod tests {
"notes",
"pack",
"patches",
"pr",
"reactions",
"repos",
"social",
@@ -1460,6 +1606,10 @@ mod tests {
]
);
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
assert_eq!(
names(&cmd, "pr"),
vec!["get", "list", "open", "status", "update"]
);
assert_eq!(
names(&cmd, "patches"),
vec!["get", "list", "send", "status"]
@@ -1484,6 +1634,7 @@ mod tests {
("messages", 8),
("pack", 2),
("patches", 4),
("pr", 5),
("reactions", 3),
("repos", 3),
("social", 7),

View File

@@ -7,9 +7,9 @@ use buzz_core::{
kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION,
KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF,
KIND_WORKFLOW_TRIGGER,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
KIND_GIT_STATUS_OPEN, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
},
observer::{
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
@@ -1228,6 +1228,164 @@ pub fn build_git_status(
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
}
/// Metadata for a git pull-request event (kind:1618, NIP-34).
///
/// A PR points reviewers at a branch tip they can fetch — `commit` (the tip)
/// plus at least one `clone_urls` entry where that commit is reachable. Unlike
/// a [`build_git_patch`], the change is *not* inlined; the diff is whatever the
/// tip introduces over its merge base. Per NIP-34 the tip SHOULD already be
/// pushed to `refs/nostr/<pr-event-id>` (or otherwise reachable) in the clone
/// repos before the event is signed — this builder does no network work and
/// does not verify reachability, mirroring [`build_git_patch`]'s philosophy.
#[derive(Default)]
pub struct GitPullRequestMeta {
/// Earliest-unique-commit of the repo (`r` tag) — lets clients subscribe
/// to all PRs against a local repo.
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// PR subject line (`subject` tag) — required, used as the header.
pub subject: String,
/// Labels (`t` tags).
pub labels: Vec<String>,
/// Tip commit id of the PR branch (`c` tag) — required.
pub commit: String,
/// Clone URL(s) where the tip can be fetched (`clone` tag) — at least one.
pub clone_urls: Vec<String>,
/// Recommended branch name (`branch-name` tag).
pub branch_name: Option<String>,
/// Most recent common ancestor with the target branch (`merge-base` tag).
pub merge_base: Option<String>,
/// Root patch event this PR revises, which should then be closed
/// (`e` tag) — optional.
pub revision_of: Option<String>,
}
/// Build a git pull-request event (kind:1618, NIP-34). `content` is the
/// markdown PR description.
pub fn build_git_pull_request(
repo: &GitRepoCoord,
content: &str,
meta: &GitPullRequestMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
if meta.subject.is_empty() {
return Err(SdkError::InvalidInput("subject must not be empty".into()));
}
if meta.subject.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"subject exceeds 256 characters (got {})",
meta.subject.len()
)));
}
check_commit_hex(&meta.commit, "commit")?;
if meta.clone_urls.is_empty() {
return Err(SdkError::InvalidInput(
"a pull request needs at least one --clone url where the tip commit can be fetched"
.into(),
));
}
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?];
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["subject", &meta.subject])?);
for label in &meta.labels {
tags.push(tag(&["t", label])?);
}
tags.push(tag(&["c", &meta.commit])?);
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
if let Some(ref branch) = meta.branch_name {
tags.push(tag(&["branch-name", branch])?);
}
if let Some(ref base) = meta.merge_base {
check_commit_hex(base, "merge_base")?;
tags.push(tag(&["merge-base", base])?);
}
if let Some(ref patch) = meta.revision_of {
let patch_id = check_hex_exact(patch, 64, "revision_of")?;
tags.push(tag(&["e", &patch_id])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PULL_REQUEST as u16), content).tags(tags))
}
/// Metadata for a git pull-request update event (kind:1619, NIP-34). A PR
/// update changes the tip commit of an existing PR; it references the PR via
/// NIP-22 uppercase root tags (`E`/`P`).
#[derive(Default)]
pub struct GitPrUpdateMeta {
/// Earliest-unique-commit of the repo (`r` tag).
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// The pull-request event being updated (`E` tag, NIP-22 root) — required.
pub pr_event: String,
/// The pull-request author (`P` tag, NIP-22 root) — required.
pub pr_author: String,
/// Updated tip commit id (`c` tag) — required.
pub commit: String,
/// Clone URL(s) where the new tip can be fetched (`clone` tag) — at least one.
pub clone_urls: Vec<String>,
/// Most recent common ancestor with the target branch (`merge-base` tag).
pub merge_base: Option<String>,
}
/// Build a git pull-request update event (kind:1619, NIP-34). `content` is
/// optional markdown context for the update.
pub fn build_git_pr_update(
repo: &GitRepoCoord,
content: &str,
meta: &GitPrUpdateMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let pr_event = check_hex_exact(&meta.pr_event, 64, "pr_event")?;
let pr_author = check_pubkey_hex(&meta.pr_author, "pr_author")?;
check_commit_hex(&meta.commit, "commit")?;
if meta.clone_urls.is_empty() {
return Err(SdkError::InvalidInput(
"a pull request update needs at least one --clone url where the tip commit can be fetched"
.into(),
));
}
let a_value = repo.to_a_tag_value()?;
let owner = check_pubkey_hex(&repo.owner, "repo owner")?;
let mut tags = vec![tag(&["a", &a_value])?];
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["E", &pr_event])?);
tags.push(tag(&["P", &pr_author])?);
tags.push(tag(&["c", &meta.commit])?);
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
if let Some(ref base) = meta.merge_base {
check_commit_hex(base, "merge_base")?;
tags.push(tag(&["merge-base", base])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PR_UPDATE as u16), content).tags(tags))
}
/// Build a workflow definition event (kind 30620).
///
/// - `channel_id`: the channel this workflow belongs to (h-tag)
@@ -2822,4 +2980,158 @@ mod tests {
let err = build_presence_update("dnd").unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
// ── build_git_pull_request / build_git_pr_update ──────────────────────────
fn pr_repo() -> GitRepoCoord {
GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
}
}
fn full_clone_tag(event: &nostr::Event) -> Vec<String> {
event
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))
.map(|t| t.as_slice()[1..].iter().map(|v| v.to_string()).collect())
.unwrap_or_default()
}
#[test]
fn git_pr_happy_path() {
let meta = GitPullRequestMeta {
subject: "Add feature X".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
branch_name: Some("feat/x".to_string()),
labels: vec!["enhancement".to_string()],
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1618);
assert_eq!(ev.content, "PR body");
assert!(has_tag(&ev, "a", &format!("30617:{}:repo", "a".repeat(64))));
assert!(has_tag(&ev, "p", &"a".repeat(64)));
assert!(has_tag(&ev, "subject", "Add feature X"));
assert!(has_tag(&ev, "c", &"c".repeat(40)));
assert!(has_tag(&ev, "t", "enhancement"));
assert!(has_tag(&ev, "branch-name", "feat/x"));
assert_eq!(
full_clone_tag(&ev),
vec!["https://example.com/repo.git".to_string()]
);
}
#[test]
fn git_pr_emits_multi_url_clone_tag() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec![
"https://a.example/repo.git".to_string(),
"https://b.example/repo.git".to_string(),
],
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
assert_eq!(full_clone_tag(&ev).len(), 2);
}
#[test]
fn git_pr_rejects_empty_subject() {
let meta = GitPullRequestMeta {
subject: String::new(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_missing_clone_url() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec![],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_rejects_short_commit() {
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "abc".to_string(),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pull_request(&pr_repo(), "body", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_revision_of_emits_e_tag() {
let patch = event_id().to_hex();
let meta = GitPullRequestMeta {
subject: "s".to_string(),
commit: "c".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
revision_of: Some(patch.clone()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "", &meta).unwrap());
assert!(has_tag(&ev, "e", &patch));
}
#[test]
fn git_pr_update_happy_path() {
let pr = event_id().to_hex();
let meta = GitPrUpdateMeta {
pr_event: pr.clone(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
merge_base: Some("e".repeat(40)),
..Default::default()
};
let ev = sign(build_git_pr_update(&pr_repo(), "rebased", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1619);
assert!(has_tag(&ev, "E", &pr));
assert!(has_tag(&ev, "P", &"b".repeat(64)));
assert!(has_tag(&ev, "c", &"d".repeat(40)));
assert!(has_tag(&ev, "merge-base", &"e".repeat(40)));
assert_eq!(full_clone_tag(&ev).len(), 1);
}
#[test]
fn git_pr_update_rejects_bad_pr_event() {
let meta = GitPrUpdateMeta {
pr_event: "not-hex".to_string(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec!["https://example.com/repo.git".to_string()],
..Default::default()
};
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_pr_update_rejects_missing_clone_url() {
let meta = GitPrUpdateMeta {
pr_event: event_id().to_hex(),
pr_author: "b".repeat(64),
commit: "d".repeat(40),
clone_urls: vec![],
..Default::default()
};
let err = build_git_pr_update(&pr_repo(), "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
}