Skip to content
Open
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
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.

## 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])?);
}
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