diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs new file mode 100644 index 0000000000..df87228e3f --- /dev/null +++ b/crates/buzz-cli/src/commands/issues.rs @@ -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, +) -> 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 + } + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 35c99301bd..695b128c13 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -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; diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs new file mode 100644 index 0000000000..2d6ed8beb7 --- /dev/null +++ b/crates/buzz-cli/src/commands/patches.rs @@ -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, +) -> 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::, _>>()?; + + 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 { + 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(_))); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c9507dceba..b9a477b667 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -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 --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner --repo-id myrepo --patch-file 0001-fix.patch --reply-to " + )] + 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, + /// Additional recipient pubkey(s) — can be specified multiple times + #[arg(long = "to")] + to: Vec, + /// Previous patch event id (series) or original root (revision) + #[arg(long)] + reply_to: Option, + /// 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, + /// Parent commit ID + #[arg(long)] + parent_commit: Option, + /// PGP signature of the commit + #[arg(long)] + commit_pgp_sig: Option, + /// Committer identity: 'name|email|timestamp|tz-offset-minutes' + #[arg(long)] + committer: Option, + }, + /// 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, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// 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, + /// Repo owner pubkey — requires --repo-id + #[arg(long, requires = "repo_id")] + repo_owner: Option, + /// Repo identifier (d-tag) — requires --repo-owner + #[arg(long, requires = "repo_owner")] + repo_id: Option, + /// Earliest-unique-commit of the repo + #[arg(long)] + euc: Option, + /// Root id of the revision that was accepted (status=merged only) + #[arg(long)] + revision: Option, + /// 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, + /// Applied patch event id — can be specified multiple times (status=merged only). + /// Accepts ``, `:`, or `::`. + #[arg(long = "q")] + q: Vec, + /// Merge commit id (status=merged only) + #[arg(long)] + merge_commit: Option, + /// Commit id applied to the target branch — can be specified multiple times (status=merged only) + #[arg(long = "applied-as-commit")] + applied_as_commit: Vec, + }, +} + +// --------------------------------------------------------------------------- +// 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, + /// Additional recipient pubkey(s) — can be specified multiple times + #[arg(long = "to")] + to: Vec, + }, + /// 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, + /// Filter by label + #[arg(long)] + label: Option, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// 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, + /// Repo owner pubkey — requires --repo-id + #[arg(long, requires = "repo_id")] + repo_owner: Option, + /// Repo identifier (d-tag) — requires --repo-owner + #[arg(long, requires = "repo_owner")] + repo_id: Option, + /// Earliest-unique-commit of the repo + #[arg(long)] + euc: Option, + /// 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, + }, +} + // --------------------------------------------------------------------------- // 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), diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 1a73d3770b..ec363f73a6 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -178,6 +178,25 @@ pub fn read_or_stdin(value: &str) -> Result { } } +/// 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 { + 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(_))); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index bf6910cae8..afc6d864a3 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -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 { 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 { 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 { + 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 { // 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::`. +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 { + 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, + /// Additional pubkeys to `p`-tag besides the repo owner. + pub recipients: Vec, + /// 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, + /// 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, + /// Parent commit ID (`parent-commit` tag). + pub parent_commit: Option, + /// PGP signature of the commit, or `Some("")` for an explicitly unsigned commit. + pub commit_pgp_sig: Option, + /// 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 { + 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, + /// Additional pubkeys to `p`-tag besides the repo owner. + pub recipients: Vec, +} + +/// 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 { + 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', , , ]`. +/// +/// Parsed from the CLI's `--q [:[:]]` 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, + /// 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, +} + +impl GitAppliedPatchRef { + /// Parse ``, `:`, or `::`. + /// + /// 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 { + 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, + /// Repo coordinate, included as an `a` tag for subscription efficiency. + pub repo: Option, + /// Earliest-unique-commit of the repo (`r` tag, no marker). + pub euc: Option, + /// Additional `p` tags (root author, revision author, etc.) besides the repo owner. + pub recipients: Vec, + /// Applied/merged patch event references (`q` tags) — kind:1631 only. + pub applied_patches: Vec, + /// Merge commit id — kind:1631 only. + pub merge_commit: Option, + /// Commit ids applied to the target branch — kind:1631 only. + pub applied_as_commits: Vec, +} + +/// 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 { + 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]