Files
buzz/crates/buzz-cli/src/commands/pr.rs
thomaspblock a1d78f2959 feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues (#4695)
## Summary

Gives Buzz-hosted git entities the same "GitHub-style" chat experience
GitHub links already get: rich preview cards, real titles, and
click-through — except clicks navigate **in-app** to the Projects view
instead of a browser.

- **Spec**: `docs/buzz-entity-links.md` — link scheme, slices, and
deferred work (`buzz://project`, OS deep links, web routes).
- **Canonical `buzz://` deep links**: new
`desktop/src/shared/lib/entityLink.ts` with builders + strict parser for
`buzz://pr?id=…&owner=…&d=…`, `buzz://issue?…`, and
`buzz://repo?owner=…&d=…`, mirrored by a Rust module
(`crates/buzz-cli/src/links.rs`) with a shared golden-format test so the
two implementations can't drift.
- **Preview cards**: `linkPreview.ts` recognizes `buzz://` entity links
*and* HTTPS relay clone URLs (`{origin}/git/<pubkey>/<repo>`, the shape
agents paste today). Both normalize onto the canonical `buzz://` href,
so the two spellings of a repo dedupe to one `Buzz`-provider card
(`BuzzMark` logo) rendered by `link-preview-attachment.tsx`.
- **Title enrichment**: PR/issue cards fetch the real subject from the
relay event (`subject` tag or first content line) via
`useResolvedLinkPreviews.ts`; the cache is community-scoped and reset in
`resetCommunityState()`.
- **In-app navigation**: clicking a card or inline anchor (including
HTTPS relay clone URLs whose origin matches the active relay) routes to
the canonical `30617:<owner>:<d>` coordinate via `goProject()`
(`markdown/entityLinks.tsx`). **Merge dependency: #4671 must merge
first** — route resolution for `30617:` coordinates is implemented on
that branch (`feat/multi-repository-projects`). Entity-link and
external-anchor logic were extracted out of `markdown.tsx` to stay under
the file-size ratchet.
- **Agent side**: `buzz pr open`, `buzz issues create`, and `buzz repos
create` now return a ready-made `link` field (omitted when the relay
returns `accepted: false`), and `base_prompt.md` instructs agents to
paste it verbatim when announcing work.

## Test plan

- [x] Desktop unit tests: pass, including new `entityLink.test.mjs` and
`linkPreview.test.mjs` coverage (golden formats, malformed-link
rejection, clone-URL/`buzz://` dedupe, origin-gated anchor behavior,
label-must-win invariant, cache epoch)
- [x] Rust: `cargo test -p buzz-cli` golden-format test +
accepted/rejected link guard assertions, clippy + fmt clean
- [x] Biome + `tsc --noEmit` clean; pre-push hooks
(desktop-tauri-checks, rust-tests, desktop-test) pass
- [ ] Manual: paste a relay clone URL and a `buzz://pr` link in a
channel — verify one card each, real PR title, and in-app navigation to
the Projects view

Related: [#4671](https://github.com/block/buzz/pull/4671)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-04 17:54:14 -04:00

353 lines
9.5 KiB
Rust

use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
};
use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta};
fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
match (body, body_file) {
(Some(_), Some(_)) => Err(CliError::Usage(
"--body and --body-file are mutually exclusive".into(),
)),
(Some(value), None) => read_or_stdin(value),
(None, Some(path)) => read_file_or_stdin(path),
(None, None) => Ok(String::new()),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_open_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
subject: &str,
body: Option<&str>,
body_file: Option<&str>,
commit: &str,
clone_urls: &[String],
branch_name: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
labels: &[String],
to: &[String],
channel: Option<&str>,
revision_of: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
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(),
clone_urls: clone_urls.to_vec(),
branch_name: branch_name.map(str::to_string),
merge_base: merge_base.map(str::to_string),
revision_of: revision_of.map(str::to_string),
};
let builder = with_git_provenance(
buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce PRs with it (see base_prompt.md).
let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
pr: &str,
pr_author: &str,
commit: &str,
clone_urls: &[String],
body: Option<&str>,
body_file: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
validate_hex64(pr)?;
validate_hex64(pr_author)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPrUpdateMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
pr_event: pr.to_string(),
pr_author: pr_author.to_string(),
commit: commit.to_string(),
clone_urls: clone_urls.to_vec(),
merge_base: merge_base.map(str::to_string),
};
let builder = with_git_provenance(
buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_pr(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1618],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_prs(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
label: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1618],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(l) = label {
filter["#t"] = serde_json::json!([l]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_pr_status(
client: &BuzzClient,
pr: &str,
status: &str,
body: Option<&str>,
body_file: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
merge_commit: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pr)?;
let status = crate::commands::patches::parse_status(status)?;
let content = read_optional_body(body, body_file)?;
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// Mirrors patch/issue status: default a `p` tag to the repo owner when
// known; callers can add PR author/reviewers with repeated `--to`.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let meta = GitStatusMeta {
root_event: pr.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: merge_commit.map(str::to_string),
applied_as_commits: vec![],
};
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PrCmd;
match cmd {
PrCmd::Open {
repo_owner,
repo_id,
subject,
body,
body_file,
commit,
clone,
branch_name,
merge_base,
euc,
label,
to,
channel,
revision_of,
} => {
cmd_open_pr(
client,
&repo_owner,
&repo_id,
&subject,
body.as_deref(),
body_file.as_deref(),
&commit,
&clone,
branch_name.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&label,
&to,
channel.as_deref(),
revision_of.as_deref(),
)
.await
}
PrCmd::Update {
repo_owner,
repo_id,
pr,
pr_author,
commit,
clone,
body,
body_file,
merge_base,
euc,
to,
} => {
cmd_update_pr(
client,
&repo_owner,
&repo_id,
&pr,
&pr_author,
&commit,
&clone,
body.as_deref(),
body_file.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&to,
)
.await
}
PrCmd::Get { event } => cmd_get_pr(client, &event).await,
PrCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_prs(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
PrCmd::Status {
pr,
status,
body,
body_file,
repo_owner,
repo_id,
euc,
to,
merge_commit,
} => {
cmd_pr_status(
client,
&pr,
&status,
body.as_deref(),
body_file.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
merge_commit.as_deref(),
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_optional_body_rejects_body_and_body_file_together() {
assert!(read_optional_body(Some("body"), Some("file.md")).is_err());
}
#[test]
fn read_optional_body_defaults_empty() {
assert_eq!(read_optional_body(None, None).unwrap(), "");
}
}