feat(cli): add patches and issues commands for NIP-34 git collaboration (#1073)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: Dawn (buzz agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-18 09:30:33 -04:00
committed by GitHub
co-authored by Dawn
parent 0181603eba
commit cd8292fe9e
6 changed files with 1522 additions and 29 deletions
+218
View File
@@ -0,0 +1,218 @@
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
// ---------------------------------------------------------------------------
// Create issue — publish kind:1621
// ---------------------------------------------------------------------------
pub async fn cmd_create_issue(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
subject: &str,
content: &str,
labels: &[String],
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let body = read_or_stdin(content)?;
let meta = GitIssueMeta {
labels: labels.to_vec(),
recipients: to.to_vec(),
};
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Get issue — query kind:1621 by event id
// ---------------------------------------------------------------------------
pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1621],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// List issues — query kind:1621 by repo coordinate, with optional filters
// ---------------------------------------------------------------------------
pub async fn cmd_list_issues(
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": [1621],
"#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(())
}
// ---------------------------------------------------------------------------
// Status — publish kind:1630/1631/1632/1633 against an issue
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
pub async fn cmd_issue_status(
client: &BuzzClient,
issue: &str,
status: &str,
content: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(issue)?;
let status = crate::commands::patches::parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
None => String::new(),
};
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 `buzz patches status`: default a `p` tag to the repo owner
// for discoverability, plus a `--to` escape hatch for the issue author
// or anyone else who should be notified of the status change.
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: issue.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: None,
applied_as_commits: vec![],
};
let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::IssuesCmd;
match cmd {
IssuesCmd::Create {
repo_owner,
repo_id,
title,
content,
label,
to,
} => cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await,
IssuesCmd::Get { event } => cmd_get_issue(client, &event).await,
IssuesCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_issues(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
IssuesCmd::Status {
issue,
status,
content,
repo_owner,
repo_id,
euc,
to,
} => {
cmd_issue_status(
client,
&issue,
&status,
content.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
)
.await
}
}
}
+2
View File
@@ -2,10 +2,12 @@ pub mod channels;
pub mod dms;
pub mod emoji;
pub mod feed;
pub mod issues;
pub mod mem;
pub mod messages;
pub mod notes;
pub mod pack;
pub mod patches;
pub mod reactions;
pub mod repos;
pub mod social;
+343
View File
@@ -0,0 +1,343 @@
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::{GitAppliedPatchRef, GitPatchMeta, GitRepoCoord, GitStatus, GitStatusMeta};
// ---------------------------------------------------------------------------
// Send patch — publish kind:1617
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
pub async fn cmd_send_patch(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
patch: &str,
euc: Option<&str>,
to: &[String],
reply_to: Option<&str>,
root: bool,
root_revision: bool,
commit: Option<&str>,
parent_commit: Option<&str>,
commit_pgp_sig: Option<&str>,
committer: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let content = read_file_or_stdin(patch)?;
let committer = match committer {
Some(spec) => Some(parse_committer(spec)?),
None => None,
};
let meta = GitPatchMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
reply_to: reply_to.map(str::to_string),
root,
root_revision,
commit: commit.map(str::to_string),
parent_commit: parent_commit.map(str::to_string),
commit_pgp_sig: commit_pgp_sig.map(str::to_string),
committer,
};
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Parse `--committer 'name|email|timestamp|tz-offset-minutes'`.
fn parse_committer(spec: &str) -> Result<(String, String, String, String), CliError> {
let parts: Vec<&str> = spec.split('|').collect();
match parts.as_slice() {
[name, email, ts, tz] => Ok((
name.to_string(),
email.to_string(),
ts.to_string(),
tz.to_string(),
)),
_ => Err(CliError::Usage(
"--committer must be 'name|email|timestamp|tz-offset-minutes'".into(),
)),
}
}
// ---------------------------------------------------------------------------
// Get patch — query kind:1617 by event id
// ---------------------------------------------------------------------------
pub async fn cmd_get_patch(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1617],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// List patches — query kind:1617 by repo coordinate, with optional filters
// ---------------------------------------------------------------------------
pub async fn cmd_list_patches(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: 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": [1617],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Status — publish kind:1630/1631/1632/1633 against a patch root
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
pub async fn cmd_patch_status(
client: &BuzzClient,
root: &str,
status: &str,
content: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
revision: Option<&str>,
to: &[String],
q: &[String],
merge_commit: Option<&str>,
applied_as_commit: &[String],
) -> Result<(), CliError> {
validate_hex64(root)?;
let status = parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
None => String::new(),
};
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(),
))
}
};
// NIP-34 expects status events to `p`-tag the repo owner (plus root/
// revision authors) so they're discoverable by subscription. Default
// to the repo owner when known; `--to` covers root-author / revision-
// author / anyone else the caller wants to notify.
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 applied_patches = q
.iter()
.map(|spec| GitAppliedPatchRef::parse(spec).map_err(sdk_err))
.collect::<Result<Vec<_>, _>>()?;
let meta = GitStatusMeta {
root_event: root.to_string(),
accepted_revision_root: revision.map(str::to_string),
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches,
merge_commit: merge_commit.map(str::to_string),
applied_as_commits: applied_as_commit.to_vec(),
};
let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Parse the CLI's status word into a `GitStatus`. `merged` and `resolved`
/// are accepted as synonyms for the same underlying kind (1631) — NIP-34
/// uses "applied/merged" for patches and "resolved" for issues, but it's one
/// status kind either way. Shared by `buzz issues status`.
pub(crate) fn parse_status(s: &str) -> Result<GitStatus, CliError> {
match s {
"open" => Ok(GitStatus::Open),
"merged" | "resolved" => Ok(GitStatus::AppliedOrResolved),
"closed" => Ok(GitStatus::Closed),
"draft" => Ok(GitStatus::Draft),
other => Err(CliError::Usage(format!(
"invalid status '{other}' — expected one of: open, merged, resolved, closed, draft"
))),
}
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PatchesCmd;
match cmd {
PatchesCmd::Send {
repo_owner,
repo_id,
patch_file,
euc,
to,
reply_to,
root,
root_revision,
commit,
parent_commit,
commit_pgp_sig,
committer,
} => {
cmd_send_patch(
client,
&repo_owner,
&repo_id,
&patch_file,
euc.as_deref(),
&to,
reply_to.as_deref(),
root,
root_revision,
commit.as_deref(),
parent_commit.as_deref(),
commit_pgp_sig.as_deref(),
committer.as_deref(),
)
.await
}
PatchesCmd::Get { event } => cmd_get_patch(client, &event).await,
PatchesCmd::List {
repo_owner,
repo_id,
author,
limit,
} => cmd_list_patches(client, &repo_owner, &repo_id, author.as_deref(), limit).await,
PatchesCmd::Status {
root,
status,
content,
repo_owner,
repo_id,
euc,
revision,
to,
q,
merge_commit,
applied_as_commit,
} => {
cmd_patch_status(
client,
&root,
&status,
content.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
revision.as_deref(),
&to,
&q,
merge_commit.as_deref(),
&applied_as_commit,
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_committer_valid() {
let result = parse_committer("Jane Doe|jane@example.com|1700000000|-480").unwrap();
assert_eq!(
result,
(
"Jane Doe".to_string(),
"jane@example.com".to_string(),
"1700000000".to_string(),
"-480".to_string()
)
);
}
#[test]
fn parse_committer_rejects_wrong_field_count() {
assert!(parse_committer("Jane Doe|jane@example.com").is_err());
assert!(parse_committer("a|b|c|d|e").is_err());
}
#[test]
fn parse_status_accepts_known_words() {
assert!(matches!(parse_status("open").unwrap(), GitStatus::Open));
assert!(matches!(
parse_status("merged").unwrap(),
GitStatus::AppliedOrResolved
));
assert!(matches!(
parse_status("resolved").unwrap(),
GitStatus::AppliedOrResolved
));
assert!(matches!(parse_status("closed").unwrap(), GitStatus::Closed));
assert!(matches!(parse_status("draft").unwrap(), GitStatus::Draft));
}
#[test]
fn parse_status_rejects_unknown_word() {
let err = parse_status("merge").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
}
+209
View File
@@ -207,6 +207,12 @@ enum Cmd {
/// Announce and discover git repositories (NIP-34)
#[command(subcommand)]
Repos(ReposCmd),
/// Send, get, list, and set status on git patches (NIP-34)
#[command(subcommand)]
Patches(PatchesCmd),
/// Create, get, list, and set status on git issues (NIP-34)
#[command(subcommand)]
Issues(IssuesCmd),
/// Upload files to the relay's Blossom store
#[command(subcommand)]
Upload(UploadCmd),
@@ -1018,6 +1024,195 @@ pub enum ReposCmd {
},
}
// ---------------------------------------------------------------------------
// Patches subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
pub enum PatchesCmd {
/// Send a git patch (NIP-34 kind:1617)
#[command(
after_help = "Examples:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file 0001-fix.patch --reply-to <prev-patch-id>"
)]
Send {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Path to a `git format-patch` file, or '-' to read from stdin
#[arg(long)]
patch_file: 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>,
/// Previous patch event id (series) or original root (revision)
#[arg(long)]
reply_to: Option<String>,
/// Mark as the first patch of a new series
#[arg(long, default_value_t = false)]
root: bool,
/// Mark as the first patch of a new revision of an existing series
#[arg(long, default_value_t = false)]
root_revision: bool,
/// Commit ID this patch produces when applied
#[arg(long)]
commit: Option<String>,
/// Parent commit ID
#[arg(long)]
parent_commit: Option<String>,
/// PGP signature of the commit
#[arg(long)]
commit_pgp_sig: Option<String>,
/// Committer identity: 'name|email|timestamp|tz-offset-minutes'
#[arg(long)]
committer: Option<String>,
},
/// Get a patch by event id
Get {
/// Patch event id (64-char hex)
#[arg(long)]
event: String,
},
/// List patches 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 patch author pubkey
#[arg(long)]
author: Option<String>,
/// Maximum number of results
#[arg(long)]
limit: Option<u32>,
},
/// Set status on a patch (open/merged/closed/draft — NIP-34 kind:1630-1633)
Status {
/// Root patch event id (first patch of the series/revision)
#[arg(long)]
root: String,
/// New status
#[arg(long, value_parser = ["open", "merged", "closed", "draft"])]
status: String,
/// Markdown context for the status change ('-' to read from stdin)
#[arg(long)]
content: 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>,
/// Root id of the revision that was accepted (status=merged only)
#[arg(long)]
revision: 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. root/revision author. Can be specified multiple times.
#[arg(long = "to")]
to: Vec<String>,
/// Applied patch event id — can be specified multiple times (status=merged only).
/// Accepts `<id>`, `<id>:<relay-url>`, or `<id>:<relay-url>:<pubkey>`.
#[arg(long = "q")]
q: Vec<String>,
/// Merge commit id (status=merged only)
#[arg(long)]
merge_commit: Option<String>,
/// Commit id applied to the target branch — can be specified multiple times (status=merged only)
#[arg(long = "applied-as-commit")]
applied_as_commit: Vec<String>,
},
}
// ---------------------------------------------------------------------------
// Issues subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
pub enum IssuesCmd {
/// Create a git issue (NIP-34 kind:1621)
Create {
/// Repo owner pubkey (64-char hex)
#[arg(long)]
repo_owner: String,
/// Repo identifier (d-tag)
#[arg(long)]
repo_id: String,
/// Issue title
#[arg(long, alias = "subject")]
title: String,
/// Issue body, markdown. Use '-' to read from stdin.
#[arg(long)]
content: 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>,
},
/// Get an issue by event id
Get {
/// Issue event id (64-char hex)
#[arg(long)]
event: String,
},
/// List issues 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 issue 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 an issue (open/resolved/closed/draft — NIP-34 kind:1630-1633)
Status {
/// Issue event id
#[arg(long)]
issue: String,
/// New status
#[arg(long, value_parser = ["open", "resolved", "closed", "draft"])]
status: String,
/// Markdown context for the status change ('-' to read from stdin)
#[arg(long)]
content: 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. the issue author. Can be specified multiple times.
#[arg(long = "to")]
to: Vec<String>,
},
}
// ---------------------------------------------------------------------------
// Upload subcommands
// ---------------------------------------------------------------------------
@@ -1192,6 +1387,8 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
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::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
Cmd::Pack(_) => unreachable!("handled above"),
@@ -1221,10 +1418,12 @@ mod tests {
"dms",
"emoji",
"feed",
"issues",
"mem",
"messages",
"notes",
"pack",
"patches",
"reactions",
"repos",
"social",
@@ -1338,6 +1537,14 @@ mod tests {
]
);
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
assert_eq!(
names(&cmd, "patches"),
vec!["get", "list", "send", "status"]
);
assert_eq!(
names(&cmd, "issues"),
vec!["create", "get", "list", "status"]
);
assert_eq!(names(&cmd, "upload"), vec!["file"]);
assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]);
}
@@ -1350,8 +1557,10 @@ mod tests {
("dms", 4),
("emoji", 5),
("feed", 1),
("issues", 4),
("messages", 8),
("pack", 2),
("patches", 4),
("reactions", 3),
("repos", 3),
("social", 7),
+47
View File
@@ -178,6 +178,25 @@ pub fn read_or_stdin(value: &str) -> Result<String, CliError> {
}
}
/// Read content from a file path, or stdin if the value is "-".
///
/// Unlike [`read_or_stdin`], `value` is never treated as literal content —
/// it always names a file (or `-` for stdin). Use this for flags like
/// `--patch-file` where the argument is a path, not the content itself.
pub fn read_file_or_stdin(value: &str) -> Result<String, CliError> {
if value == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("failed to read stdin: {e}")))?;
Ok(buf)
} else {
std::fs::read_to_string(value)
.map_err(|e| CliError::Usage(format!("failed to read {value:?}: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -458,4 +477,32 @@ mod tests {
fn read_or_stdin_passthrough_empty_string() {
assert_eq!(super::read_or_stdin("").unwrap(), "");
}
// --- read_file_or_stdin ---
#[test]
fn read_file_or_stdin_reads_file_contents() {
let mut path = std::env::temp_dir();
path.push(format!(
"buzz-cli-test-{}-{}.patch",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "diff --git a/x b/x\n").unwrap();
let got = super::read_file_or_stdin(path.to_str().unwrap()).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(got, "diff --git a/x b/x\n");
}
#[test]
fn read_file_or_stdin_does_not_treat_path_as_literal_content() {
// Regression for the bug where `read_or_stdin` was used for
// `--patch-file`: a nonexistent path must error, not be returned
// verbatim as if it were the patch content.
let err = super::read_file_or_stdin("0001-does-not-exist.patch").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
}
+703 -29
View File
@@ -6,8 +6,10 @@
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_REPO_ANNOUNCEMENT,
KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
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,
},
observer::{
content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
@@ -48,6 +50,21 @@ fn check_hex_len(s: &str, min_len: usize, field: &str) -> Result<(), SdkError> {
Ok(())
}
/// Validate a git commit-like hex id (commit, parent-commit, euc,
/// merge-commit, applied-as-commit). Git object ids are full SHA-1 (40 hex
/// chars) or SHA-256 (64 hex chars) — anything shorter is an abbreviated
/// ref that NIP-34 canonical tags shouldn't carry, since consumers resolve
/// these against the actual repo.
fn check_commit_hex(s: &str, field: &str) -> Result<(), SdkError> {
if (s.len() != 40 && s.len() != 64) || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{field} must be a full 40-character (SHA-1) or 64-character (SHA-256) hex commit id (got {:?})",
s
)));
}
Ok(())
}
fn check_pubkey_hex(s: &str, field: &str) -> Result<String, SdkError> {
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
@@ -57,6 +74,51 @@ fn check_pubkey_hex(s: &str, field: &str) -> Result<String, SdkError> {
Ok(s.to_ascii_lowercase())
}
/// Validate an exact-length hex string (event ids), returning it lowercased.
fn check_hex_exact(s: &str, len: usize, field: &str) -> Result<String, SdkError> {
if s.len() != len || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(SdkError::InvalidInput(format!(
"{field} must be a {len}-character hex string"
)));
}
Ok(s.to_ascii_lowercase())
}
/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dot,
/// no `..`. Shared by `build_repo_announcement` and `GitRepoCoord` so a
/// repo coordinate built directly through the SDK (bypassing CLI-side
/// `validate_repo_id`) can't slip an invalid `d`-tag into an `a`-tag value.
fn check_repo_id(repo_id: &str) -> Result<(), SdkError> {
if repo_id.is_empty() {
return Err(SdkError::InvalidInput("repo_id must not be empty".into()));
}
if repo_id.len() > 64 {
return Err(SdkError::InvalidInput(format!(
"repo_id exceeds 64 characters (got {})",
repo_id.len()
)));
}
if !repo_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(SdkError::InvalidInput(
"repo_id may only contain [a-zA-Z0-9._-]".into(),
));
}
if repo_id.starts_with('.') {
return Err(SdkError::InvalidInput(
"repo_id must not start with a dot".into(),
));
}
if repo_id.contains("..") {
return Err(SdkError::InvalidInput(
"repo_id must not contain '..'".into(),
));
}
Ok(())
}
/// Validate and normalize a NIP-30 custom emoji shortcode.
///
/// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase
@@ -785,33 +847,7 @@ pub fn build_repo_announcement(
relays: &[&str],
) -> Result<EventBuilder, SdkError> {
// Validate repo_id
if repo_id.is_empty() {
return Err(SdkError::InvalidInput("repo_id must not be empty".into()));
}
if repo_id.len() > 64 {
return Err(SdkError::InvalidInput(format!(
"repo_id exceeds 64 characters (got {})",
repo_id.len()
)));
}
if !repo_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(SdkError::InvalidInput(
"repo_id may only contain [a-zA-Z0-9._-]".into(),
));
}
if repo_id.starts_with('.') {
return Err(SdkError::InvalidInput(
"repo_id must not start with a dot".into(),
));
}
if repo_id.contains("..") {
return Err(SdkError::InvalidInput(
"repo_id must not contain '..'".into(),
));
}
check_repo_id(repo_id)?;
// Validate optional name
if let Some(n) = name {
@@ -915,6 +951,337 @@ pub fn build_repo_announcement(
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "").tags(tags))
}
// ── Git collaboration: patches, issues, status (NIP-34) ─────────────────────
/// Repository coordinate — owner pubkey + `d`-tag identifier.
///
/// Renders as the `a`-tag value clients use to address a kind:30617
/// announcement: `30617:<owner>:<id>`.
pub struct GitRepoCoord {
/// 64-char hex pubkey of the repo's announcing owner.
pub owner: String,
/// The repo's `d`-tag identifier.
pub id: String,
}
impl GitRepoCoord {
fn to_a_tag_value(&self) -> Result<String, SdkError> {
let owner = check_pubkey_hex(&self.owner, "repo owner")?;
check_repo_id(&self.id)?;
Ok(format!("30617:{owner}:{}", self.id))
}
}
/// Metadata for a git patch event (kind:1617, NIP-34).
#[derive(Default)]
pub struct GitPatchMeta {
/// Earliest-unique-commit of the repo (`r` tag, `euc` marker).
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// Previous patch in a series, or the original root patch when this is
/// the first patch of a revision — emits `["e", id, "", "reply"]`.
pub reply_to: Option<String>,
/// First patch in a new series — emits `["t", "root"]`.
pub root: bool,
/// First patch in a revision of an existing series — emits `["t", "root-revision"]`.
pub root_revision: bool,
/// Commit ID this patch produces when applied (`commit` tag + `r` tag).
pub commit: Option<String>,
/// Parent commit ID (`parent-commit` tag).
pub parent_commit: Option<String>,
/// PGP signature of the commit, or `Some("")` for an explicitly unsigned commit.
pub commit_pgp_sig: Option<String>,
/// Committer identity: `(name, email, unix-timestamp, tz-offset-minutes)`.
pub committer: Option<(String, String, String, String)>,
}
/// Build a git patch event (kind:1617, NIP-34).
///
/// `content` is the verbatim output of `git format-patch` — not truncated.
/// NIP-34 says patches SHOULD be used when under 60KB (PRs otherwise); this
/// builder enforces that bound rather than silently truncating a patch that
/// must remain applyable.
pub fn build_git_patch(
repo: &GitRepoCoord,
content: &str,
meta: &GitPatchMeta,
) -> Result<EventBuilder, SdkError> {
if content.trim().is_empty() {
return Err(SdkError::InvalidInput(
"patch content must not be empty — refusing to publish an unappliable patch".into(),
));
}
check_content(content, 60 * 1024)?;
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, "euc"])?);
}
tags.push(tag(&["p", &owner])?);
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
if let Some(ref prev) = meta.reply_to {
let event_id = check_hex_exact(prev, 64, "reply_to")?;
tags.push(tag(&["e", &event_id, "", "reply"])?);
}
if meta.root && meta.root_revision {
return Err(SdkError::InvalidInput(
"patch cannot be both --root and --root-revision".into(),
));
}
if meta.root {
tags.push(tag(&["t", "root"])?);
}
if meta.root_revision {
tags.push(tag(&["t", "root-revision"])?);
}
if let Some(ref commit) = meta.commit {
check_commit_hex(commit, "commit")?;
tags.push(tag(&["commit", commit])?);
tags.push(tag(&["r", commit])?);
}
if let Some(ref parent) = meta.parent_commit {
check_commit_hex(parent, "parent_commit")?;
tags.push(tag(&["parent-commit", parent])?);
}
if let Some(ref sig) = meta.commit_pgp_sig {
tags.push(tag(&["commit-pgp-sig", sig])?);
}
if let Some((ref name, ref email, ref ts, ref tz)) = meta.committer {
tags.push(tag(&["committer", name, email, ts, tz])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_PATCH as u16), content).tags(tags))
}
/// Metadata for a git issue event (kind:1621, NIP-34).
#[derive(Default)]
pub struct GitIssueMeta {
/// Labels (`t` tags).
pub labels: Vec<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
}
/// Build a git issue event (kind:1621, NIP-34). `content` is the markdown body.
pub fn build_git_issue(
repo: &GitRepoCoord,
subject: &str,
content: &str,
meta: &GitIssueMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
if subject.is_empty() {
return Err(SdkError::InvalidInput("subject must not be empty".into()));
}
if subject.len() > 256 {
return Err(SdkError::InvalidInput(format!(
"subject exceeds 256 characters (got {})",
subject.len()
)));
}
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])?, tag(&["p", &owner])?];
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
tags.push(tag(&["subject", subject])?);
for label in &meta.labels {
tags.push(tag(&["t", label])?);
}
Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags))
}
/// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitStatus {
/// 1630 — Open (the default state).
Open,
/// 1631 — Applied/Merged for patches; Resolved for issues.
AppliedOrResolved,
/// 1632 — Closed.
Closed,
/// 1633 — Draft.
Draft,
}
/// A reference to an applied/merged patch event for a status `q` tag,
/// optionally carrying a relay-url and/or pubkey hint per NIP-34:
/// `['q', <id>, <relay-url>, <pubkey>]`.
///
/// Parsed from the CLI's `--q <id>[:<relay-url>[:<pubkey>]]` syntax via
/// [`GitAppliedPatchRef::parse`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitAppliedPatchRef {
/// The applied/merged patch event id (64-char hex).
pub id: String,
/// Optional relay-url hint where the patch event can be found.
pub relay: Option<String>,
/// Optional pubkey hint of the patch's author. Only meaningful when
/// `relay` is also set, per NIP-34's `['q', id, relay, pubkey]` shape.
pub pubkey: Option<String>,
}
impl GitAppliedPatchRef {
/// Parse `<id>`, `<id>:<relay-url>`, or `<id>:<relay-url>:<pubkey>`.
///
/// The relay-url segment may itself contain `:` (e.g. `wss://host:port`),
/// so splitting is bounded to at most 3 parts rather than splitting on
/// every colon.
pub fn parse(spec: &str) -> Result<Self, SdkError> {
let mut parts = spec.splitn(3, ':');
let id = parts.next().unwrap_or_default().to_string();
let rest = parts.next();
match rest {
None => Ok(GitAppliedPatchRef {
id,
relay: None,
pubkey: None,
}),
Some(_) => {
// Re-split with the relay url glued back together, since a
// relay URL itself contains colons (`wss://host:port`); the
// pubkey, if present, is always the last `:`-delimited
// segment.
let rest_str = &spec[id.len() + 1..];
if let Some(idx) = rest_str.rfind(':') {
let candidate_pubkey = &rest_str[idx + 1..];
if candidate_pubkey.len() == 64
&& candidate_pubkey.chars().all(|c| c.is_ascii_hexdigit())
{
return Ok(GitAppliedPatchRef {
id,
relay: Some(rest_str[..idx].to_string()),
pubkey: Some(candidate_pubkey.to_ascii_lowercase()),
});
}
}
Ok(GitAppliedPatchRef {
id,
relay: Some(rest_str.to_string()),
pubkey: None,
})
}
}
}
}
impl GitStatus {
fn kind(self) -> u16 {
match self {
GitStatus::Open => KIND_GIT_STATUS_OPEN as u16,
GitStatus::AppliedOrResolved => KIND_GIT_STATUS_MERGED as u16,
GitStatus::Closed => KIND_GIT_STATUS_CLOSED as u16,
GitStatus::Draft => KIND_GIT_STATUS_DRAFT as u16,
}
}
}
/// Metadata for a git status event (kind:1630-1633, NIP-34). Applies to a
/// patch root, a patch revision root, an issue, or a PR.
#[derive(Default)]
pub struct GitStatusMeta {
/// The issue/PR/original-root-patch event being given a status — required.
pub root_event: String,
/// When a revision was the one applied/merged, its root id.
pub accepted_revision_root: Option<String>,
/// Repo coordinate, included as an `a` tag for subscription efficiency.
pub repo: Option<GitRepoCoord>,
/// Earliest-unique-commit of the repo (`r` tag, no marker).
pub euc: Option<String>,
/// Additional `p` tags (root author, revision author, etc.) besides the repo owner.
pub recipients: Vec<String>,
/// Applied/merged patch event references (`q` tags) — kind:1631 only.
pub applied_patches: Vec<GitAppliedPatchRef>,
/// Merge commit id — kind:1631 only.
pub merge_commit: Option<String>,
/// Commit ids applied to the target branch — kind:1631 only.
pub applied_as_commits: Vec<String>,
}
/// Build a git status event (kind:1630/1631/1632/1633, NIP-34).
/// `content` is optional markdown context for the status change.
pub fn build_git_status(
status: GitStatus,
content: &str,
meta: &GitStatusMeta,
) -> Result<EventBuilder, SdkError> {
check_content(content, 64 * 1024)?;
let root = check_hex_exact(&meta.root_event, 64, "root_event")?;
let mut tags = vec![tag(&["e", &root, "", "root"])?];
if let Some(ref revision) = meta.accepted_revision_root {
let revision = check_hex_exact(revision, 64, "accepted_revision_root")?;
tags.push(tag(&["e", &revision, "", "reply"])?);
}
for recipient in &meta.recipients {
let pk = check_pubkey_hex(recipient, "recipient")?;
tags.push(tag(&["p", &pk])?);
}
if let Some(ref repo) = meta.repo {
let a_value = repo.to_a_tag_value()?;
tags.push(tag(&["a", &a_value])?);
}
if let Some(ref euc) = meta.euc {
check_commit_hex(euc, "euc")?;
tags.push(tag(&["r", euc])?);
}
if status != GitStatus::AppliedOrResolved
&& (!meta.applied_patches.is_empty()
|| meta.merge_commit.is_some()
|| !meta.applied_as_commits.is_empty())
{
return Err(SdkError::InvalidInput(
"applied_patches/merge_commit/applied_as_commits only apply to the merged/resolved status".into(),
));
}
for patch_ref in &meta.applied_patches {
let patch_id = check_hex_exact(&patch_ref.id, 64, "applied_patch")?;
match (&patch_ref.relay, &patch_ref.pubkey) {
(None, None) => tags.push(tag(&["q", &patch_id])?),
(Some(relay), None) => tags.push(tag(&["q", &patch_id, relay])?),
(Some(relay), Some(pubkey)) => {
let pubkey = check_pubkey_hex(pubkey, "applied_patch pubkey")?;
tags.push(tag(&["q", &patch_id, relay, &pubkey])?)
}
(None, Some(_)) => {
return Err(SdkError::InvalidInput(
"applied_patch pubkey hint requires a relay-url hint".into(),
))
}
}
}
if let Some(ref merge_commit) = meta.merge_commit {
check_commit_hex(merge_commit, "merge_commit")?;
tags.push(tag(&["merge-commit", merge_commit])?);
tags.push(tag(&["r", merge_commit])?);
}
if !meta.applied_as_commits.is_empty() {
let mut commits_tag = vec!["applied-as-commits"];
for commit in &meta.applied_as_commits {
check_commit_hex(commit, "applied_as_commit")?;
}
commits_tag.extend(meta.applied_as_commits.iter().map(String::as_str));
tags.push(tag(&commits_tag)?);
for commit in &meta.applied_as_commits {
tags.push(tag(&["r", commit])?);
}
}
Ok(EventBuilder::new(Kind::Custom(status.kind()), content).tags(tags))
}
// ── Builder 31: build_workflow_def ────────────────────────────────────────────
/// Build a workflow definition event (kind 30620).
@@ -2089,6 +2456,313 @@ mod tests {
assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git");
}
// ── build_git_patch / build_git_issue / build_git_status (NIP-34) ───────
#[test]
fn git_patch_happy_path_minimal() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "my-repo".to_string(),
};
let ev =
sign(build_git_patch(&repo, "diff --git a/x b/x", &GitPatchMeta::default()).unwrap());
assert_eq!(ev.kind.as_u16(), 1617);
assert_eq!(ev.content, "diff --git a/x b/x");
assert!(has_tag(&ev, "a", &format!("30617:{owner}:my-repo")));
assert!(has_tag(&ev, "p", &owner));
}
#[test]
fn git_patch_root_and_metadata_tags() {
let owner = "a".repeat(64);
let commit = "c".repeat(40);
let parent = "d".repeat(40);
let repo = GitRepoCoord {
owner,
id: "repo".to_string(),
};
let meta = GitPatchMeta {
root: true,
commit: Some(commit.clone()),
parent_commit: Some(parent.clone()),
..Default::default()
};
let ev = sign(build_git_patch(&repo, "patch body", &meta).unwrap());
assert!(has_tag(&ev, "t", "root"));
assert!(has_tag(&ev, "commit", &commit));
assert!(has_tag(&ev, "parent-commit", &parent));
assert!(has_tag(&ev, "r", &commit));
}
#[test]
fn git_patch_rejects_root_and_root_revision_together() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta = GitPatchMeta {
root: true,
root_revision: true,
..Default::default()
};
let err = build_git_patch(&repo, "x", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_oversized_content() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let big = "x".repeat(60 * 1024 + 1);
let err = build_git_patch(&repo, &big, &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::ContentTooLarge { .. }));
}
#[test]
fn git_patch_rejects_bad_repo_owner() {
let repo = GitRepoCoord {
owner: "not-hex".to_string(),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, "x", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_issue_happy_path() {
let owner = "a".repeat(64);
let repo = GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
};
let meta = GitIssueMeta {
labels: vec!["bug".to_string(), "p1".to_string()],
recipients: vec![],
};
let ev =
sign(build_git_issue(&repo, "Crashes on startup", "steps to repro", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1621);
assert_eq!(ev.content, "steps to repro");
assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo")));
assert!(has_tag(&ev, "subject", "Crashes on startup"));
assert!(has_tag(&ev, "t", "bug"));
assert!(has_tag(&ev, "t", "p1"));
}
#[test]
fn git_issue_rejects_empty_subject() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_issue(&repo, "", "body", &GitIssueMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_status_open_happy_path() {
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root.clone(),
..Default::default()
};
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1630);
assert!(has_tag(&ev, "e", &root));
}
#[test]
fn git_status_merged_with_applied_patches() {
let root = event_id().to_hex();
let patch_id = event_id().to_hex();
let merge_commit = "f".repeat(40);
let meta = GitStatusMeta {
root_event: root,
applied_patches: vec![GitAppliedPatchRef {
id: patch_id.clone(),
relay: None,
pubkey: None,
}],
merge_commit: Some(merge_commit.clone()),
..Default::default()
};
let ev =
sign(build_git_status(GitStatus::AppliedOrResolved, "merged, thanks!", &meta).unwrap());
assert_eq!(ev.kind.as_u16(), 1631);
assert!(has_tag(&ev, "q", &patch_id));
assert!(has_tag(&ev, "merge-commit", &merge_commit));
assert!(has_tag(&ev, "r", &merge_commit));
}
#[test]
fn git_status_rejects_merge_fields_on_non_merged_status() {
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root,
merge_commit: Some("f".repeat(40)),
..Default::default()
};
let err = build_git_status(GitStatus::Closed, "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_status_rejects_bad_root_event() {
let meta = GitStatusMeta {
root_event: "not-an-event-id".to_string(),
..Default::default()
};
let err = build_git_status(GitStatus::Open, "", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_empty_content() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, "", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_whitespace_only_content() {
// Regression: a failed `git format-patch | buzz patches send
// --patch-file -` must not silently publish a whitespace-only
// (i.e. unappliable) patch.
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let err = build_git_patch(&repo, " \n\t\n", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_repo_coord_rejects_invalid_repo_id_chars() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "../etc/passwd".to_string(),
};
let err = build_git_patch(&repo, "diff", &GitPatchMeta::default()).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_rejects_short_commit_hex() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta = GitPatchMeta {
commit: Some("a".to_string()),
..Default::default()
};
let err = build_git_patch(&repo, "diff", &meta).unwrap_err();
assert!(matches!(err, SdkError::InvalidInput(_)));
}
#[test]
fn git_patch_accepts_full_sha1_and_sha256_commit_hex() {
let repo = GitRepoCoord {
owner: "a".repeat(64),
id: "repo".to_string(),
};
let meta_sha1 = GitPatchMeta {
commit: Some("c".repeat(40)),
..Default::default()
};
assert!(build_git_patch(&repo, "diff", &meta_sha1).is_ok());
let meta_sha256 = GitPatchMeta {
commit: Some("c".repeat(64)),
..Default::default()
};
assert!(build_git_patch(&repo, "diff", &meta_sha256).is_ok());
}
#[test]
fn git_status_defaults_p_tag_to_repo_owner_when_repo_given() {
// SDK-level: GitStatusMeta.recipients is the caller's responsibility
// (the CLI defaults it), but verify the repo owner ends up p-tagged
// when the CLI-style recipients list includes it.
let owner = "a".repeat(64);
let root = event_id().to_hex();
let meta = GitStatusMeta {
root_event: root,
repo: Some(GitRepoCoord {
owner: owner.clone(),
id: "repo".to_string(),
}),
recipients: vec![owner.clone()],
..Default::default()
};
let ev = sign(build_git_status(GitStatus::Open, "", &meta).unwrap());
assert!(has_tag(&ev, "p", &owner));
}
#[test]
fn git_applied_patch_ref_parse_id_only() {
let id = "a".repeat(64);
let parsed = GitAppliedPatchRef::parse(&id).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, None);
assert_eq!(parsed.pubkey, None);
}
#[test]
fn git_applied_patch_ref_parse_id_and_relay() {
let id = "a".repeat(64);
let spec = format!("{id}:wss://relay.example.com");
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
assert_eq!(parsed.pubkey, None);
}
#[test]
fn git_applied_patch_ref_parse_id_relay_and_pubkey() {
let id = "a".repeat(64);
let pubkey = "b".repeat(64);
let spec = format!("{id}:wss://relay.example.com:{pubkey}");
let parsed = GitAppliedPatchRef::parse(&spec).unwrap();
assert_eq!(parsed.id, id);
assert_eq!(parsed.relay, Some("wss://relay.example.com".to_string()));
assert_eq!(parsed.pubkey, Some(pubkey));
}
#[test]
fn git_status_q_tag_includes_relay_and_pubkey_hints() {
let root = event_id().to_hex();
let patch_id = event_id().to_hex();
let pubkey = "b".repeat(64);
let meta = GitStatusMeta {
root_event: root,
applied_patches: vec![GitAppliedPatchRef {
id: patch_id.clone(),
relay: Some("wss://relay.example.com".to_string()),
pubkey: Some(pubkey.clone()),
}],
..Default::default()
};
let ev = sign(build_git_status(GitStatus::AppliedOrResolved, "", &meta).unwrap());
let q_tag = ev
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("q"))
.expect("q tag present");
let parts = q_tag.as_slice();
assert_eq!(parts.get(1).map(|v| v.as_str()), Some(patch_id.as_str()));
assert_eq!(
parts.get(2).map(|v| v.as_str()),
Some("wss://relay.example.com")
);
assert_eq!(parts.get(3).map(|v| v.as_str()), Some(pubkey.as_str()));
}
// ── Builder 31: build_workflow_def ───────────────────────────────────────
#[test]