Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,141 @@ pub async fn cmd_send_message(
Ok(())
}

pub struct WorkReportParams {
pub channel_id: String,
pub thread_root: String,
pub status: crate::ReportStatus,
pub outcome: String,
pub deliverables: Vec<String>,
pub decisions: Vec<String>,
pub verification: Vec<String>,
pub risks: Vec<String>,
pub next_actions: Vec<String>,
pub prior: Option<String>,
}

pub async fn cmd_publish_work_report(
client: &BuzzClient,
params: WorkReportParams,
) -> Result<(), CliError> {
let channel_id = parse_uuid(&params.channel_id)?;
let root_event = fetch_event(client, &params.thread_root).await?;
let root_channel = channel_id_from_event(&params.thread_root, &root_event)?;
if root_channel != channel_id {
return Err(CliError::Usage(format!(
"thread root {} does not belong to channel {}",
params.thread_root, params.channel_id
)));
}
let resolved_root = thread_ref_from_event(&params.thread_root, &root_event)?
.root_event_id
.to_hex();
if !resolved_root.eq_ignore_ascii_case(&params.thread_root) {
return Err(CliError::Usage(format!(
"--thread must reference the thread root (use {resolved_root})"
)));
}
let thread_root = parse_event_id(&params.thread_root)?;
let head_filter = serde_json::json!({
"kinds": [40009],
"#h": [params.channel_id.as_str()],
"#e": [params.thread_root.as_str()],
"limit": 100
});
let mut report_events = fetch_events(client, &head_filter)
.await
.ok_or_else(|| CliError::Other("could not load the current work-report head".into()))?;
report_events.retain(|event| {
event
.get("tags")
.and_then(serde_json::Value::as_array)
.is_some_and(|tags| {
tags.iter().any(|tag| {
let Some(parts) = tag.as_array() else {
return false;
};
parts.first().and_then(serde_json::Value::as_str) == Some("e")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(params.thread_root.as_str())
&& parts.get(3).and_then(serde_json::Value::as_str) == Some("root")
})
})
});
report_events.sort_by(|left, right| {
left.get("created_at")
.and_then(serde_json::Value::as_u64)
.cmp(&right.get("created_at").and_then(serde_json::Value::as_u64))
.then_with(|| {
left.get("id")
.and_then(serde_json::Value::as_str)
.cmp(&right.get("id").and_then(serde_json::Value::as_str))
})
});
let current_head = report_events
.last()
.and_then(|event| event.get("id"))
.and_then(serde_json::Value::as_str);
match (current_head, params.prior.as_deref()) {
(Some(head), Some(prior)) if head.eq_ignore_ascii_case(prior) => {}
(Some(head), _) => {
return Err(CliError::Conflict(format!(
"work report head changed; retry with --prior {head}"
)))
}
(None, Some(_)) => {
return Err(CliError::Conflict(
"cannot set --prior because this thread has no work report".into(),
))
}
(None, None) => {}
}
let prior = match params.prior.as_deref() {
Some(prior_id) => {
let prior_event = fetch_event(client, prior_id).await?;
if prior_event.get("kind").and_then(serde_json::Value::as_u64) != Some(40009) {
return Err(CliError::Usage(
"--prior must reference a work report".into(),
));
}
let tags = prior_event
.get("tags")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| CliError::Usage("prior work report has no tags".into()))?;
let matches_root = tags.iter().any(|tag| {
let Some(parts) = tag.as_array() else {
return false;
};
parts.first().and_then(serde_json::Value::as_str) == Some("e")
&& parts.get(1).and_then(serde_json::Value::as_str)
== Some(params.thread_root.as_str())
&& parts.get(3).and_then(serde_json::Value::as_str) == Some("root")
});
if !matches_root || channel_id_from_event(prior_id, &prior_event)? != channel_id {
return Err(CliError::Usage(
"--prior must reference a work report for the same thread".into(),
));
}
Some(parse_event_id(prior_id)?)
}
None => None,
};
let report = buzz_sdk::WorkReport {
status: params.status.into(),
outcome: params.outcome,
deliverables: params.deliverables,
decisions: params.decisions,
verification: params.verification,
risks: params.risks,
next_actions: params.next_actions,
};
let builder = buzz_sdk::build_work_report(channel_id, thread_root, prior, &report)
.map_err(|error| CliError::Usage(error.to_string()))?;
let event = client.sign_event(builder)?;
let response = client.submit_event(event).await?;
println!("{}", normalize_write_response(&response));
Ok(())
}

