Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |

Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.

When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.
Comment thread
thomaspblock marked this conversation as resolved.

## Conversational Agent Creation

When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-cli/src/commands/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub async fn cmd_open_pr(
euc: Option<&str>,
labels: &[String],
to: &[String],
channel: Option<&str>,
revision_of: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
Expand All @@ -44,6 +45,7 @@ pub async fn cmd_open_pr(
let meta = GitPullRequestMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
channel_id: channel.map(str::to_string),
subject: subject.to_string(),
labels: labels.to_vec(),
commit: commit.to_string(),
Expand Down Expand Up @@ -227,6 +229,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE
euc,
label,
to,
channel,
revision_of,
} => {
cmd_open_pr(
Expand All @@ -243,6 +246,7 @@ pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliE
euc.as_deref(),
&label,
&to,
channel.as_deref(),
revision_of.as_deref(),
)
.await
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,9 @@ pub enum PrCmd {
/// Additional recipient pubkey(s) — can be specified multiple times
#[arg(long = "to")]
to: Vec<String>,
/// Channel where this pull request originated (NIP-29 h-tag)
#[arg(long)]
channel: Option<String>,
/// Root patch event id this PR revises
#[arg(long)]
revision_of: Option<String>,
Expand Down
12 changes: 12 additions & 0 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,8 @@ pub struct GitPullRequestMeta {
pub euc: Option<String>,
/// Additional pubkeys to `p`-tag besides the repo owner.
pub recipients: Vec<String>,
/// NIP-29 channel where the pull request originated (`h` tag).
pub channel_id: Option<String>,
/// PR subject line (`subject` tag) — required, used as the header.
pub subject: String,
/// Labels (`t` tags).
Expand Down Expand Up @@ -1352,6 +1354,14 @@ pub fn build_git_pull_request(
tags.push(tag(&["t", label])?);
}
tags.push(tag(&["c", &meta.commit])?);
if let Some(ref channel_id) = meta.channel_id {
if channel_id.trim().is_empty() {
return Err(SdkError::InvalidInput(
"channel_id must not be empty".into(),
));
}
tags.push(tag(&["h", channel_id])?);
Comment thread
thomaspblock marked this conversation as resolved.
Outdated
}
let mut clone_tag = vec!["clone"];
clone_tag.extend(meta.clone_urls.iter().map(String::as_str));
tags.push(tag(&clone_tag)?);
Expand Down Expand Up @@ -3351,6 +3361,7 @@ mod tests {
clone_urls: vec!["https://example.com/repo.git".to_string()],
branch_name: Some("feat/x".to_string()),
labels: vec!["enhancement".to_string()],
channel_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
..Default::default()
};
let ev = sign(build_git_pull_request(&pr_repo(), "PR body", &meta).unwrap());
Expand All @@ -3361,6 +3372,7 @@ mod tests {
assert!(has_tag(&ev, "subject", "Add feature X"));
assert!(has_tag(&ev, "c", &"c".repeat(40)));
assert!(has_tag(&ev, "t", "enhancement"));
assert!(has_tag(&ev, "h", "11111111-1111-4111-8111-111111111111"));
assert!(has_tag(&ev, "branch-name", "feat/x"));
assert_eq!(
full_clone_tag(&ev),
Expand Down
124 changes: 121 additions & 3 deletions desktop/src-tauri/src/commands/project_git_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ pub struct ProjectPullRequestReviewRequestInput {
reviewer_label: String,
}

/// Repository-scoped metadata for an agent-signed lifecycle status.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPullRequestStatusInput {
target_owner: String,
repo_address: String,
pull_request_id: String,
pull_request_author: String,
status: String,
created_at: u64,
}

/// A previously signed merged-status event that needs publishing again.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -249,6 +261,44 @@ fn build_merged_status_event(
.map_err(|error| format!("sign merged pull request status: {error}"))
}

fn build_pull_request_status_event(
keys: &Keys,
repo_address: &str,
pull_request_id: &str,
pull_request_author: &str,
status: &str,
created_at: u64,
) -> Result<String, String> {
let owner = keys.public_key().to_hex();
let (pull_request_id, pull_request_author) =
validate_merge_status_metadata(repo_address, &owner, pull_request_id, pull_request_author)?;
let kind = match status {
"open" => Kind::Custom(1630),
"closed" => Kind::Custom(1632),
"draft" => Kind::Custom(1633),
_ => return Err("Invalid pull request lifecycle status.".to_string()),
};
let mut raw_tags = vec![
vec!["e", pull_request_id.as_str(), "", "root"],
vec!["a", repo_address],
vec!["p", owner.as_str()],
];
if pull_request_author != owner {
raw_tags.push(vec!["p", pull_request_author.as_str()]);
}
let tags = raw_tags
.into_iter()
.map(Tag::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("build pull request status tags: {error}"))?;
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs())))
.sign_with_keys(keys)
.map(|event| event.as_json())
.map_err(|error| format!("sign pull request status: {error}"))
}

fn build_review_request_event(
keys: &Keys,
repo_address: &str,
Expand Down Expand Up @@ -433,6 +483,31 @@ pub async fn clone_project_repository(
.map_err(|error| format!("repo clone task failed: {error}"))?
}

#[tauri::command]
pub async fn sign_project_pull_request_status(
input: ProjectPullRequestStatusInput,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let target_owner = input.target_owner.trim().to_ascii_lowercase();
if normalize_event_id(&target_owner).is_none() {
return Err("Invalid target repository owner.".to_string());
}
let identity = project_owner_identity(&app, &state, &target_owner)?;
let event = Event::from_json(build_pull_request_status_event(
&identity.keys,
&input.repo_address,
&input.pull_request_id,
&input.pull_request_author,
&input.status,
input.created_at,
)?)
.map_err(|error| format!("parse signed pull request status: {error}"))?;
submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref())
.await?;
Ok(())
}

#[tauri::command]
pub async fn sign_project_pull_request_review_request(
input: ProjectPullRequestReviewRequestInput,
Expand Down Expand Up @@ -663,9 +738,9 @@ pub async fn merge_project_pull_request(
#[cfg(test)]
mod tests {
use super::{
align_unborn_head_branch, build_merged_status_event, build_review_request_event,
classify_merge_error, normalize_commit, same_repository, validate_merge_status_metadata,
ProjectPullRequestMergeError,
align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event,
build_review_request_event, classify_merge_error, normalize_commit, same_repository,
validate_merge_status_metadata, ProjectPullRequestMergeError,
};
use crate::commands::project_git_exec::{build_test_git_auth_config, run_git};
use nostr::{Event, JsonUtil, Keys, Timestamp};
Expand Down Expand Up @@ -834,6 +909,49 @@ mod tests {
.is_err());
}

#[test]
fn lifecycle_status_is_signed_by_repository_owner() {
let keys = Keys::generate();
let owner = keys.public_key().to_hex();
let author = "b".repeat(64);
let event = Event::from_json(
build_pull_request_status_event(
&keys,
&format!("30617:{owner}:buzz"),
&"d".repeat(64),
&author,
"closed",
Timestamp::now().as_secs(),
)
.unwrap(),
)
.unwrap();

assert_eq!(event.pubkey, keys.public_key());
assert_eq!(event.kind.as_u16(), 1632);
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["p", author.as_str()]));
assert!(event.verify().is_ok());
}

#[test]
fn lifecycle_status_rejects_merged_alias() {
let keys = Keys::generate();
let owner = keys.public_key().to_hex();

assert!(build_pull_request_status_event(
&keys,
&format!("30617:{owner}:buzz"),
&"d".repeat(64),
&"b".repeat(64),
"merged",
Timestamp::now().as_secs(),
)
.is_err());
}

#[test]
fn review_request_is_signed_by_repository_owner() {
let keys = Keys::generate();
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ pub fn run() {
delete_project_remote_branch,
push_project_local_repository,
pull_project_local_repository,
sign_project_pull_request_status,
sign_project_pull_request_review_request,
publish_project_pull_request_merged_status,
merge_project_pull_request,
Expand Down
Loading
Loading