Add collaborative Project state CLI

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-01 13:11:50 -04:00
parent b1a5738176
commit 2fc8d83cae
3 changed files with 675 additions and 11 deletions

View File

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

View File

@@ -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() {