pub struct SendDiffParams {
pub channel_id: String,
pub diff: String,
Expand Down Expand Up @@ -965,6 +1100,35 @@ pub async fn dispatch(
)
.await
}
MessagesCmd::Report {
channel,
thread,
status,
outcome,
deliverables,
decisions,
verification,
risks,
next_actions,
prior,
} => {
cmd_publish_work_report(
client,
WorkReportParams {
channel_id: channel,
thread_root: thread,
status,
outcome,
deliverables,
decisions,
verification,
risks,
next_actions,
prior,
},
)
.await
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete {
event,
Expand Down
105 changes: 104 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,32 @@ pub enum PresenceStatus {
Offline,
}

#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ReportStatus {
#[value(name = "completed")]
Completed,
#[value(name = "in-review")]
InReview,
#[value(name = "needs-decision")]
NeedsDecision,
#[value(name = "blocked")]
Blocked,
#[value(name = "failed")]
Failed,
}

impl From<ReportStatus> for buzz_sdk::WorkReportStatus {
fn from(value: ReportStatus) -> Self {
match value {
ReportStatus::Completed => Self::Completed,
ReportStatus::InReview => Self::InReview,
ReportStatus::NeedsDecision => Self::NeedsDecision,
ReportStatus::Blocked => Self::Blocked,
ReportStatus::Failed => Self::Failed,
}
}
}

#[derive(Clone, clap::ValueEnum)]
pub enum EmojiScope {
#[value(name = "own")]
Expand Down Expand Up @@ -435,6 +461,42 @@ pub enum MessagesCmd {
#[arg(long)]
reply_to: Option<String>,
},
/// Publish or update the structured outcome report for a thread
#[command(
after_help = "Example:\n buzz messages report --channel <UUID> --thread <EVENT_ID> --status completed --outcome \"Shipped the fix\" --deliverable <PR_URL> --verification \"CI passed\""
)]
Report {
/// Channel UUID containing the thread
#[arg(long)]
channel: String,
/// Thread root event ID
#[arg(long)]
thread: String,
/// Current report status
#[arg(long, value_enum)]
status: ReportStatus,
/// One-sentence result or required action
#[arg(long)]
outcome: String,
/// PR, file, document, or artifact reference (repeatable)
#[arg(long = "deliverable")]
deliverables: Vec<String>,
/// Material decision and rationale (repeatable)
#[arg(long = "decision")]
decisions: Vec<String>,
/// Test, CI, runtime check, or other evidence (repeatable)
#[arg(long = "verification")]
verification: Vec<String>,
/// Material risk or limitation (repeatable)
#[arg(long = "risk")]
risks: Vec<String>,
/// Next action with its owner (repeatable)
#[arg(long = "next-action")]
next_actions: Vec<String>,
/// Previous work-report event ID when updating
#[arg(long)]
prior: Option<String>,
},
/// Edit a previously sent message
Edit {
/// Event ID of the message to edit (64-char hex)
Expand Down Expand Up @@ -2302,6 +2364,7 @@ mod tests {
"delete",
"edit",
"get",
"report",
"search",
"send",
"send-diff",
Expand Down Expand Up @@ -2429,6 +2492,46 @@ mod tests {
);
}

#[test]
fn work_report_command_accepts_structured_repeatable_fields() {
let channel = uuid::Uuid::new_v4().to_string();
let root = "ab".repeat(32);
assert!(Cli::try_parse_from([
"buzz",
"messages",
"report",
"--channel",
channel.as_str(),
"--thread",
root.as_str(),
"--status",
"in-review",
"--outcome",
"Ready for review",
"--deliverable",
"https://example.com/pr/1",
"--verification",
"CI passed",
"--next-action",
"Maintainer: review",
])
.is_ok());
assert!(Cli::try_parse_from([
"buzz",
"messages",
"report",
"--channel",
channel.as_str(),
"--thread",
root.as_str(),
"--status",
"done",
"--outcome",
"Invalid status",
])
.is_err());
}

#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
Expand All @@ -2440,7 +2543,7 @@ mod tests {
("feed", 1),
("issues", 6),
("media", 1),
("messages", 8),
("messages", 9),
("pack", 2),
("patches", 4),
("pr", 5),
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
pub const KIND_STREAM_REMINDER: u32 = 40007;
/// A diff/patch message showing file changes (unified diff format).
pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008;
/// A structured, signed outcome report rooted in a channel thread.
pub const KIND_WORK_REPORT: u32 = 40009;
/// Canvas (shared document) for a channel.
pub const KIND_CANVAS: u32 = 40100;
/// System message for channel state changes (join, leave, rename, etc.).
Expand Down Expand Up @@ -707,6 +709,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_REMINDER,
KIND_STREAM_MESSAGE_DIFF,
KIND_WORK_REPORT,
KIND_CANVAS,
KIND_SYSTEM_MESSAGE,
KIND_CHANNEL_SUMMARY,
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,7 @@ mod tests {
use buzz_core::kind::{
KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST,
KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
KIND_WORK_REPORT,
};
use buzz_core::observer::{
encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
Expand Down Expand Up @@ -1223,6 +1224,7 @@ mod tests {
for kind in [
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_WORK_REPORT,
KIND_CANVAS,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
Expand Down
Loading
Loading