mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(cli): manage repository protection rules (#2193)
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user