mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
Add collaborative Project state CLI
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -16,15 +16,18 @@
|
||||
//! - Deletion durability against later arrival (watermark follow-up) is
|
||||
//! not in scope.
|
||||
|
||||
use buzz_core::kind::KIND_PROJECT;
|
||||
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::BuzzClient;
|
||||
use crate::client::{normalize_events, BuzzClient};
|
||||
use crate::commands::parse_write_response;
|
||||
use crate::commands::project_channel::{
|
||||
repo_id_from_project_slug, require_repo_channel_binding, truncate_repo_name,
|
||||
@@ -481,6 +484,159 @@ pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project_owner(client: &BuzzClient, owner: Option<&str>) -> Result<String, CliError> {
|
||||
let owner = owner
|
||||
.map(str::to_owned)
|
||||
.or_else(|| client.auth_tag_owner_hex())
|
||||
.unwrap_or_else(|| client.keys().public_key().to_hex());
|
||||
crate::validate::validate_hex64(&owner)?;
|
||||
PublicKey::parse(&owner)
|
||||
.map(|key| key.to_hex())
|
||||
.map_err(|error| CliError::Usage(format!("invalid Project owner: {error}")))
|
||||
}
|
||||
|
||||
fn project_coordinate(owner: &str, slug: &str) -> Result<String, CliError> {
|
||||
validate_project_slug(slug)?;
|
||||
Ok(format!("{KIND_PROJECT}:{owner}:{slug}"))
|
||||
}
|
||||
|
||||
fn parse_project_state(
|
||||
event: Event,
|
||||
relay_pubkey: &str,
|
||||
coordinate: &str,
|
||||
) -> Result<(Event, u64), CliError> {
|
||||
let relay_pubkey = PublicKey::parse(relay_pubkey)
|
||||
.map_err(|error| CliError::Other(format!("relay self pubkey is invalid: {error}")))?;
|
||||
let revision = validate_project_state_projection(&event, &relay_pubkey, coordinate)
|
||||
.map_err(|error| CliError::Other(format!("Project State is invalid: {error}")))?;
|
||||
Ok((event, revision))
|
||||
}
|
||||
|
||||
async fn fetch_project_state(
|
||||
client: &BuzzClient,
|
||||
slug: &str,
|
||||
owner: Option<&str>,
|
||||
) -> 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?)
|
||||
.map_err(|error| CliError::Other(format!("relay info is invalid: {error}")))?;
|
||||
let relay_pubkey = relay_info
|
||||
.get("self")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| CliError::Other("relay info is missing its self pubkey".into()))?;
|
||||
crate::validate::validate_hex64(relay_pubkey)
|
||||
.map_err(|_| CliError::Other("relay self pubkey is invalid".into()))?;
|
||||
let relay_pubkey = PublicKey::parse(relay_pubkey)
|
||||
.map_err(|error| CliError::Other(format!("relay self pubkey is invalid: {error}")))?
|
||||
.to_hex();
|
||||
let projection_d = hex::encode(Sha256::digest(coordinate.as_bytes()));
|
||||
let events = client
|
||||
.query_paginated(
|
||||
serde_json::json!({
|
||||
"kinds": [KIND_PROJECT_STATE],
|
||||
"authors": [relay_pubkey],
|
||||
"#d": [projection_d],
|
||||
"#a": [coordinate],
|
||||
}),
|
||||
2,
|
||||
)
|
||||
.await?;
|
||||
if events.len() > 1 {
|
||||
return Err(CliError::Other(
|
||||
"relay returned multiple current Project State events".into(),
|
||||
));
|
||||
}
|
||||
let event = events
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| CliError::NotFound(format!("Project State for {slug:?} not found")))?;
|
||||
let event: Event = serde_json::from_value(event)
|
||||
.map_err(|error| CliError::Other(format!("Project State event is invalid: {error}")))?;
|
||||
parse_project_state(event, &relay_pubkey, &coordinate)
|
||||
}
|
||||
|
||||
fn format_project_state(event: &Event, format: &crate::OutputFormat) -> Result<String, CliError> {
|
||||
let normalized = normalize_events(&[serde_json::to_value(event)
|
||||
.map_err(|error| CliError::Other(format!("Project State encoding failed: {error}")))?]);
|
||||
match format {
|
||||
crate::OutputFormat::Json => Ok(normalized),
|
||||
crate::OutputFormat::Compact => {
|
||||
let events: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&normalized).map_err(|error| {
|
||||
CliError::Other(format!("Project State encoding failed: {error}"))
|
||||
})?;
|
||||
let compact: Vec<serde_json::Value> = events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
serde_json::json!({
|
||||
"id": event.get("id").cloned().unwrap_or_default(),
|
||||
"content": event.get("content").cloned().unwrap_or_default(),
|
||||
"created_at": event.get("created_at").cloned().unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&compact)
|
||||
.map_err(|error| CliError::Other(format!("Project State encoding failed: {error}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_state(
|
||||
client: &BuzzClient,
|
||||
slug: &str,
|
||||
owner: Option<&str>,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
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 builder = build_project_related_channel_change(&coordinate, revision, add, remove)
|
||||
.map_err(|error| CliError::Usage(error.to_string()))?;
|
||||
let event = client.sign_event(builder)?;
|
||||
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,
|
||||
@@ -798,7 +954,11 @@ fn validate_visibility(vis: &str) -> Result<(), CliError> {
|
||||
|
||||
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
pub async fn dispatch(
|
||||
cmd: crate::ProjectsCmd,
|
||||
client: &BuzzClient,
|
||||
format: &crate::OutputFormat,
|
||||
) -> Result<(), CliError> {
|
||||
use crate::ProjectsCmd;
|
||||
match cmd {
|
||||
ProjectsCmd::Create {
|
||||
@@ -821,6 +981,9 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
|
||||
.await
|
||||
}
|
||||
ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await,
|
||||
ProjectsCmd::State { slug, owner } => {
|
||||
cmd_state(client, &slug, owner.as_deref(), format).await
|
||||
}
|
||||
ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await,
|
||||
ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await,
|
||||
ProjectsCmd::AddChannel {
|
||||
@@ -842,6 +1005,12 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
|
||||
)
|
||||
.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,
|
||||
@@ -877,10 +1046,134 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP};
|
||||
use nostr::Tag;
|
||||
use nostr::{Keys, Kind, Tag};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn project_state_event(keys: &Keys, coordinate: &str, revision: &str, content: &str) -> Event {
|
||||
let projection_d = hex::encode(Sha256::digest(coordinate.as_bytes()));
|
||||
EventBuilder::new(Kind::Custom(KIND_PROJECT_STATE as u16), content)
|
||||
.tags([
|
||||
Tag::parse(["d", &projection_d]).expect("d tag"),
|
||||
Tag::parse(["a", coordinate]).expect("a tag"),
|
||||
Tag::parse(["rev", revision]).expect("rev tag"),
|
||||
Tag::parse(["e", &"1".repeat(64), "", "identity"]).expect("identity tag"),
|
||||
Tag::parse(["e", &"2".repeat(64), "", "change"]).expect("change tag"),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.expect("state event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_state_requires_relay_signature_coordinate_and_canonical_body() {
|
||||
let relay = Keys::generate();
|
||||
let owner = "a".repeat(64);
|
||||
let coordinate = format!("30621:{owner}:platform");
|
||||
let content = r#"{"v":1,"deleted":false,"project_tags":[["d","platform"]]}"#;
|
||||
let event = project_state_event(&relay, &coordinate, "7", content);
|
||||
let (_, revision) =
|
||||
parse_project_state(event.clone(), &relay.public_key().to_hex(), &coordinate)
|
||||
.expect("valid state");
|
||||
assert_eq!(revision, 7);
|
||||
|
||||
let impostor = Keys::generate();
|
||||
assert!(parse_project_state(
|
||||
project_state_event(&impostor, &coordinate, "7", content),
|
||||
&relay.public_key().to_hex(),
|
||||
&coordinate,
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_project_state(
|
||||
event,
|
||||
&relay.public_key().to_hex(),
|
||||
&format!("30621:{owner}:other"),
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_project_state(
|
||||
project_state_event(&relay, &coordinate, "07", content),
|
||||
&relay.public_key().to_hex(),
|
||||
&coordinate,
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_project_state(
|
||||
project_state_event(
|
||||
&relay,
|
||||
&coordinate,
|
||||
"7",
|
||||
r#"{"v":1,"deleted":false,"project_tags":[],"future":true}"#,
|
||||
),
|
||||
&relay.public_key().to_hex(),
|
||||
&coordinate,
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_project_state(
|
||||
project_state_event(&relay, &coordinate, "7", &format!(" {content}")),
|
||||
&relay.public_key().to_hex(),
|
||||
&coordinate,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_state_output_honors_global_format() {
|
||||
let relay = Keys::generate();
|
||||
let coordinate = format!("30621:{}:platform", "a".repeat(64));
|
||||
let content = r#"{"v":1,"deleted":false,"project_tags":[["d","platform"]]}"#;
|
||||
let event = project_state_event(&relay, &coordinate, "7", content);
|
||||
|
||||
let json: Vec<serde_json::Value> = serde_json::from_str(
|
||||
&format_project_state(&event, &crate::OutputFormat::Json).expect("json output"),
|
||||
)
|
||||
.expect("JSON array");
|
||||
assert_eq!(json.len(), 1);
|
||||
let object = json[0].as_object().expect("event object");
|
||||
assert_eq!(object.len(), 7);
|
||||
for field in [
|
||||
"id",
|
||||
"pubkey",
|
||||
"kind",
|
||||
"content",
|
||||
"created_at",
|
||||
"tags",
|
||||
"sig",
|
||||
] {
|
||||
assert!(object.contains_key(field), "missing {field}");
|
||||
}
|
||||
|
||||
let compact: Vec<serde_json::Value> = serde_json::from_str(
|
||||
&format_project_state(&event, &crate::OutputFormat::Compact).expect("compact output"),
|
||||
)
|
||||
.expect("compact array");
|
||||
assert_eq!(compact.len(), 1);
|
||||
let compact = compact[0].as_object().expect("compact event object");
|
||||
assert_eq!(compact.len(), 3);
|
||||
assert_eq!(compact.get("id"), object.get("id"));
|
||||
assert_eq!(compact.get("content"), object.get("content"));
|
||||
assert_eq!(compact.get("created_at"), object.get("created_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_owner_defaults_to_delegating_owner() {
|
||||
let actor = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let auth = Tag::parse(["auth", &owner, "kind=47010", &"3".repeat(128)]).expect("auth tag");
|
||||
let client =
|
||||
crate::client::BuzzClient::new("http://127.0.0.1:9".into(), actor, Some(auth), None)
|
||||
.expect("client");
|
||||
assert_eq!(project_owner(&client, None).expect("owner"), owner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_change_cas_conflict_maps_to_exit_five() {
|
||||
let error = map_project_change_submit(Err(CliError::Relay {
|
||||
status: 400,
|
||||
body: "conflict: Project revision is 8".into(),
|
||||
}))
|
||||
.expect_err("conflict");
|
||||
assert!(matches!(error, CliError::Conflict(_)));
|
||||
assert_eq!(crate::error::exit_code(&error), 5);
|
||||
}
|
||||
|
||||
async fn run_default_repo_create_race(
|
||||
winning_channel: &str,
|
||||
) -> (Result<(), CliError>, Vec<u16>) {
|
||||
|
||||
@@ -1316,6 +1316,15 @@ pub enum ProjectsCmd {
|
||||
#[arg(long)]
|
||||
owner: Option<String>,
|
||||
},
|
||||
/// Read the relay-authoritative collaborative Project state
|
||||
State {
|
||||
/// Project slug
|
||||
slug: String,
|
||||
/// Project owner pubkey (64-char hex). Defaults to the current identity
|
||||
/// or its NIP-OA owner when configured.
|
||||
#[arg(long)]
|
||||
owner: Option<String>,
|
||||
},
|
||||
/// List projects
|
||||
List {
|
||||
/// Owner pubkey (64-char hex). Defaults to the current identity.
|
||||
@@ -1356,6 +1365,25 @@ pub enum ProjectsCmd {
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
/// Atomically add or remove related channels at the current 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-char hex). Defaults to the current identity
|
||||
/// or its NIP-OA owner when configured.
|
||||
#[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 {
|
||||
@@ -2087,7 +2115,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
|
||||
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
|
||||
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
|
||||
Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await,
|
||||
Cmd::Projects(sub) => commands::projects::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await,
|
||||
Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await,
|
||||
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
|
||||
@@ -2399,11 +2427,13 @@ mod tests {
|
||||
vec![
|
||||
"add-channel",
|
||||
"add-repo",
|
||||
"change-related-channels",
|
||||
"create",
|
||||
"delete",
|
||||
"get",
|
||||
"list",
|
||||
"remove-repo",
|
||||
"state",
|
||||
"update"
|
||||
]
|
||||
);
|
||||
@@ -2444,7 +2474,7 @@ mod tests {
|
||||
("pack", 2),
|
||||
("patches", 4),
|
||||
("pr", 5),
|
||||
("projects", 8),
|
||||
("projects", 10),
|
||||
("reactions", 3),
|
||||
("repos", 5),
|
||||
("social", 7),
|
||||
@@ -2534,6 +2564,30 @@ mod tests {
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_collaborative_state_commands_parse() {
|
||||
let owner = "a".repeat(64);
|
||||
assert!(
|
||||
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",
|
||||
"--remove",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
])
|
||||
.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.
|
||||
#[test]
|
||||
fn projects_update_multi_field_is_accepted() {
|
||||
|
||||
Reference in New Issue
Block a user