Skip to content
Closed
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions plugins/pull-requests/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion plugins/pull-requests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions plugins/pull-requests/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
Binary file added plugins/pull-requests/docs/changes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/pull-requests/docs/stack-attention.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plugins/pull-requests/docs/summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 20 additions & 2 deletions plugins/pull-requests/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,25 @@ describe("GitHub read boundary", () => {
const run = vi.fn<GhRunner>().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";
Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions plugins/pull-requests/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
46 changes: 41 additions & 5 deletions plugins/pull-requests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadResult> = 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<SearchResult> = 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) });
Expand All @@ -28,7 +30,7 @@ function setup() {
return rpc<PullRequestItem>("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 () => {
Expand Down Expand Up @@ -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<PullRequestItem>("show", { id: "github:PR_1" });
Expand All @@ -174,6 +176,40 @@ describe("PR registry and host identity", () => {
await h.rpc("refresh", { discover: true }); await h.settle();
expect((await h.rpc<PullRequestItem>("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<PullRequestItem>("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<PullRequestItem>("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<PullRequestItem>("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<void>((resolve) => { release = resolve; }));
Expand Down
Loading
Loading