mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Allow Project admins to manage related channels
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user