mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## 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>
353 lines
9.5 KiB
Rust
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(), "");
|
|
}
|
|
}
|