Allow Project admins to manage related channels

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-01 16:09:32 -04:00
parent cd086971c0
commit abbf98052f
13 changed files with 1478 additions and 64 deletions

View File

@@ -19,11 +19,12 @@
use buzz_core::kind::{KIND_PROJECT, KIND_PROJECT_STATE};
use buzz_core::project_state::validate_project_state_projection;
use buzz_sdk::{
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
PROJECT_D_MAX_LEN,
build_delete_addressable, build_project, build_project_related_channel_change,
build_project_with_tags, ProjectMemberCoord, PROJECT_D_MAX_LEN,
};
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
use crate::client::{normalize_events, BuzzClient};
@@ -503,19 +504,19 @@ fn parse_project_state(
event: Event,
relay_pubkey: &str,
coordinate: &str,
) -> Result<Event, CliError> {
) -> Result<(Event, u64), CliError> {
let relay_pubkey = PublicKey::parse(relay_pubkey)
.map_err(|error| CliError::Other(format!("relay self pubkey is invalid: {error}")))?;
validate_project_state_projection(&event, &relay_pubkey, coordinate)
let revision = validate_project_state_projection(&event, &relay_pubkey, coordinate)
.map_err(|error| CliError::Other(format!("Project State is invalid: {error}")))?;
Ok(event)
Ok((event, revision))
}
async fn fetch_project_state(
client: &BuzzClient,
slug: &str,
owner: Option<&str>,
) -> Result<Event, CliError> {
) -> Result<(Event, u64), CliError> {
let owner = project_owner(client, owner)?;
let coordinate = project_coordinate(&owner, slug)?;
let relay_info: serde_json::Value = serde_json::from_str(&client.get_public("/").await?)
@@ -587,11 +588,56 @@ async fn cmd_state(
owner: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let event = fetch_project_state(client, slug, owner).await?;
let (event, _) = fetch_project_state(client, slug, owner).await?;
println!("{}", format_project_state(&event, format)?);
Ok(())
}
async fn cmd_change_related_channels(
client: &BuzzClient,
slug: &str,
owner: Option<&str>,
add: &[Uuid],
remove: &[Uuid],
) -> Result<(), CliError> {
let owner = project_owner(client, owner)?;
let coordinate = project_coordinate(&owner, slug)?;
let (_, revision) = fetch_project_state(client, slug, Some(&owner)).await?;
let event = client.sign_event(
build_project_related_channel_change(&coordinate, revision, add, remove)
.map_err(|error| CliError::Usage(error.to_string()))?,
)?;
let raw = map_project_change_submit(client.submit_event(event).await)?;
let response: serde_json::Value = serde_json::from_str(&raw)
.map_err(|error| CliError::Other(format!("relay response is invalid: {error}")))?;
if !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("Project change was rejected");
return if message.starts_with("conflict:") {
Err(CliError::Conflict(message.into()))
} else {
Err(CliError::Other(format!("relay rejected event: {message}")))
};
}
println!("{}", crate::client::normalize_write_response(&raw));
Ok(())
}
fn map_project_change_submit(result: Result<String, CliError>) -> Result<String, CliError> {
match result {
Err(CliError::Relay { status: 400, body }) if body.starts_with("conflict:") => {
Err(CliError::Conflict(body))
}
result => result,
}
}
/// `buzz projects list`
pub async fn cmd_list(
client: &BuzzClient,
@@ -960,6 +1006,12 @@ pub async fn dispatch(
)
.await
}
ProjectsCmd::ChangeRelatedChannels {
slug,
owner,
add,
remove,
} => cmd_change_related_channels(client, &slug, owner.as_deref(), &add, &remove).await,
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
ProjectsCmd::Update {
slug,
@@ -1760,6 +1812,16 @@ mod tests {
.expect("client construction")
}
#[test]
fn project_change_conflict_uses_the_cli_conflict_exit_path() {
let error = map_project_change_submit(Err(CliError::Relay {
status: 400,
body: "conflict: Project revision is 9".into(),
}))
.expect_err("conflict response must remain distinguishable");
assert!(matches!(error, CliError::Conflict(_)));
}
/// Creating without --repo or --channel must fail locally; the default
/// repository needs a home channel to bind as git ACL.
#[tokio::test]

View File

@@ -1365,6 +1365,24 @@ pub enum ProjectsCmd {
#[arg(long)]
template: Option<String>,
},
/// Atomically add or remove related channels at the current Project revision
#[command(
name = "change-related-channels",
group = clap::ArgGroup::new("related-channel-mutation").required(true).multiple(true)
)]
ChangeRelatedChannels {
/// Project slug
slug: String,
/// Project owner pubkey (64-character lowercase hex)
#[arg(long)]
owner: Option<String>,
/// Related channel UUID to add (repeatable)
#[arg(long = "add", group = "related-channel-mutation")]
add: Vec<Uuid>,
/// Related channel UUID to remove (repeatable)
#[arg(long = "remove", group = "related-channel-mutation")]
remove: Vec<Uuid>,
},
/// Remove one or more member repositories from a project
#[command(name = "remove-repo")]
RemoveRepo {
@@ -2408,6 +2426,7 @@ mod tests {
vec![
"add-channel",
"add-repo",
"change-related-channels",
"create",
"delete",
"get",
@@ -2454,7 +2473,7 @@ mod tests {
("pack", 2),
("patches", 4),
("pr", 5),
("projects", 9),
("projects", 10),
("reactions", 3),
("repos", 5),
("social", 7),
@@ -2551,6 +2570,19 @@ mod tests {
Cli::try_parse_from(["buzz", "projects", "state", "platform", "--owner", &owner,])
.is_ok()
);
assert!(Cli::try_parse_from([
"buzz",
"projects",
"change-related-channels",
"platform",
"--add",
"11111111-1111-4111-8111-111111111111",
])
.is_ok());
assert!(
Cli::try_parse_from(["buzz", "projects", "change-related-channels", "platform",])
.is_err()
);
}
/// Multiple independent fields must be accepted in the same invocation.