feat(cli): manage repository protection rules (#2193)

This commit is contained in:
thomaspblock
2026-07-21 08:57:08 +02:00
committed by GitHub
parent daf1f40a52
commit f94324598d
6 changed files with 684 additions and 16 deletions

View File

@@ -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 <slug> "my-value"
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
buzz mem rm <slug>
# 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 <group> <subcommand> [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
```

View File

@@ -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 |

View File

@@ -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<Vec<Event>, 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<Option<Event>, 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<Tag, CliError> {
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<Tag>),
Remove(String),
}
fn build_updated_repo_announcement(
existing: &Event,
change: ProtectionChange,
) -> Result<EventBuilder, CliError> {
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<Tag> = 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<Vec<String>> = 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<serde_json::Value, CliError> {
let raw_tags: Vec<Vec<String>> = 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<serde_json::Value> = 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<String, CliError> {
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<Event, CliError> {
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<crate::RepoPushRole>,
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<Tag>, 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::<serde_json::Value>(&output).expect("normalized JSON"),
serde_json::json!({
"event_id": "abc",
"accepted": true,
"message": "saved",
})
);
}
}

View File

@@ -1120,6 +1120,61 @@ pub enum ReposCmd {
#[arg(long)]
limit: Option<u32>,
},
/// 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<RepoPushRole>,
/// 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)]
@@ -1873,7 +1928,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<String> = 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"]
@@ -1920,7 +1993,7 @@ mod tests {
("patches", 4),
("pr", 5),
("reactions", 3),
("repos", 3),
("repos", 4),
("social", 7),
("upload", 1),
("users", 4),

View File

@@ -931,6 +931,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<Tag>,
) -> Result<EventBuilder, SdkError> {
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
@@ -2751,6 +2768,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();

View File

@@ -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 `<relay>/git/<your-pubkey>/<repo-id>` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id <id> --clone <relay>/git/<your-pubkey>/<id>`, then `git remote add origin <that-url>` 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 |