diff --git a/README.md b/README.md index 5ec94db60..1188b784f 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,11 @@ Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/p See your GitHub pull requests and review requests, inspect checks and changes, and continue related work in bb. Thread links are optional and survive branch changes. -![Compact Pull Requests sidebar with status icons and timestamps](https://github.com/user-attachments/assets/00c9963c-89ac-4ee6-86e4-06c7840c725c) +![Needs attention on: the failing Handle empty search PR in the queue, beside a sample stack's ordered Summary](plugins/pull-requests/docs/stack-attention.png) -![Pull request Summary with status rail and linked thread](https://github.com/user-attachments/assets/1ed45e6e-163d-44e9-852a-7e818eef0c2c) +![Summary of a sample pull request with passing checks, an approved review, its stack and a linked thread](plugins/pull-requests/docs/summary.png) -![Read-only changed file selection](https://github.com/user-attachments/assets/e664001b-4dbc-4ecc-8f48-38e79cd82dc1) +![Read-only Changes for a sample pull request](plugins/pull-requests/docs/changes.png) [Source](plugins/pull-requests) · [README](plugins/pull-requests/README.md) diff --git a/package-lock.json b/package-lock.json index ff838e546..a644942cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10014,7 +10014,7 @@ }, "plugins/pull-requests": { "name": "bb-plugin-pull-requests", - "version": "0.1.0", + "version": "0.1.1", "license": "UNLICENSED", "dependencies": { "lucide-react": "^0.474.0", diff --git a/plugins/pull-requests/PLUGIN_OVERVIEW.md b/plugins/pull-requests/PLUGIN_OVERVIEW.md new file mode 100644 index 000000000..d678d0276 --- /dev/null +++ b/plugins/pull-requests/PLUGIN_OVERVIEW.md @@ -0,0 +1,15 @@ +## Your review queue + +Browse open pull requests you authored, requests for your review, and open work in your bb projects’ GitHub repositories. Filter by status, author, or reviewer, search the known registry, and pin work you want to revisit. Recent merged and closed pull requests are available through the status filter. + +## Follow the work + +See CI, review, and mergeability status beside each pull request. Open its Summary or read-only Changes, then return to a linked bb thread to continue. Stacks and labeled branch dependencies stay together, with merged prerequisites visible as context. + +Paste a GitHub pull request URL to open or link it. Thread references in descriptions can associate accessible threads; verified origin markers also check the server and repository. You can manage links and choose a preferred thread. + +## Requirements and privacy + +Supports GitHub.com. Install and authenticate the GitHub CLI on each connected machine you want to use as a source. Each machine uses its own account and repository permissions; there is no fixed account. + +Pull Requests reads GitHub and stores cached snapshots, thread associations, and preferences in bb. It does not submit reviews or merge pull requests. Source, freshness, offline machines, and incomplete discovery are shown so cached information is not mistaken for a current result. Open searches are limited to 1,000 results per query; merged and closed history is limited to the latest 100 authored pull requests per account. diff --git a/plugins/pull-requests/README.md b/plugins/pull-requests/README.md index f68a28c43..2c6734756 100644 --- a/plugins/pull-requests/README.md +++ b/plugins/pull-requests/README.md @@ -2,7 +2,11 @@ See your GitHub pull requests and continue related work in bb. -![Compact sidebar and pull request Summary with status rail and linked thread](https://github.com/user-attachments/assets/1ed45e6e-163d-44e9-852a-7e818eef0c2c) +![The queue for a sample Atlas project and the Summary of PR 42, Improve keyboard navigation: passing checks, an approved review, a two-PR stack and its linked Keyboard navigation thread](docs/summary.png) + +![Needs attention on: the failing Handle empty search PR in the queue, beside the stack's ordered Summary](docs/stack-attention.png) + +![Read-only Changes for PR 42 showing the src/navigation.ts patch](docs/changes.png) ## Install @@ -19,6 +23,7 @@ Open **Pull Requests** from plugin navigation. The sidebar queries GitHub for yo - Every PR has a readable status and reason. Live input requests win over GitHub freshness; stale or incomplete evidence is **Unknown**. **Ready for decision** means no known GitHub blocker, never user approval or QA sign-off. - Filters cover the complete known registry. Missing members, ambiguous dependencies, cached relationships, and incomplete GitHub discovery are labeled explicitly. - Paste a GitHub URL into search to open or link a PR. Manage links and the preferred thread in detail. Project ownership uses the preferred thread, then a unique verified origin, then an unambiguous repository mapping. +- `BB-Thread-ID` markers remain supported. Linked `BB-Thread` markers can establish origin only for this server’s configured app, server-access, or loopback origin, after checking thread access and repository ownership. Links to other servers can still provide ordinary thread references. - Thread IDs and thread links in a PR description automatically associate accessible bb threads during sync. Multiple threads are supported; explicitly unlinked threads stay unlinked. Cached PRs remain usable during refresh. Descriptions and diffs load when opened, with delayed placeholders for slower reads. diff --git a/plugins/pull-requests/core.ts b/plugins/pull-requests/core.ts index 1bddd4cf9..7cf5f2d72 100644 --- a/plugins/pull-requests/core.ts +++ b/plugins/pull-requests/core.ts @@ -14,9 +14,21 @@ export function parsePullRequestUrl(raw: string) { const owner = match[1]!, repository = match[2]!, number = match[3]!; return { owner, repository, number: Number(number), url: `https://github.com/${owner}/${repository}/pull/${number}` }; } -export function originMarkers(body: string): string[] { +export function originMarkers(body: string, serverUrls: readonly string[] = []): string[] { const legacy = Array.from(body.matchAll(/^BB-Thread-ID: (thr_[a-zA-Z0-9]+)\s*$/gm), (match) => match[1]!); - const linked = Array.from(body.matchAll(/^BB-Thread: \[[^\n]*\]\(https:\/\/brsbl\.getbb\.app\/projects\/proj_[a-zA-Z0-9]+\/threads\/(thr_[a-zA-Z0-9]+)\)\s*$/gm), (match) => match[1]!); + const origins = new Set(serverUrls.flatMap((value) => { + try { + const url = new URL(value); + return /^(https?:)$/.test(url.protocol) && !url.username && !url.password ? [url.origin] : []; + } catch { return []; } + })); + const linked = Array.from(body.matchAll(/^BB-Thread: \[[^\n]*\]\(([^\s)]+)\)\s*$/gm)).flatMap((match) => { + try { + const url = new URL(match[1]!); + const thread = /^\/projects\/proj_[a-zA-Z0-9]+\/threads\/(thr_[a-zA-Z0-9]+)$/.exec(url.pathname); + return origins.has(url.origin) && !url.username && !url.password && !url.search && !url.hash && thread ? [thread[1]!] : []; + } catch { return []; } + }); return [...new Set([...legacy, ...linked])]; } export function referencedThreadIds(body: string): string[] { diff --git a/plugins/pull-requests/docs/changes.png b/plugins/pull-requests/docs/changes.png new file mode 100644 index 000000000..86612f559 Binary files /dev/null and b/plugins/pull-requests/docs/changes.png differ diff --git a/plugins/pull-requests/docs/stack-attention.png b/plugins/pull-requests/docs/stack-attention.png new file mode 100644 index 000000000..c04427243 Binary files /dev/null and b/plugins/pull-requests/docs/stack-attention.png differ diff --git a/plugins/pull-requests/docs/summary.png b/plugins/pull-requests/docs/summary.png new file mode 100644 index 000000000..076e0aecd Binary files /dev/null and b/plugins/pull-requests/docs/summary.png differ diff --git a/plugins/pull-requests/github.test.ts b/plugins/pull-requests/github.test.ts index 4f3cacf4e..f998e7aef 100644 --- a/plugins/pull-requests/github.test.ts +++ b/plugins/pull-requests/github.test.ts @@ -33,7 +33,25 @@ describe("GitHub read boundary", () => { const run = vi.fn().mockResolvedValueOnce(JSON.stringify(account)).mockRejectedValueOnce(Object.assign(new Error("GraphQL"), { stderr: "Field 'stack' doesn't exist on type 'PullRequest'" })).mockResolvedValueOnce(JSON.stringify({ data: { viewer, search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [rawPr()] } } })).mockResolvedValueOnce(JSON.stringify(account)); expect(await searchPullRequests({ scope: "authored" }, run)).toMatchObject({ ok: true, snapshots: [{ stack: { state: "unavailable" } }] }); expect(run.mock.calls[2]![0].join(" ")).not.toContain("stack {"); - expect(originMarkers("BB-Thread: [Owner](https://brsbl.getbb.app/projects/proj_a/threads/thr_owner)\nBB-Thread-ID: thr_owner")).toEqual(["thr_owner"]); + expect(originMarkers("BB-Thread: [Owner](https://studio.example/projects/proj_a/threads/thr_owner)\nBB-Thread-ID: thr_owner")).toEqual(["thr_owner"]); + }); + it("recognizes linked markers only on the current server while retaining plain markers", () => { + const linked = (origin: string, thread = "thr_owner") => `BB-Thread: [Implementation](${origin}/projects/proj_demo/threads/${thread})`; + expect(originMarkers(linked("https://studio.example"), ["https://studio.example/"])).toEqual(["thr_owner"]); + expect(originMarkers(linked("https://other.example"), ["https://other.example"])).toEqual(["thr_owner"]); + expect(originMarkers(linked("http://127.0.0.1:4567"), ["http://127.0.0.1:4567"])).toEqual(["thr_owner"]); + expect(originMarkers(`${linked("https://studio.example")}\nBB-Thread-ID: thr_owner`, ["https://studio.example"])).toEqual(["thr_owner"]); + expect(originMarkers(`${linked("https://other.example")}\nBB-Thread-ID: thr_legacy`, ["https://studio.example"])).toEqual(["thr_legacy"]); + expect(originMarkers(linked("https://studio.example"))).toEqual([]); + expect(referencedThreadIds(linked("https://other.example"))).toEqual(["thr_owner"]); + }); + it("does not trust lookalike origins, credentials, malformed URLs, or marker prose", () => { + const body = [ + "https://studio.example.evil.test", "https://studio.example@evil.test", "https://user@studio.example", + "http://studio.example", "https://studio.example:444", "not-a-url", + ].map((origin) => `BB-Thread: [Implementation](${origin}/projects/proj_demo/threads/thr_owner)`).join("\n"); + expect(originMarkers(body, ["https://studio.example", "invalid"])).toEqual([]); + expect(originMarkers("Text BB-Thread: [Implementation](https://studio.example/projects/proj_demo/threads/thr_owner)", ["https://studio.example"])).toEqual([]); }); it("keeps GitHub author avatars while accepting snapshots saved before avatars", async () => { const avatarUrl = "https://avatars.githubusercontent.com/u/42?s=40"; @@ -47,7 +65,7 @@ describe("GitHub read boundary", () => { expect(projectSnapshot({ ...rawPr(), author: null }).authorAvatarUrl).toBeNull(); }); it("deduplicates description IDs, mentions and thread links without matching partial IDs", () => { - expect(referencedThreadIds("BB-Thread-ID: thr_a\nRelated: thr_b, @thread:thr_a\n[Review](https://brsbl.getbb.app/projects/proj_a/threads/thr_c)\n[Chat](bb://thread/thr_d)\nnot_thr_other thr_partial_suffix thr_partial-suffix")).toEqual(["thr_a", "thr_b", "thr_c", "thr_d"]); + expect(referencedThreadIds("BB-Thread-ID: thr_a\nRelated: thr_b, @thread:thr_a\n[Review](https://studio.example/projects/proj_a/threads/thr_c)\n[Chat](bb://thread/thr_d)\nnot_thr_other thr_partial_suffix thr_partial-suffix")).toEqual(["thr_a", "thr_b", "thr_c", "thr_d"]); }); it("queries account-wide authored and requested-review PRs with bounded cursor pagination", async () => { for (const [scope, qualifier] of [["authored", "author:alice is:open"], ["review", "is:open review-requested:alice"], ["history", "author:alice is:closed"]] as const) { diff --git a/plugins/pull-requests/package.json b/plugins/pull-requests/package.json index 682cc4d9e..f7ca6cbed 100644 --- a/plugins/pull-requests/package.json +++ b/plugins/pull-requests/package.json @@ -1,12 +1,14 @@ { "name": "bb-plugin-pull-requests", - "version": "0.1.0", + "version": "0.1.1", "description": "See your GitHub pull requests and continue related work in bb.", "type": "module", "license": "UNLICENSED", "files": [ "dist", - "README.md" + "docs", + "README.md", + "PLUGIN_OVERVIEW.md" ], "scripts": { "build": "node ../../tooling/build-plugin.mjs", diff --git a/plugins/pull-requests/server.test.ts b/plugins/pull-requests/server.test.ts index 1debe203c..4976e69d2 100644 --- a/plugins/pull-requests/server.test.ts +++ b/plugins/pull-requests/server.test.ts @@ -9,15 +9,17 @@ afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose function snapshot(number = 1): Snapshot { return { nodeId: `PR_${number}`, url: `https://github.com/acme/repo/pull/${number}`, repository: "acme/repo", number, title: `PR ${number}`, body: "Private description", author: "alice", state: "open", headSha: "abc", headBranch: "fix", baseBranch: "main", updatedAt: "2026-10-04T00:00:00Z", fetchedAt: "2026-10-04T00:00:00Z", checks: { state: "passing", passing: 1, failing: 0, pending: 0, total: 1, complete: true, items: [{ name: "CI", state: "passing", url: null }] }, review: "approved", mergeability: "mergeable", queued: false, autoMerge: false, additions: 1, deletions: 1, changedFiles: 1, originThreadIds: [], stack: { state: "none", items: [] } }; } -function setup() { +function setup(appUrl: string | null = null, accessUrl: string | null = null) { + let environmentPullRequest = async (_input: { environmentId: string }) => ({ outcome: "available" as const, pullRequest: { url: snapshot().url } }); let beforeThreadRead = async () => {}; let response: (input: { url: string; expectedAccountId?: string }, hostId: string) => Promise = async (input) => ({ ok: true, accountId: "U_A", login: "alice", snapshot: snapshot(Number(input.url.split("/").at(-1))) }); let searchResponse: (input: { scope: string }, hostId: string) => Promise = async () => ({ ok: true, accountId: "U_A", login: "alice", snapshots: [snapshot()], nextCursor: null }); const projects: { id: string; gitRemoteUrl: string | null }[] = []; const threads = [makeThreadResponse({ id: "thr_a", environmentId: "env_a", title: "Implementation" }), makeThreadResponse({ id: "thr_b", environmentId: "env_b", title: "Review" })]; - const { bb, harness } = createFakePluginHost({ pluginId: "pull-requests", experimental_hostEntry: true, sdk: { + const { bb, harness } = createFakePluginHost({ pluginId: "pull-requests", appUrl, experimental_hostEntry: true, sdk: { + system: { config: async () => ({ serverAccess: { effectiveUrl: null, providers: accessUrl ? [{ availability: { status: "available", serverUrl: accessUrl } }] : [] } }) as never }, threads: { get: async ({ threadId }) => { await beforeThreadRead(); const thread = threads.find((entry) => entry.id === threadId); if (!thread) throw new Error("No thread"); return thread; }, list: async ({ offset = 0, environmentId } = {}) => offset ? [] : threads.filter((thread) => !environmentId || thread.environmentId === environmentId) }, - environments: { get: async ({ environmentId }) => ({ hostId: environmentId === "env_b" ? "host_b" : "host_a", path: "/repo" }), pullRequest: async () => ({ outcome: "available", pullRequest: { url: snapshot().url } }) }, + environments: { get: async ({ environmentId }) => ({ hostId: environmentId === "env_b" ? "host_b" : "host_a", path: "/repo" }), pullRequest: (input) => environmentPullRequest(input) }, projects: { list: async () => projects as never }, hosts: { list: async () => [makeHostResponse({ id: "host_a", status: "connected" }), makeHostResponse({ id: "host_b", status: "connected" })] }, }, experimental_callHostRpc: async ({ method, input, hostId }) => method === "search" ? searchResponse(input as { scope: string }, hostId) : response(input as { url: string; expectedAccountId?: string }, hostId) }); @@ -28,7 +30,7 @@ function setup() { return rpc("link", { token: preview.token }); }; const settle = async () => { await vi.waitFor(async () => expect(await rpc("list", {})).toMatchObject({ coverage: { running: false } })); }; - return { bb, harness, rpc, link, settle, threads, projects, setSearchResponse: (next: typeof searchResponse) => { searchResponse = next; }, setBeforeThreadRead: (next: typeof beforeThreadRead) => { beforeThreadRead = next; }, setResponse: (next: typeof response) => { response = next; } }; + return { bb, harness, rpc, link, settle, threads, projects, setEnvironmentPullRequest: (next: typeof environmentPullRequest) => { environmentPullRequest = next; }, setSearchResponse: (next: typeof searchResponse) => { searchResponse = next; }, setBeforeThreadRead: (next: typeof beforeThreadRead) => { beforeThreadRead = next; }, setResponse: (next: typeof response) => { response = next; } }; } describe("PR registry and host identity", () => { it("returns the complete known inbox before hierarchy filtering while preserving legacy list pages", async () => { @@ -155,7 +157,7 @@ describe("PR registry and host identity", () => { const h = setup(); h.threads.push(makeThreadResponse({ id: "thr_hidden", visibility: "hidden" }), makeThreadResponse({ id: "thr_deleted", deletedAt: Date.now() })); h.threads[1]!.archivedAt = Date.now(); - const value = { ...snapshot(), body: "BB-Thread-ID: thr_a\n[Review](https://brsbl.getbb.app/threads/thr_b)\n@thread:thr_a thr_hidden thr_deleted thr_unknown", originThreadIds: ["thr_a"] }; + const value = { ...snapshot(), body: "BB-Thread-ID: thr_a\n[Review](https://studio.example/threads/thr_b)\n@thread:thr_a thr_hidden thr_deleted thr_unknown", originThreadIds: ["thr_a"] }; h.setSearchResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshots: [value], nextCursor: null })); await h.rpc("refresh", { discover: true }); await h.settle(); const item = await h.rpc("show", { id: "github:PR_1" }); @@ -174,6 +176,40 @@ describe("PR registry and host identity", () => { await h.rpc("refresh", { discover: true }); await h.settle(); expect((await h.rpc("show", { id: item.id })).links.map((link) => link.threadId)).toEqual(["thr_a", "thr_later"]); }); + it.each([ + ["https://studio.example", "https://studio.example", true], + ["https://other.example", "https://other.example", true], + ["https://studio.example", "https://other.example", false], + [null, "http://127.0.0.1:38886", true], + ] as const)("verifies linked origins for server %s and marker %s", async (appUrl, markerUrl, expected) => { + const h = setup(appUrl); + const value = { ...snapshot(), body: `BB-Thread: [Implementation](${markerUrl}/projects/proj_demo/threads/thr_a)`, originThreadIds: ["thr_b"] }; + h.setSearchResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshots: [value], nextCursor: null })); + h.setResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshot: value })); + await h.rpc("refresh", { discover: true, includeArchived: true }); await h.settle(); + const item = await h.rpc("show", { id: "github:PR_1" }); + expect(item.links.find((link) => link.threadId === "thr_a")?.origin).toBe(expected); + expect(item.links.find((link) => link.threadId === "thr_b")?.origin).toBe(false); + }); + it("recognizes the current remote-access server origin", async () => { + const h = setup(null, "https://remote.example"); + const value = { ...snapshot(), body: "BB-Thread: [Implementation](https://remote.example/projects/proj_demo/threads/thr_a)" }; + h.setSearchResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshots: [value], nextCursor: null })); + h.setResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshot: value })); + await h.rpc("refresh", { discover: true, includeArchived: true }); await h.settle(); + const item = await h.rpc("show", { id: "github:PR_1" }); + expect(item.links.find((link) => link.threadId === "thr_a")?.origin).toBe(true); + }); + it("uses the server-access origin and still requires repository verification", async () => { + const h = setup(null, "https://remote.example"); + const value = { ...snapshot(), body: "BB-Thread: [Review](https://remote.example/projects/proj_demo/threads/thr_b)" }; + h.setSearchResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshots: [value], nextCursor: null })); + h.setResponse(async () => ({ ok: true, accountId: "U_A", login: "alice", snapshot: value })); + h.setEnvironmentPullRequest(async ({ environmentId }) => ({ outcome: "available", pullRequest: { url: environmentId === "env_b" ? "https://github.com/acme/other/pull/1" : snapshot().url } })); + await h.rpc("refresh", { discover: true, includeArchived: true }); await h.settle(); + const item = await h.rpc("show", { id: "github:PR_1" }); + expect(item.links.find((link) => link.threadId === "thr_b")?.origin).toBe(false); + }); it("does not associate a delayed body reference after GitHub access is revoked", async () => { const h = setup(); let release!: () => void; h.setBeforeThreadRead(() => new Promise((resolve) => { release = resolve; })); diff --git a/plugins/pull-requests/server.ts b/plugins/pull-requests/server.ts index bc81ef7be..d3df2cd88 100644 --- a/plugins/pull-requests/server.ts +++ b/plugins/pull-requests/server.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { cliCommand, defineCli, PluginCliError, type BbPluginApi } from "@get-bb/plugin-sdk"; import { z } from "zod"; import { CHANGED, hostContract, rpcContract, type Link, type PullRequestItem, type ReadResult, type SearchResult, type Reader, type Snapshot, type ThreadChoice } from "./contract.js"; -import { githubRepository, mapConcurrent, parsePullRequestUrl, referencedThreadIds } from "./core.js"; +import { githubRepository, mapConcurrent, originMarkers, parsePullRequestUrl, referencedThreadIds } from "./core.js"; import { createStore } from "./store.js"; type Thread = Pick>, "id" | "title" | "titleFallback" | "projectId" | "environmentId" | "archivedAt" | "deletedAt" | "visibility">; @@ -205,7 +205,17 @@ export default function plugin(bb: BbPluginApi): void { try { const current = await eligibleThread(thread.id); store.link(item.id, makeLink(await threadChoice(current), "environment", "discovery")); } catch { /* Thread deleted or hidden during discovery. */ } } // A body convention can establish origin only when one accessible thread independently resolves to this repository. - const markers = item.snapshot?.originThreadIds ?? []; + const serverUrls = [bb.server.loopbackBaseUrl]; + if (bb.server.experimental_appUrl) serverUrls.push(bb.server.experimental_appUrl); + try { + const { serverAccess } = await bb.sdk.system.config(); + if (serverAccess.effectiveUrl) serverUrls.push(serverAccess.effectiveUrl); + for (const { availability } of serverAccess.providers) { + if (availability?.status === "available" && availability.serverUrl) serverUrls.push(availability.serverUrl); + } + } catch { /* Configured app and loopback origins remain usable if access discovery fails. */ } + // Reparse on the server: host snapshots and older cached markers cannot establish this server's origin. + const markers = originMarkers(item.snapshot?.body ?? "", serverUrls); if (markers.length === 1 && item.snapshot) { try { const origin = await eligibleThread(markers[0]!); diff --git a/tooling/publish-install-refs.mjs b/tooling/publish-install-refs.mjs index a8631d06d..10aac2b67 100644 --- a/tooling/publish-install-refs.mjs +++ b/tooling/publish-install-refs.mjs @@ -26,7 +26,7 @@ const productionDependencyFields = [ "peerDependencies", ]; // Opt in per plugin so existing release histories are never rewritten. -const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "improve-prompt"]); +const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "pull-requests", "improve-prompt"]); // Preserve the published prefix when a workspace directory differs from its stable ID. const versionTagPrefixes = { "improve-prompt": "prompt-shaper" }; const explicitlyRetiredInstallRefs = Object.freeze([