mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
feat(projects): unify navigation and reviewed channel requests
Open project homes consistently from channel and project routes, preserve file/work-item deep links, and let agents request owner-reviewed project channels. Signed-off-by: Thomas Petersen <thomasp@squareup.com> Co-authored-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -6,7 +6,8 @@ use serde::Serialize;
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
const REQUEST_KIND: &str = "agent_management_request";
|
||||
const AGENT_REQUEST_KIND: &str = "agent_management_request";
|
||||
const PROJECT_CHANNEL_REQUEST_KIND: &str = "project_channel_request";
|
||||
const MAX_NAME_CHARS: usize = 120;
|
||||
const MAX_PROMPT_CHARS: usize = 20_000;
|
||||
|
||||
@@ -37,6 +38,20 @@ pub struct UpdateAgentDraft {
|
||||
pub respond_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateProjectChannelDraft {
|
||||
pub home_channel_id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub visibility: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ttl_seconds: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManagementRequest<T> {
|
||||
@@ -88,6 +103,7 @@ fn build<T: Serialize>(
|
||||
keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
channel_id: String,
|
||||
request_kind: &'static str,
|
||||
action: &'static str,
|
||||
request: T,
|
||||
) -> Result<BuiltDraftRequest, CliError> {
|
||||
@@ -95,13 +111,13 @@ fn build<T: Serialize>(
|
||||
let payload = ObserverEvent {
|
||||
seq: 0,
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
kind: REQUEST_KIND,
|
||||
kind: request_kind,
|
||||
agent_index: None,
|
||||
channel_id: Some(channel_id),
|
||||
session_id: None,
|
||||
turn_id: None,
|
||||
payload: ManagementRequest {
|
||||
request_type: REQUEST_KIND,
|
||||
request_type: request_kind,
|
||||
action,
|
||||
request_id: request_id.clone(),
|
||||
request,
|
||||
@@ -138,7 +154,14 @@ pub fn build_create(
|
||||
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
|
||||
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
|
||||
};
|
||||
build(keys, owner, channel_id, "create", request)
|
||||
build(
|
||||
keys,
|
||||
owner,
|
||||
channel_id,
|
||||
AGENT_REQUEST_KIND,
|
||||
"create",
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_update(
|
||||
@@ -182,7 +205,50 @@ pub fn build_update(
|
||||
"include at least one field to update".into(),
|
||||
));
|
||||
}
|
||||
build(keys, owner, channel_id, "update", request)
|
||||
build(
|
||||
keys,
|
||||
owner,
|
||||
channel_id,
|
||||
AGENT_REQUEST_KIND,
|
||||
"update",
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_project_channel(
|
||||
keys: &Keys,
|
||||
owner: &PublicKey,
|
||||
draft: CreateProjectChannelDraft,
|
||||
) -> Result<BuiltDraftRequest, CliError> {
|
||||
let home_channel_id = required(draft.home_channel_id, "home channel", 128)?;
|
||||
uuid::Uuid::parse_str(&home_channel_id)
|
||||
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {home_channel_id}")))?;
|
||||
let visibility = required(draft.visibility, "visibility", 16)?;
|
||||
if visibility != "open" && visibility != "private" {
|
||||
return Err(CliError::Usage("visibility must be open or private".into()));
|
||||
}
|
||||
if draft.ttl_seconds == Some(0) {
|
||||
return Err(CliError::Usage("ttl must be greater than zero".into()));
|
||||
}
|
||||
let request = CreateProjectChannelDraft {
|
||||
home_channel_id: home_channel_id.clone(),
|
||||
name: required(draft.name, "name", MAX_NAME_CHARS)?,
|
||||
description: draft
|
||||
.description
|
||||
.map(|value| required(value, "description", 2_048))
|
||||
.transpose()?,
|
||||
visibility,
|
||||
ttl_seconds: draft.ttl_seconds,
|
||||
template_name: optional(draft.template_name, "template")?,
|
||||
};
|
||||
build(
|
||||
keys,
|
||||
owner,
|
||||
home_channel_id,
|
||||
PROJECT_CHANNEL_REQUEST_KIND,
|
||||
"create",
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,9 +294,9 @@ mod tests {
|
||||
.any(|tag| tag.first().map(String::as_str) == Some("h")));
|
||||
|
||||
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
|
||||
assert_eq!(payload["kind"], REQUEST_KIND);
|
||||
assert_eq!(payload["kind"], AGENT_REQUEST_KIND);
|
||||
assert_eq!(payload["channelId"], CHANNEL);
|
||||
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
|
||||
assert_eq!(payload["payload"]["type"], AGENT_REQUEST_KIND);
|
||||
assert_eq!(payload["payload"]["action"], "create");
|
||||
assert_eq!(
|
||||
payload["payload"]["request"]["displayName"],
|
||||
@@ -274,4 +340,34 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("invalid channel UUID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_channel_request_is_owner_encrypted() {
|
||||
let agent = Keys::generate();
|
||||
let owner = Keys::generate();
|
||||
let built = build_project_channel(
|
||||
&agent,
|
||||
&owner.public_key(),
|
||||
CreateProjectChannelDraft {
|
||||
home_channel_id: CHANNEL.into(),
|
||||
name: "release-planning".into(),
|
||||
description: Some("Coordinate the next release.".into()),
|
||||
visibility: "open".into(),
|
||||
ttl_seconds: None,
|
||||
template_name: Some("Release team".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
|
||||
assert_eq!(payload["kind"], PROJECT_CHANNEL_REQUEST_KIND);
|
||||
assert_eq!(payload["channelId"], CHANNEL);
|
||||
assert_eq!(payload["payload"]["type"], PROJECT_CHANNEL_REQUEST_KIND);
|
||||
assert_eq!(payload["payload"]["action"], "create");
|
||||
assert_eq!(payload["payload"]["request"]["homeChannelId"], CHANNEL);
|
||||
assert_eq!(
|
||||
payload["payload"]["request"]["templateName"],
|
||||
"Release team"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,58 @@ use buzz_sdk::{
|
||||
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
|
||||
PROJECT_D_MAX_LEN,
|
||||
};
|
||||
use nostr::{Event, EventBuilder, Tag, Timestamp};
|
||||
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};
|
||||
|
||||
use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
|
||||
use crate::client::BuzzClient;
|
||||
use crate::commands::parse_write_response;
|
||||
use crate::commands::project_channel::repo_id_from_project_slug;
|
||||
use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement};
|
||||
use crate::error::CliError;
|
||||
|
||||
async fn cmd_add_channel_draft(
|
||||
client: &BuzzClient,
|
||||
home_channel: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
visibility: String,
|
||||
ttl_seconds: Option<u64>,
|
||||
template_name: Option<String>,
|
||||
) -> Result<(), CliError> {
|
||||
let owner_hex = client
|
||||
.auth_tag_owner_hex()
|
||||
.ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?;
|
||||
let owner = PublicKey::parse(&owner_hex)
|
||||
.map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?;
|
||||
let built = build_project_channel(
|
||||
client.keys(),
|
||||
&owner,
|
||||
CreateProjectChannelDraft {
|
||||
home_channel_id: home_channel,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
ttl_seconds,
|
||||
template_name,
|
||||
},
|
||||
)?;
|
||||
let response = client.publish_ephemeral_event(built.event).await?;
|
||||
let mut output: serde_json::Value = serde_json::from_str(&response)
|
||||
.map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?;
|
||||
if let Some(object) = output.as_object_mut() {
|
||||
object.insert("request_id".into(), built.request_id.into());
|
||||
object.insert("action".into(), "add-channel".into());
|
||||
object.insert("saved".into(), false.into());
|
||||
object.insert(
|
||||
"message".into(),
|
||||
"Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
println!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Buzz repo-ID grammar (bare --repo shorthand) ─────────────────────────────
|
||||
|
||||
/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand).
|
||||
@@ -739,6 +783,25 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
|
||||
ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).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 {
|
||||
home_channel,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
ttl,
|
||||
template,
|
||||
} => {
|
||||
cmd_add_channel_draft(
|
||||
client,
|
||||
home_channel,
|
||||
name,
|
||||
description,
|
||||
visibility.to_string(),
|
||||
ttl,
|
||||
template,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
|
||||
ProjectsCmd::Update {
|
||||
slug,
|
||||
|
||||
@@ -1334,6 +1334,28 @@ pub enum ProjectsCmd {
|
||||
#[arg(long = "repo", required = true)]
|
||||
repo: Vec<String>,
|
||||
},
|
||||
/// Draft a project-linked channel for owner review in Buzz Desktop
|
||||
#[command(name = "add-channel")]
|
||||
AddChannel {
|
||||
/// Project home channel UUID from the current ACP [Context]
|
||||
#[arg(long)]
|
||||
home_channel: String,
|
||||
/// New channel name
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Optional channel description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Channel visibility
|
||||
#[arg(long, value_enum, default_value = "open")]
|
||||
visibility: ChannelVisibility,
|
||||
/// Optional temporary-channel lifetime in seconds
|
||||
#[arg(long)]
|
||||
ttl: Option<u64>,
|
||||
/// Optional Desktop channel-template name
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
/// Remove one or more member repositories from a project
|
||||
#[command(name = "remove-repo")]
|
||||
RemoveRepo {
|
||||
@@ -2375,6 +2397,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
names(&cmd, "projects"),
|
||||
vec![
|
||||
"add-channel",
|
||||
"add-repo",
|
||||
"create",
|
||||
"delete",
|
||||
@@ -2421,7 +2444,7 @@ mod tests {
|
||||
("pack", 2),
|
||||
("patches", 4),
|
||||
("pr", 5),
|
||||
("projects", 7),
|
||||
("projects", 8),
|
||||
("reactions", 3),
|
||||
("repos", 5),
|
||||
("social", 7),
|
||||
@@ -2492,6 +2515,25 @@ mod tests {
|
||||
|
||||
// ── projects update mutation group ────────────────────────────────────────
|
||||
|
||||
/// Project-channel requests accept the owner-review metadata.
|
||||
#[test]
|
||||
fn projects_add_channel_accepts_owner_review_fields() {
|
||||
assert!(Cli::try_parse_from([
|
||||
"buzz",
|
||||
"projects",
|
||||
"add-channel",
|
||||
"--home-channel",
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"--name",
|
||||
"release-planning",
|
||||
"--visibility",
|
||||
"private",
|
||||
"--template",
|
||||
"Release team",
|
||||
])
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// 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