diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 304f656384..a8c668cf06 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -22,7 +22,7 @@ buzz channels list ## Usage -All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other. +All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. ```bash # Set relay URL (defaults to http://localhost:3000) @@ -82,11 +82,20 @@ buzz mem set "my-value" buzz mem patch --base-hash < diff.patch # or --no-base-hash buzz mem rm +# Repository protection +buzz repos protect list --id my-repo +buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete +buzz repos protect remove --id my-repo --ref refs/heads/main + # Pipe to jq buzz channels list | jq '.[].name' ``` -## 60 Subcommands across 13 Groups +`protect set` replaces every existing rule for the exact ref pattern. Any +constraint omitted from the command is removed. `protect list` reports malformed +stored rules in `validation_error` so an owner can remove and repair them. + +## Commands | Group | Subcommand | Description | |-------|-----------|-------------| @@ -141,6 +150,9 @@ buzz channels list | jq '.[].name' | `repos` | `create` | Announce a git repository (NIP-34) | | | `get` | Get a repository announcement | | | `list` | List repository announcements | +| | `protect list` | List branch and tag protection rules | +| | `protect set` | Create or replace a protection rule | +| | `protect remove` | Remove a protection rule | | `upload` | `file` | Upload a file to the Blossom store | | `pack` | `validate` | Validate a persona pack (local, no relay) | | | `inspect` | Inspect a persona pack (local, no relay) | @@ -164,5 +176,5 @@ buzz [flags] stdout: raw relay JSON stderr: {"error": "category", "message": "detail"} -exit: 0=ok 1=user 2=network 3=auth 4=other +exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict ``` diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 1b0bb3fafc..4b7257aba7 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -62,7 +62,7 @@ via direct DB access. Use this for testing admin operations (archive, delete-channel, add/remove-channel-member). ```bash -DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ +DATABASE_URL="${DATABASE_URL:?set DATABASE_URL for the local Buzz database}" \ cargo run -p buzz-admin -- mint-token \ --name "cli-test" \ --scopes "messages:read,messages:write,channels:read,channels:write,users:read,users:write,files:read,files:write,admin:channels" @@ -596,10 +596,13 @@ buzz channels delete --channel "$FORUM_ID" | jq . | 49 | `repos create` | ☐ | | | 50 | `repos get` | ☐ | | | 51 | `repos list` | ☐ | | -| 52 | `upload file` | ☐ | | -| 53 | `pack validate` | ☐ | Local, no relay | -| 54 | `pack inspect` | ☐ | Local, no relay | -| 55 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard | -| 56 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | -| 57 | `notes ls` | ☐ | Own, --author all, --tag, --limit | -| 58 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | +| 52 | `repos protect list` | ☐ | Empty/populated rules; unknown rules visible; malformed rule reported in validation_error | +| 53 | `repos protect set` | ☐ | Create and replace complete exact-ref rule; verify metadata is preserved | +| 54 | `repos protect remove` | ☐ | Remove exact ref; missing rule → NotFound | +| 55 | `upload file` | ☐ | | +| 56 | `pack validate` | ☐ | Local, no relay | +| 57 | `pack inspect` | ☐ | Local, no relay | +| 58 | `notes set` | ☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard | +| 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 | +| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit | +| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound | diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index c56984d601..0f570df1aa 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -1,7 +1,204 @@ -use crate::client::BuzzClient; +use buzz_core::{ + git_perms::{parse_protection_tag, parse_protection_tags, RefPattern}, + kind::KIND_GIT_REPO_ANNOUNCEMENT, +}; +use nostr::{Event, EventBuilder, Tag, Timestamp}; + +use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_repo_id; +fn parse_events(json: &str) -> Result, CliError> { + serde_json::from_str(json) + .map_err(|error| CliError::Other(format!("failed to parse relay response: {error}"))) +} + +async fn fetch_own_repo_announcement( + client: &BuzzClient, + repo_id: &str, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [KIND_GIT_REPO_ANNOUNCEMENT], + "authors": [client.keys().public_key().to_hex()], + "#d": [repo_id], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let mut events = parse_events(&raw)?; + events.sort_by_key(|event| std::cmp::Reverse(event.created_at)); + Ok(events.into_iter().next()) +} + +fn repo_id_from_event(event: &Event) -> Result<&str, CliError> { + event + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("d")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }) + .ok_or_else(|| CliError::Other("repository announcement is missing its d tag".into())) +} + +fn tag_error(error: impl std::fmt::Display) -> CliError { + CliError::Other(format!("failed to build protection tag: {error}")) +} + +fn protection_pattern(tag: &Tag) -> Option<&str> { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("buzz-protect")) + .then(|| values.get(1).map(String::as_str)) + .flatten() +} + +fn has_tag_name(tag: &Tag, name: &str) -> bool { + tag.as_slice().first().map(String::as_str) == Some(name) +} + +fn build_protection_tag( + ref_pattern: &str, + push_role: Option<&str>, + no_force_push: bool, + no_delete: bool, + require_patch: bool, +) -> Result { + let mut values = vec!["buzz-protect".to_string(), ref_pattern.to_string()]; + if let Some(role) = push_role { + values.push(format!("push:{role}")); + } + if no_force_push { + values.push("no-force-push".into()); + } + if no_delete { + values.push("no-delete".into()); + } + if require_patch { + values.push("require-patch".into()); + } + let rule_values: Vec<&str> = values[1..].iter().map(String::as_str).collect(); + parse_protection_tag(&rule_values) + .map_err(|error| CliError::Usage(format!("invalid protection rule: {error}")))?; + Tag::parse(values).map_err(tag_error) +} + +enum ProtectionChange { + Set(Box), + Remove(String), +} + +fn build_updated_repo_announcement( + existing: &Event, + change: ProtectionChange, +) -> Result { + let repo_id = repo_id_from_event(existing)?; + let (pattern, replacement) = match change { + ProtectionChange::Set(tag) => { + let pattern = protection_pattern(&tag) + .ok_or_else(|| CliError::Other("replacement is not a protection tag".into()))? + .to_string(); + (pattern, Some(*tag)) + } + ProtectionChange::Remove(pattern) => { + RefPattern::parse(&pattern) + .map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?; + (pattern, None) + } + }; + + let mut tags: Vec = existing + .tags + .iter() + .filter(|tag| { + !has_tag_name(tag, "auth") && protection_pattern(tag) != Some(pattern.as_str()) + }) + .cloned() + .collect(); + if let Some(tag) = replacement { + tags.push(tag); + } + + let raw_tags: Vec> = tags.iter().map(|tag| tag.as_slice().to_vec()).collect(); + parse_protection_tags(&raw_tags).map_err(|error| { + CliError::Other(format!( + "repository contains invalid protection rules; refusing update: {error}" + )) + })?; + + // Advance only the observed head. Using wall-clock time here would let a + // delayed writer leapfrog an intervening update and silently erase metadata. + let next_created_at = existing + .created_at + .as_secs() + .checked_add(1) + .ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?; + buzz_sdk::build_repo_announcement_with_tags(repo_id, &existing.content, tags) + .map_err(|error| CliError::Other(format!("failed to build repository update: {error}"))) + .map(|builder| builder.custom_created_at(Timestamp::from(next_created_at))) +} + +fn protection_rules_json(event: &Event) -> Result { + let raw_tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + let (unknown_rules, validation_error) = match parse_protection_tags(&raw_tags) { + Ok(parsed) => (parsed.unknown_rules, None), + Err(error) => (Vec::new(), Some(error.to_string())), + }; + let protections: Vec = event + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("buzz-protect")).then(|| { + serde_json::json!({ + "ref": values.get(1).map(String::as_str).unwrap_or(""), + "rules": values.get(2..).unwrap_or_default(), + }) + }) + }) + .collect(); + + Ok(serde_json::json!({ + "repo_id": repo_id_from_event(event)?, + "protections": protections, + "unknown_rules": unknown_rules, + "validation_error": validation_error, + })) +} + +fn validate_write_response(raw: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict( + "repository changed concurrently; fetch the latest rules and retry".into(), + )); + } + Ok(normalize_write_response(raw)) +} + +async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let raw = client.submit_event(event).await?; + println!("{}", validate_write_response(&raw)?); + Ok(()) +} + pub async fn cmd_create_repo( client: &BuzzClient, repo_id: &str, @@ -84,8 +281,73 @@ pub async fn cmd_list_repos( Ok(()) } +async fn current_repo(client: &BuzzClient, repo_id: &str) -> Result { + validate_repo_id(repo_id)?; + fetch_own_repo_announcement(client, repo_id) + .await? + .ok_or_else(|| { + CliError::NotFound(format!( + "repository {repo_id:?} was not found for the current identity" + )) + }) +} + +async fn cmd_protect_list(client: &BuzzClient, repo_id: &str) -> Result<(), CliError> { + let event = current_repo(client, repo_id).await?; + println!("{}", protection_rules_json(&event)?); + Ok(()) +} + +async fn cmd_protect_set( + client: &BuzzClient, + repo_id: &str, + ref_pattern: &str, + push_role: Option, + no_force_push: bool, + no_delete: bool, + require_patch: bool, +) -> Result<(), CliError> { + let push_role = push_role.map(|role| match role { + crate::RepoPushRole::Owner => "owner", + crate::RepoPushRole::Admin => "admin", + crate::RepoPushRole::Member => "member", + }); + let tag = build_protection_tag( + ref_pattern, + push_role, + no_force_push, + no_delete, + require_patch, + )?; + let event = current_repo(client, repo_id).await?; + let builder = build_updated_repo_announcement(&event, ProtectionChange::Set(Box::new(tag)))?; + submit_repo_update(client, builder).await +} + +async fn cmd_protect_remove( + client: &BuzzClient, + repo_id: &str, + ref_pattern: &str, +) -> Result<(), CliError> { + RefPattern::parse(ref_pattern) + .map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?; + let event = current_repo(client, repo_id).await?; + if !event + .tags + .iter() + .any(|tag| protection_pattern(tag) == Some(ref_pattern)) + { + return Err(CliError::NotFound(format!( + "repository {repo_id:?} has no protection rule for {ref_pattern:?}" + ))); + } + let builder = + build_updated_repo_announcement(&event, ProtectionChange::Remove(ref_pattern.to_string()))?; + submit_repo_update(client, builder).await +} + pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> { - use crate::ReposCmd; + use crate::{ReposCmd, ReposProtectCmd}; match cmd { ReposCmd::Create { id, @@ -108,5 +370,275 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C } ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await, ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await, + ReposCmd::Protect(command) => match command { + ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await, + ReposProtectCmd::Set { + id, + ref_pattern, + push, + no_force_push, + no_delete, + require_patch, + } => { + cmd_protect_set( + client, + &id, + &ref_pattern, + push, + no_force_push, + no_delete, + require_patch, + ) + .await + } + ReposProtectCmd::Remove { id, ref_pattern } => { + cmd_protect_remove(client, &id, &ref_pattern).await + } + }, + } +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + use super::{ + build_protection_tag, build_updated_repo_announcement, protection_rules_json, + validate_write_response, ProtectionChange, + }; + + fn signed_repo(tags: Vec, content: &str, created_at: u64) -> nostr::Event { + EventBuilder::new(Kind::Custom(30617), content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(&Keys::generate()) + .expect("sign repository event") + } + + fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).expect("valid test tag") + } + + #[test] + fn protection_update_preserves_metadata_and_replaces_only_matching_pattern() { + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&["name", "Demo"]), + tag(&["buzz-channel", "channel-id"]), + tag(&["future-metadata", "preserve-me"]), + tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + tag(&["buzz-protect", "refs/heads/main", "push:member"]), + tag(&["buzz-protect", "refs/tags/*", "no-delete"]), + ], + "repository content", + 100, + ); + let replacement = build_protection_tag("refs/heads/main", Some("admin"), true, true, false) + .expect("valid replacement"); + + let updated = build_updated_repo_announcement( + &existing, + ProtectionChange::Set(Box::new(replacement)), + ) + .expect("build update") + .sign_with_keys(&Keys::generate()) + .expect("sign update"); + + assert_eq!(updated.content, "repository content"); + assert_eq!(updated.created_at.as_secs(), 101); + assert!(!updated + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth"))); + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-channel", "channel-id"])); + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"])); + assert!(updated.tags.iter().any(|tag| { + tag.as_slice() + == [ + "buzz-protect", + "refs/heads/main", + "push:admin", + "no-force-push", + "no-delete", + ] + })); + assert!(updated + .tags + .iter() + .any(|tag| { tag.as_slice() == ["buzz-protect", "refs/tags/*", "no-delete"] })); + assert_eq!( + updated + .tags + .iter() + .filter(|tag| { + let values = tag.as_slice(); + values.first().map(String::as_str) == Some("buzz-protect") + && values.get(1).map(String::as_str) == Some("refs/heads/main") + }) + .count(), + 1 + ); + } + + #[test] + fn protection_remove_preserves_other_patterns() { + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&["buzz-protect", "refs/heads/main", "no-delete"]), + tag(&["buzz-protect", "refs/heads/release", "push:owner"]), + ], + "", + 10, + ); + + let updated = build_updated_repo_announcement( + &existing, + ProtectionChange::Remove("refs/heads/main".into()), + ) + .expect("build removal") + .sign_with_keys(&Keys::generate()) + .expect("sign removal"); + + assert!(!updated + .tags + .iter() + .any(|tag| tag.as_slice().get(1).map(String::as_str) == Some("refs/heads/main"))); + assert!(updated + .tags + .iter() + .any(|tag| { tag.as_slice() == ["buzz-protect", "refs/heads/release", "push:owner"] })); + } + + #[test] + fn protection_set_requires_at_least_one_rule() { + assert!(build_protection_tag("refs/heads/main", None, false, false, false).is_err()); + } + + #[test] + fn protection_update_rejects_malformed_existing_rules() { + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&["buzz-protect", "refs/heads/main"]), + ], + "", + 10, + ); + let replacement = + build_protection_tag("refs/heads/release", Some("admin"), false, false, false) + .expect("valid replacement"); + + let error = build_updated_repo_announcement( + &existing, + ProtectionChange::Set(Box::new(replacement)), + ) + .expect_err("malformed existing rule must fail closed"); + + assert!(error + .to_string() + .contains("repository contains invalid protection rules")); + } + + #[test] + fn protection_update_enforces_repository_rule_limit() { + let mut tags = vec![tag(&["d", "demo"])]; + for index in 0..50 { + tags.push(tag(&[ + "buzz-protect", + &format!("refs/heads/branch-{index}"), + "push:member", + ])); + } + let existing = signed_repo(tags, "", 10); + let replacement = + build_protection_tag("refs/heads/main", Some("admin"), false, false, false) + .expect("valid replacement"); + + let error = build_updated_repo_announcement( + &existing, + ProtectionChange::Set(Box::new(replacement)), + ) + .expect_err("the 51st rule must be rejected"); + + assert!(error.to_string().contains("exceeds max 50")); + } + + #[test] + fn protection_list_keeps_unknown_rules_visible() { + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&[ + "buzz-protect", + "refs/heads/main", + "push:admin", + "future-rule", + ]), + ], + "", + 10, + ); + + let json = protection_rules_json(&existing).expect("list protections"); + assert_eq!(json["repo_id"], "demo"); + assert_eq!(json["protections"][0]["ref"], "refs/heads/main"); + assert_eq!( + json["protections"][0]["rules"], + serde_json::json!(["push:admin", "future-rule"]) + ); + assert_eq!(json["validation_error"], serde_json::Value::Null); + } + + #[test] + fn protection_list_surfaces_malformed_rules_for_recovery() { + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&["buzz-protect", "refs/heads/main"]), + ], + "", + 10, + ); + + let json = protection_rules_json(&existing).expect("list malformed protections"); + assert_eq!(json["protections"][0]["ref"], "refs/heads/main"); + assert!(json["validation_error"] + .as_str() + .is_some_and(|error| error.contains("needs pattern + at least one rule"))); + } + + #[test] + fn duplicate_write_response_is_a_conflict() { + let error = validate_write_response( + r#"{"event_id":"abc","accepted":true,"message":"duplicate: superseded"}"#, + ) + .expect_err("dominated writes must not report success"); + + assert!(matches!(error, crate::error::CliError::Conflict(_))); + } + + #[test] + fn successful_write_response_is_normalized() { + let output = validate_write_response( + r#"{"event_id":"abc","accepted":true,"message":"saved","extra":"ignored"}"#, + ) + .expect("accepted write"); + + assert_eq!( + serde_json::from_str::(&output).expect("normalized JSON"), + serde_json::json!({ + "event_id": "abc", + "accepted": true, + "message": "saved", + }) + ); } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 262334b23e..06be822de6 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1070,6 +1070,61 @@ pub enum ReposCmd { #[arg(long)] limit: Option, }, + /// Manage branch and tag protection rules on one of your repositories. + #[command(subcommand)] + Protect(ReposProtectCmd), +} + +/// Commands for inspecting and changing repository protection rules. +#[derive(Subcommand)] +pub enum ReposProtectCmd { + /// List the repository's protection rules. + List { + /// Repository identifier (d-tag). + #[arg(long)] + id: String, + }, + /// Create or replace the rule for an exact ref pattern. + Set { + /// Repository identifier (d-tag). + #[arg(long)] + id: String, + /// Full ref pattern, such as refs/heads/main or refs/heads/*. + #[arg(long = "ref")] + ref_pattern: String, + /// Minimum role allowed to push. + #[arg(long)] + push: Option, + /// Reject non-fast-forward updates. + #[arg(long, default_value_t = false)] + no_force_push: bool, + /// Reject deletion of matching refs. + #[arg(long, default_value_t = false)] + no_delete: bool, + /// Require the NIP-34 patch workflow instead of direct pushes. + #[arg(long, default_value_t = false)] + require_patch: bool, + }, + /// Remove every protection rule for an exact ref pattern. + Remove { + /// Repository identifier (d-tag). + #[arg(long)] + id: String, + /// Full ref pattern to remove. + #[arg(long = "ref")] + ref_pattern: String, + }, +} + +/// Minimum channel role accepted by a repository push rule. +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum RepoPushRole { + /// Repository owner only. + Owner, + /// Repository owner or channel admin. + Admin, + /// Any channel member. + Member, } #[derive(Subcommand)] @@ -1814,7 +1869,25 @@ mod tests { "set-list" ] ); - assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]); + assert_eq!( + names(&cmd, "repos"), + vec!["create", "get", "list", "protect"] + ); + let repos = cmd + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "repos") + .expect("repos command"); + let protect = repos + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "protect") + .expect("repos protect command"); + let mut protect_names: Vec = protect + .get_subcommands() + .map(|subcommand| subcommand.get_name().to_string()) + .filter(|name| name != "help") + .collect(); + protect_names.sort(); + assert_eq!(protect_names, vec!["list", "remove", "set"]); assert_eq!( names(&cmd, "pr"), vec!["get", "list", "open", "status", "update"] @@ -1861,7 +1934,7 @@ mod tests { ("patches", 4), ("pr", 5), ("reactions", 3), - ("repos", 3), + ("repos", 4), ("social", 7), ("upload", 1), ("users", 4), diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 31493c8985..3ad12fa2df 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -930,6 +930,23 @@ pub fn build_repo_announcement( Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), "").tags(tags)) } +/// Build a repository announcement while preserving caller-supplied metadata. +/// +/// This is intended for read-modify-write updates to kind:30617 announcements. +/// Every supplied tag is retained except `d`; all existing `d` tags are replaced +/// with exactly one validated canonical repository identifier. +pub fn build_repo_announcement_with_tags( + repo_id: &str, + content: &str, + mut tags: Vec, +) -> Result { + check_repo_id(repo_id)?; + tags.retain(|tag| tag.as_slice().first().map(String::as_str) != Some("d")); + tags.insert(0, tag(&["d", repo_id])?); + + Ok(EventBuilder::new(Kind::Custom(KIND_GIT_REPO_ANNOUNCEMENT as u16), content).tags(tags)) +} + /// Repository coordinate — owner pubkey + `d`-tag identifier. /// /// Renders as the `a`-tag value clients use to address a kind:30617 @@ -2613,6 +2630,34 @@ mod tests { .any(|t| t.as_slice().first().map(|v| v.as_str()) == Some("clone"))); } + #[test] + fn repo_announcement_with_tags_preserves_metadata_and_canonicalizes_d() { + let tags = vec![ + Tag::parse(["d", "wrong-repo"]).unwrap(), + Tag::parse(["name", "Protected Repo"]).unwrap(), + Tag::parse(["buzz-channel", "channel-id"]).unwrap(), + Tag::parse(["future-metadata", "preserve-me"]).unwrap(), + ]; + + let ev = sign( + build_repo_announcement_with_tags("protected-repo", "repository content", tags) + .unwrap(), + ); + + assert_eq!(ev.kind.as_u16(), 30617); + assert_eq!(ev.content, "repository content"); + assert_eq!( + ev.tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("d")) + .count(), + 1 + ); + assert!(has_tag(&ev, "d", "protected-repo")); + assert!(has_tag(&ev, "buzz-channel", "channel-id")); + assert!(has_tag(&ev, "future-metadata", "preserve-me")); + } + #[test] fn repo_announcement_rejects_empty_repo_id() { let err = build_repo_announcement("", None, None, &[], None, &[]).unwrap_err(); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index e3e15aa0ed..fefdfa77fa 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -45,6 +45,8 @@ Run `buzz agents draft-update --help` for optional runtime, provider, model, ren Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `/git//` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id --clone /git//`, then `git remote add origin ` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol. +Manage your repository's enforced branch and tag rules with `repos protect list|set|remove`. Ref patterns must use full Git names such as `refs/heads/main` or `refs/tags/*`; supported rules are `--push owner|admin|member`, `--no-force-push`, `--no-delete`, and `--require-patch`. `protect set` replaces the complete rule for that exact pattern, so omitted constraints are removed. Protection updates preserve every unrelated metadata tag and return exit code 5 when a newer NIP-33 head wins a concurrent write. + ## Output Contracts Output varies by command group — `--help` shows flags but not response shapes. @@ -58,7 +60,8 @@ Output varies by command group — `--help` shows flags but not response shapes. | Command | Output | |---------|--------| | `canvas get` | raw markdown string or `null` — NOT a JSON envelope | -| `social *`, `repos *` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above | +| `social *`, `repos get/list` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above | +| `repos protect list` | `{repo_id, protections: [{ref, rules}], unknown_rules, validation_error}` | | `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded}` | | `mem get` | raw bytes to stdout, no trailing newline | | `mem hash` | SHA-256 hex string |