mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
NIP-50 full-text search finds messages citing an entity's id (or a repo's coordinate), grouped into a Channels workspace tab and per-entity 'discussed in' cards with participants, channel links, and snippets. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
289 lines
14 KiB
Markdown
289 lines
14 KiB
Markdown
# Buzz Entity Links
|
||
|
||
Status: **partially implemented**. Done on this branch:
|
||
|
||
- Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git/<pubkey>/<repo>`)
|
||
render as Buzz repository preview cards in chat
|
||
(`desktop/src/shared/lib/linkPreview.ts`).
|
||
- Slice 1 — `buzz://pr|issue|repo|project` deep links: `entityLink.ts`
|
||
builders/parser, preview cards with relay title enrichment (repo and
|
||
project titles resolve from their announcement events), in-timeline click
|
||
navigation to `/projects/$projectId`.
|
||
- Slice 2 — OS-level deep links: the `repo`/`project`/`pr`/`issue` hosts in
|
||
`desktop/src-tauri/src/deep_link.rs` emit `deep-link-entity`, and
|
||
`useEntityDeepLinks` routes them through the same handler as in-timeline
|
||
clicks.
|
||
- Slice 3 (create-command part) — `crates/buzz-cli/src/links.rs`, `link`
|
||
output field on `pr open` / `issues create` / `repos create` /
|
||
`projects create`, base prompt guidance, cross-language golden-format tests.
|
||
- Sharing from the UI — `lib/projectShareLinks.ts` maps the Projects read
|
||
models onto links, surfaced as "Copy link" in the project, repository,
|
||
issue, and pull request row menus and as a copy button in the project,
|
||
issue, and pull request detail headers.
|
||
|
||
Still unimplemented: `link` on get commands and the follow-ups in slice 4.
|
||
|
||
## Problem
|
||
|
||
When a message contains a GitHub URL, the desktop client renders a rich
|
||
preview card ("GitHub · PR block/buzz #4020") below the message. Those cards
|
||
are produced entirely client-side by URL parsing in
|
||
`desktop/src/shared/lib/linkPreview.ts` and rendered by
|
||
`desktop/src/shared/ui/link-preview-attachment.tsx`.
|
||
|
||
Buzz-hosted entities have no equivalent. There is **no link format at all**
|
||
for a Buzz repository, project, pull request, or issue:
|
||
|
||
- The only rich deep link today is `buzz://message?channel=…&id=…`
|
||
(`desktop/src/features/messages/lib/messageLink.ts`), rendered as an inline
|
||
pill via `remarkMessageLinks.ts` + `MessageLinkPill.tsx`.
|
||
- OS-level deep links (`desktop/src-tauri/src/deep_link.rs`,
|
||
`desktop/src/shared/deep-link.ts`) support `connect`, `join`,
|
||
`add-community`, `message`, and `nostr-bind` — no git entities.
|
||
- `buzz pr open` / `buzz issues create` return raw event ids; there is no URL
|
||
in their output and no guidance in the agent base prompt
|
||
(`crates/buzz-acp/src/base_prompt.md`) for referencing Buzz work items in
|
||
chat. Agents can only say "PR up" with a hex id.
|
||
- The relay-served web client only has `/repos/$repoId`; no PR/issue pages.
|
||
|
||
So an agent that opens a PR on a Buzz-hosted repository cannot produce
|
||
anything clickable, while the same agent opening a GitHub PR gets a card for
|
||
free.
|
||
|
||
## Goals
|
||
|
||
1. A canonical, shareable link format for Buzz repositories, projects, pull
|
||
requests, and issues.
|
||
2. Rich preview cards in the desktop message timeline for those links, with
|
||
parity to (and better data than) the GitHub cards — titles come from the
|
||
actual Nostr events, not URL text.
|
||
3. Clicking a link navigates in-app to the existing project detail views.
|
||
4. CLI output includes the link so agents (and the base prompt) can emit it
|
||
when announcing work.
|
||
|
||
## Non-goals (v1)
|
||
|
||
- Web (browser) pages for PRs/issues — the web client has no such views yet,
|
||
so links are app-only, same as `buzz://message` today.
|
||
- Cross-community links. Like `buzz://message`, links are interpreted against
|
||
the community the message was received in. A `relay=` query parameter is
|
||
reserved for a future cross-community version but not emitted or consumed.
|
||
- Generic OpenGraph unfurling for arbitrary URLs — that is the separate
|
||
`proto/rich-link-previews` prototype and stays orthogonal.
|
||
- Mobile rendering. Mobile should degrade gracefully (plain link) in v1;
|
||
pill/card parity is a follow-up.
|
||
|
||
## Link format
|
||
|
||
Extend the existing `buzz://` scheme, mirroring `buzz://message`:
|
||
|
||
```
|
||
buzz://repo?owner=<pubkey-hex>&d=<repo-dtag>[&tab=<tab>]
|
||
buzz://project?owner=<pubkey-hex>&d=<project-dtag>[&tab=<tab>]
|
||
buzz://pr?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
|
||
buzz://issue?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
|
||
```
|
||
|
||
- `owner` is the 64-char lowercase hex pubkey of the repository/project
|
||
announcement author (the NIP-34 / NIP-MP coordinate owner).
|
||
- `d` is the addressable `d`-tag. For `repo`/`project` links the
|
||
(`owner`, `d`) pair is the full `30617:<owner>:<d>` /
|
||
`30621:<owner>:<d>` coordinate.
|
||
- `tab` (coordinate links only, optional) selects a workspace tab instead
|
||
of the default readme overview: `files`, `commits`, `issues`, `prs`,
|
||
`contributors`, or `channels`. The overview has no spelling (canonical links omit the
|
||
parameter), unknown values are rejected, and event links accept no `tab`.
|
||
The desktop's copy-link button emits it automatically when a non-overview
|
||
tab is active, so "link to the PR list" is just the project link copied
|
||
from the Pull Request tab.
|
||
- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event;
|
||
`owner` + `d` are the routing coordinate that lets the client navigate
|
||
(and render a fallback card) without an event lookup. **v1 decision:** the
|
||
implemented parser requires all three parameters — the CLI always emits
|
||
them, and accepting hint-less links would force an event lookup before any
|
||
navigation. A future revision can relax this without breaking existing
|
||
links.
|
||
|
||
Validation rules match the existing codebase: `owner` and `id` are
|
||
`/^[a-f0-9]{64}$/`; `d` follows addressable d-tag rules already enforced in
|
||
`projectModels.ts` / `buzz-sdk`.
|
||
|
||
### HTTPS URLs
|
||
|
||
Agents naturally paste HTTPS clone URLs
|
||
(`{relay-origin}/git/<pubkey>/<repo>`) when announcing work, so those are
|
||
recognized **first** — implemented on this branch. Detection keys on the
|
||
path shape (`/git/` + 64-hex pubkey segment) rather than a host allow-list,
|
||
since relay hosts differ per community. The preview href is normalized to
|
||
the canonical `buzz://repo?owner=…&d=…` deep link (the raw transport URL is
|
||
not a browsable page), so clone-URL cards and inline clone-URL anchors get
|
||
the same in-app click navigation as explicit entity links, and both
|
||
spellings of the same repository dedupe to one card.
|
||
|
||
PRs, issues, and projects have no HTTPS page to link to (the web client has
|
||
no such routes), which is why they use the `buzz://` scheme above: it is
|
||
community-relative by construction, matches the established `buzz://message`
|
||
precedent, and requires no new relay surface. If web views land later, the
|
||
desktop can additionally recognize those `{relay-origin}/…` URLs with the
|
||
same card treatment.
|
||
|
||
## Rendering in chat (desktop)
|
||
|
||
Two presentations, consistent with how GitHub links and message links behave
|
||
today:
|
||
|
||
1. **Autolinked bare URL** (`<buzz://pr?…>` or bare in text): render an
|
||
**attachment card** below the message in the existing `AttachmentGroup`,
|
||
exactly like GitHub cards. Provider label `Buzz`, type label
|
||
`PR` / `issue` / `repo` / `project`.
|
||
2. **Explicitly labeled markdown link** (`[fix the tooltip](buzz://pr?…)`):
|
||
keep the author's label inline (same rule as
|
||
`resolveMessageLinkRenderTarget` in `messageLink.ts`), still clickable.
|
||
|
||
### Card content and enrichment
|
||
|
||
Unlike GitHub (title derived from URL path only), Buzz entities live on the
|
||
same relay, so the card can show real data:
|
||
|
||
| Entity | Title source | Fallback |
|
||
|---------|-------------------------------------------|---------------------|
|
||
| PR | `subject` tag of the kind `1618` event | `PR <id-prefix>` |
|
||
| Issue | `subject` tag of the kind `1621` event | `issue <id-prefix>` |
|
||
| Repo | `name` tag of the kind `30617` event | `d`-tag |
|
||
| Project | `name` tag of the kind `30621` event | `d`-tag |
|
||
|
||
Enrichment is a single relay query by event id (PR/issue) or coordinate
|
||
(repo/project) through the existing `relayClient`, cached per event id.
|
||
Kind filters must always be included in the query (relay p-gate). Cards
|
||
render immediately with the fallback title and upgrade in place when the
|
||
lookup resolves — same progressive pattern as
|
||
`useResolvedLinkPreviews.ts` uses for Google titles.
|
||
|
||
Open/merged/closed status chips (from kind `1630`–`1633` status events) are
|
||
a nice-to-have and explicitly deferred to a follow-up.
|
||
|
||
### New module
|
||
|
||
`desktop/src/shared/lib/entityLink.ts` (placed in `shared/lib` rather than
|
||
the projects feature so `linkPreview.ts` — also `shared/lib` — can import
|
||
it without a feature→shared boundary violation):
|
||
|
||
- `buildRepoLink`, `buildProjectLink`, `buildPullRequestLink`,
|
||
`buildIssueLink`
|
||
- `parseEntityLink(url): EntityLinkParseResult` (discriminated union, same
|
||
shape as `parseMessageLink`)
|
||
- `isEntityLink(href)` cheap pre-check for the markdown renderer
|
||
- `isLinkableCoordinate(owner, dtag)` — addressable d-tags allow a wider
|
||
charset (and 1024 bytes) than the link format's
|
||
`[a-zA-Z0-9._-]{1,64}`, so callers that build links from read models check
|
||
first and hide the share affordance instead of surfacing a builder throw
|
||
|
||
Detection: extend `extractSupportedLinkPreviews` in `linkPreview.ts` with a
|
||
`buzz://` pattern (new `SupportedLinkPreviewKind` members
|
||
`buzz-pull-request`, `buzz-issue`, `buzz-repository`, `buzz-project`), or —
|
||
if mixing schemes into the URL regex is awkward — a parallel extractor
|
||
composed in `markdown.tsx`. Code blocks / spoiler / image-link masking rules
|
||
are shared either way, and the existing `MAX_PREVIEWS` cap applies across
|
||
both sources.
|
||
|
||
## Click handling and OS deep links
|
||
|
||
**In-timeline click** *(implemented)*: navigate via
|
||
`useAppNavigation.goProject()`. The `/projects/$projectId` route id is the
|
||
canonical `30617:<owner>:<d>` coordinate (see `entityLinkProjectRouteId` in
|
||
`shared/lib/entityLink.ts`). Route resolution on the `feat/multi-repository-projects`
|
||
branch (#4671) resolves this coordinate to the correct project and repository
|
||
regardless of container grouping — **#4671 must merge before #4695** to avoid
|
||
unresolved routes at runtime:
|
||
|
||
- `pr` / `issue` → `/projects/30617:<owner>:<d>?pullRequestId=<id>` (or `issueId`).
|
||
- `repo` → `/projects/30617:<owner>:<d>`.
|
||
|
||
If resolution fails (entity not visible in this community), show the same
|
||
kind of toast fallback used for unresolvable message links.
|
||
|
||
**OS-level** *(implemented)*: the `repo` / `project` / `pr` / `issue` hosts
|
||
in `desktop/src-tauri/src/deep_link.rs` validate the link's canonical form
|
||
(so a malformed link does not raise and focus the window for a navigation
|
||
that would then be declined), then emit `deep-link-entity` with the URL
|
||
verbatim. `useEntityDeepLinks` — sibling to `useMessageDeepLinks.ts`, mounted
|
||
in `AppShell` for the main window only — re-parses it with `parseEntityLink`
|
||
and reuses `useOpenEntityLink`, so a link opened from the OS lands on the
|
||
same view as one clicked in a message.
|
||
|
||
## CLI (`buzz-cli`)
|
||
|
||
Add a `link` field to the JSON output of the write commands that create
|
||
linkable entities:
|
||
|
||
- `buzz pr open` → `{ event_id, accepted, message, link }`
|
||
- `buzz issues create` → same
|
||
- `buzz repos create` → link built from owner pubkey + `d`-tag
|
||
- `buzz projects create` → same
|
||
|
||
The builder lives in one Rust helper (e.g. `crates/buzz-cli/src/links.rs`)
|
||
so the format has exactly one definition on the Rust side; the TypeScript
|
||
`entityLink.ts` is its mirror and both are covered by shared-format tests
|
||
(golden strings asserted on both sides, like the NIP-MP fixture pattern).
|
||
|
||
`buzz pr get` / `buzz issues get` / `buzz repos get` also include `link` in
|
||
their output so agents can link to existing entities, not just ones they
|
||
just created.
|
||
|
||
## Agent guidance
|
||
|
||
One addition to `crates/buzz-acp/src/base_prompt.md`, next to the existing
|
||
`--channel` rule for PR opens:
|
||
|
||
> When you announce a pull request, issue, repository, or project in a
|
||
> channel message, include the `link` value from the command output as a
|
||
> bare URL on its own line so it renders as a preview card.
|
||
|
||
No persona changes needed — the base prompt applies to all managed agents.
|
||
|
||
## Interaction with existing work
|
||
|
||
- **`proto/rich-link-previews`** (generic OpenGraph cards): orthogonal.
|
||
Entity links never hit the network beyond a relay event query; no overlap
|
||
in code paths except the shared `AttachmentGroup` rendering slot.
|
||
- **`feat/multi-repository-projects` (NIP-MP)**: independent. Entity links
|
||
reference single repositories/PRs/issues by coordinate/event id; the
|
||
PR→project resolution step simply uses whatever project read models exist
|
||
on `main` at implementation time.
|
||
|
||
## Implementation plan (suggested PR slices)
|
||
|
||
0. **HTTPS clone-URL repo cards** *(done, this branch)* — recognize relay
|
||
`/git/<pubkey>/<repo>` URLs in `linkPreview.ts`, `Buzz` provider card
|
||
with the `BuzzMark` logo, href normalized to the `buzz://repo` deep link
|
||
for in-app navigation.
|
||
1. **Link core + cards** *(done, this branch)* — `entityLink.ts`, detection
|
||
in `linkPreview.ts`, `Buzz` card variant in
|
||
`link-preview-attachment.tsx`, in-timeline click navigation, relay title
|
||
enrichment (with `resetLinkPreviewTitleCache()` wired into
|
||
`resetCommunityState()`). Unit tests (`entityLink.test.mjs`, extended
|
||
`linkPreview.test.mjs`).
|
||
2. **OS deep links** *(done, this branch)* — `deep_link.rs` +
|
||
`useEntityDeepLinks` + `deep-link.ts` parity tests.
|
||
3. **CLI + agent prompt** *(create commands done, this branch)* — `links.rs`
|
||
helper, `link` output field on `pr open` / `issues create` /
|
||
`repos create` / `projects create`, base prompt paragraph, cross-language
|
||
golden-format test. Still open: `link` on the get commands.
|
||
4. **Follow-ups (separate)** — status chips on PR/issue cards, mobile
|
||
pill/card rendering, web PR/issue routes + HTTPS link recognition,
|
||
cross-community `relay=` parameter.
|
||
|
||
## Security considerations
|
||
|
||
- All identifiers are validated before use (`owner`/`id` strict hex-64,
|
||
`d`-tag charset rules). Parse failures render the raw text as a plain,
|
||
non-clickable string — never an anchor with an unvalidated href.
|
||
- Title enrichment queries go through the already-authenticated
|
||
`relayClient` with explicit `kinds` filters; no new HTTP surface and no
|
||
outbound fetches to third parties.
|
||
- Card titles come from event tags authored by arbitrary users; they must be
|
||
rendered as text (existing card components already do this — verify no
|
||
`dangerouslySetInnerHTML` in the new variant).
|
||
- Deep links arriving from the OS are untrusted input; the new listener must
|
||
apply the same validation as the in-timeline parser before navigating.
|