diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 886686c234..4383b7b49e 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -542,6 +542,10 @@ pub fn bundled_manifests() -> Vec { "../../../src/bundled/changesets/manifest.json" )) .expect("changesets manifest"), + serde_json::from_str(include_str!( + "../../../src/bundled/review-configuration/manifest.json" + )) + .expect("review configuration manifest"), ] } /// New bundles must opt in to the default-on policy. diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index c570282057..9aaadb9eff 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -1384,6 +1384,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { | "buzz.drafts-and-sent" | "buzz.changesets" | "buzz.charts" + | "buzz.review-configuration" ); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 28848960ec..e64f0583d0 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1278,6 +1278,36 @@ tab, asks **Discard changes?** first; **Keep editing** keeps them. local installs. Upgrades preserve their previous state, including Bestie off when no choice was saved. Enable or disable them in **Manage plugins**; saved choices persist. A deliberate plugin-settings reset uses the current defaults. +- **Review Configuration** is optional and starts off. Enable it in **Manage + plugins** to add its own Messages sidebar entry. Choose a review channel, + register a GitHub repository, and save named lanes with reviewer agents and + instructions. Each lane supports multiple reviewers. Saved + repositories appear in a list; open one to manage its lanes. **Reload** + reads the latest saved versions. Settings live on the community relay and are + visible to channel readers; members who can post can edit them. A conflicting + edit preserves your draft: copy your changes before cancelling and reloading. + **Review Configuration** has matching **Register repository**, **Submit PR**, and **Pull requests** actions at the top, followed by **Repositories**. **Edit lane** + opens one lane; **Save lane** persists it before **Add lane** starts another. + **Back to lanes** cancels the editor; **Back to repositories** returns to the list. + Every new or edited lane requires at least one all/any routing rule matching + title text, labels, changed-file counts, or paths such as `src/**/*.tsx`. + Earlier rule-free lanes still match every PR until edited. + The **Submit PR** page has a full GitHub PR URL field and a + Submit button. It finds the repository in the selected review channel, reads + current PR details, and assigns the first configured reviewer in each matching + lane. Missing configuration or zero matching lanes sends nothing. Results show + assigned agents and their thread buttons. Choose **Pull requests** on the main page to see previous submissions in a bordered table. The table shows GitHub status (Open, Draft, Closed, or Merged); Reload refreshes it. Remind is disabled for closed, merged, loading, or unavailable PRs and rechecks GitHub before sending. The original submitter can click **Remind** to notify assigned reviewers in their current threads, or confirm **Delete** to remove a submission from the shared list; its conversations and GitHub PR remain. Deleting does not stop active reviews. Resubmitting a deleted PR applies current routing and creates new reviewer threads. + Submitting an existing PR reuses its saved reviewers and threads and mentions + them again in those threads. Incomplete initial delivery resumes from the original + browser profile. An interrupted notification retries its saved messages. + Internal **Review status** is separate from GitHub status: Needs review, Waiting on author, or Done. Reviewers report through `pr_review_status_update` using only `done` or `wait_author`; Buzz supplies the review thread. The request includes instructions fetched from that tool. Remind and resubmitting an existing PR reset the lanes to Needs review. Reviews of a PR run sequentially in the same lane threads. Incoming reports update the table; Reload catches missed updates. Reporting requires native Codex thread sessions, a compatible shared harness, or a standalone MCP process bound to that thread by its host. An unsupported host refuses the update; a chat reply alone does not record a result. + The separate Submit PR form has a **Back to Review Configuration** button. All pages remember the selected + review channel. Reviewers must be owned by the signed-in identity and already + belong to the channel. A connected agent runtime is needed for replies; + delivery alone does not mean a review started. Desktop uses signed-in GitHub + access; browser metadata reads support public PRs and may hit rate limits. + PRs above 3,000 changed files or 100 labels are unsupported. This does not post + GitHub comments, track verdicts, pin reviewer checkouts, or schedule reminders. - A plugin's details in Manage plugins link to the Settings sections that hold its settings: sections it adds, such as GitHub comment authors, and Decisions API for a decision provider, such as TypeSafe. Only sections the Settings @@ -1499,4 +1529,3 @@ occurs. It contains operation metadata, with no draft text or secrets. See ## Plugin account actions Plugins can add actions to your avatar menu. Profile-related actions may appear just above Settings; other plugin actions appear below it. - diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index e4c2f2da2c..f608dd061c 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -73,6 +73,15 @@ export function apply(ctx) { | `subjectLift` (optional) | Lift a message image's subject | `const lift = ctx.reflect.get("subjectLift")`. `lift?.supports(source)`, then `await lift.lift(source, signal?)` resolves a transparent PNG `File` ready for `session.emoji.upload`, or rejects with a reason. `source` is `session.media(attachment.url)`. macOS 14+ only; see the plugin architecture's Subject lift contract. | | `pluginInstaller` (optional) | Ask the owner to install another plugin, check installation status, or access retained update reviews | `requestInstall(...)` resolves `"installed" \| "updated" \| "cancelled" \| "unavailable"` and never installs silently. Status methods are described below. `updates` is an optional, host-retained capability; see [Plugin architecture](plugin-architecture.md#plugin-installer). | +Plugins can read the current documentation for a built-in or active plugin tool +with `ctx.agents.describeTool(name)`. It returns a detached +`{ name, description, inputSchema }`, or `undefined` when unavailable, and never +returns the handler. Use this to compose agent instructions from the actual tool +definition instead of maintaining a second copy. Read it when composing each +message; already sent messages keep their original text. Feature-check the method +on older hosts. Describing a tool does not grant access to call it or install it +on another agent's runtime. + The installation-status methods may be missing on older hosts even when `pluginInstaller` is available, so feature-check each method before calling it. `getInstallationStatus` returns `"installed"` when the manifest ID is in the ready catalog, `"absent"` when it is not, and `"unavailable"` while the catalog is not ready. Installed status is independent of whether the plugin is enabled, contributes UI, or came from a particular install source. A subscription reports later status changes; subscribe first, then read the current status to avoid missing a change between the read and subscription. Own the subscription with `ctx.effect`: ```js diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 78423ebbc7..41d080a58a 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -94,6 +94,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.focus", "buzz.charts", "buzz.drafts-and-sent", + "buzz.review-configuration", "buzz.changesets", ].includes(plugin.manifest.id), plugin.manifest.id, @@ -103,12 +104,57 @@ test("the app runtime exposes ready bundled pages and removes them on disable", "buzz.reminders", "buzz.workflows", "buzz.drafts-and-sent", + "buzz.review-configuration", ]) { assert.equal( services.pages.snapshot().some((page) => page.pluginId === id), false, ); } + await services.plugins.change("enable", "buzz.review-configuration"); + await vi.waitFor(() => + assert.ok( + services.pages + .snapshot() + .some( + (page) => page.key === "buzz.review-configuration/configuration", + ), + ), + ); + const reviewConfiguration = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.review-configuration"); + assert.equal(reviewConfiguration.title, "Review Configuration"); + assert.equal(reviewConfiguration.primary, true); + assert.equal(reviewConfiguration.placement, "sidebar"); + assert.match( + renderToStaticMarkup(createElement(reviewConfiguration.component)), + /Connect to a community to configure reviews/, + ); + const submitPr = services.pages + .snapshot() + .find((page) => page.key === "buzz.review-configuration/submit"); + assert.ok(submitPr); + assert.equal(submitPr.title, "Submit PR"); + assert.equal(submitPr.primary, false); + assert.equal(submitPr.placement, "sidebar"); + assert.match( + renderToStaticMarkup(createElement(submitPr.component)), + /Connect to a community to submit PRs/, + ); + const allPrs = services.pages + .snapshot() + .find((page) => page.key === "buzz.review-configuration/all"); + assert.ok(allPrs); + assert.equal(allPrs.title, "All PRs"); + assert.equal(allPrs.primary, false); + await services.plugins.change("disable", "buzz.review-configuration"); + assert.equal( + services.pages + .snapshot() + .some((page) => page.pluginId === "buzz.review-configuration"), + false, + ); await services.plugins.change("enable", "buzz.reminders"); await vi.waitFor(() => assert.ok( diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index e0505dd48f..9180b44a0c 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -6,6 +6,7 @@ import { NoteIcon, GearIcon, LightningIcon, + ListChecksIcon, RobotIcon, SendIcon, } from "../../shared/design-system/icons/index"; @@ -58,6 +59,7 @@ const bundledIcons = new Map([ ["buzz.reminders/reminders", AlarmIcon], ["buzz.agents/agents", RobotIcon], ["buzz.workflows/workflows", LightningIcon], + ["buzz.review-configuration/configuration", ListChecksIcon], ["buzz.relay-notes/notes", NoteIcon], ]); /** Resolve host navigation policy once, independently of icons and registrations. */ diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 3dd23e4284..1e9d7232e1 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -328,6 +328,7 @@ function setup( session: vi.fn(async () => {}), register: vi.fn(), addTool: vi.fn(), + describeTool: vi.fn(), createToolOperation: vi.fn(() => { throw new Error("No operation provider in this fixture"); }), diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 09a2427367..9d96524b4d 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -4880,7 +4880,7 @@ it("offers the tools plugins add, runs them as the agent here, and starts a new expect(JSON.stringify(reply?.result)).toContain("created"); expect(call).toHaveBeenCalledWith( expect.objectContaining({ pubkey }), - { channel: "channel", root }, + { channel: "channel", root, review: { channel: "channel", root } }, { title: "Ship it" }, ); await f.complete(); @@ -7377,6 +7377,47 @@ it("neither lists nor runs the retired owner-read tools, while its own reads wor await f.complete(); }); +it("supplies an immutable review thread outside tool arguments and omits it for channel-wide sessions", async () => { + const f = fixture(); + const contexts: unknown[] = []; + f.added.push({ + name: "inspect_review_context", + description: "Inspect host context.", + inputSchema: { type: "object", properties: {} }, + call: async (_client, context) => { + contexts.push(context.review); + return "inspected"; + }, + }); + await f.runtime.run(f.delivery("review")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.tool("inspect_review_context", { + review: { channel: "wrong", root: "wrong" }, + }); + await f.runtime.run(f.delivery("follow-up")); + await vi.waitFor(() => + expect(f.sent.some((wire) => wire.method === "turn/steer")).toBe(true), + ); + await f.tool("inspect_review_context", {}); + expect(contexts).toEqual([ + { channel: "channel", root }, + { channel: "channel", root }, + ]); + await f.complete(); + await vi.waitFor(() => + expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"), + ); + const next = f.delivery("channel scope", "d".repeat(64)); + await f.runtime.run({ + ...next, + config: { ...(next.config as object), scope: "channel" }, + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + await f.tool("inspect_review_context", {}, 1); + expect(contexts[2]).toBeUndefined(); + await f.complete(1); +}); + it("supplies an operation proof only while the native Codex call is active", async () => { const f = fixture(); const provider = createToolOperation("example/proposal", () => ({ diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index e8d8a88930..3fb9c249ab 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -153,6 +153,7 @@ type Saved = { protection?: string; }; type Active = { + readonly review?: Context["review"]; /** The server this turn runs on; steering must reach the same process. */ rpc: AppServer; threadId?: string; @@ -2324,6 +2325,7 @@ export class CodexRuntime { // A watch or timer turn has no default destination. const context: Context = request ? { + ...(active?.review ? { review: active.review } : {}), ...(request.session ? { session: request.session } : {}), channel: request.conversation.channelId, ...(request.toolRoot ? { root: request.toolRoot } : {}), @@ -2774,6 +2776,14 @@ export class CodexRuntime { ...(adopted && lane.voice ? [lane.voice.signal] : []), ]); const active: Active = { + ...(request.conversation.root && !request.wake + ? { + review: { + channel: request.conversation.channelId, + root: request.conversation.root, + }, + } + : {}), rpc: server.rpc, ...(adopted && lane.voice ? { threadId: lane.voice.loaded.threadId, turnId: adopted } diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 57122af5c8..4486ab0b63 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -62,6 +62,8 @@ import changesetsManifest from "./changesets/manifest.json"; import * as changesets from "./changesets"; import workflowsManifest from "./workflows/manifest.json"; import * as workflows from "./workflows"; +import reviewConfigurationManifest from "./review-configuration/manifest.json"; +import * as reviewConfiguration from "./review-configuration"; import type { BundledPlugin } from "../plugins/manager"; import linksManifest from "./links/manifest.json"; import * as links from "./links"; @@ -258,6 +260,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: workflows, enabledByDefault: false, }, + { + manifest: { ...reviewConfigurationManifest, apiVersion: 1 }, + module: reviewConfiguration, + enabledByDefault: false, + }, { manifest: { ...moderationManifest, apiVersion: 1 }, module: moderation, diff --git a/src/bundled/navigation-owner.test.ts b/src/bundled/navigation-owner.test.ts index 964da081b0..130008dc7c 100644 --- a/src/bundled/navigation-owner.test.ts +++ b/src/bundled/navigation-owner.test.ts @@ -47,6 +47,7 @@ it("gives every bundled page to the app whose navigation lists it", async () => // Pages listed in the Messages sidebar keep Messages selected. "buzz.drafts-and-sent/drafts-sent": messages, "buzz.inbox/inbox": messages, + "buzz.review-configuration/configuration": messages, // Dock apps own themselves. "buzz.bestie/bestie": "buzz.bestie/bestie", "buzz.reminders/reminders": "buzz.reminders/reminders", diff --git a/src/bundled/review-configuration/LaneReviewers.tsx b/src/bundled/review-configuration/LaneReviewers.tsx new file mode 100644 index 0000000000..d6feada9dc --- /dev/null +++ b/src/bundled/review-configuration/LaneReviewers.tsx @@ -0,0 +1,112 @@ +import type { AgentChoicesSnapshot } from "../../features/agents/choices"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Select } from "../../shared/design-system/ui/Select"; + +/** Selection records identities only; it grants no channel access or execution authority. */ +export function LaneReviewers({ + lane, + reviewers, + choices, + disabled, + onChange, +}: { + lane: number; + reviewers: readonly string[]; + choices: AgentChoicesSnapshot; + disabled: boolean; + onChange: (reviewers: readonly string[]) => void; +}) { + const available = choices.selectable.filter( + (agent) => !reviewers.includes(agent.pubkey), + ); + return ( +
+ + onChange({ ...routing, mode: mode as LaneRouting["mode"] }) + } + /> + + {routing.rules.map((rule, index) => ( +
+
+ Rule {index + 1} + +
+
+ + update(index, { + ...rule, + operator: operator as LaneRule["operator"], + }) + } + /> + + + update(index, { ...rule, value: text }) + } + /> + +
+
+ ))} +
+
+ +
+ + ); +} + +export function ruleDescription(rule: LaneRule) { + return `${ruleFields.find((field) => field.value === rule.field)?.label} ${ruleOperators[rule.field].find((operator) => operator.value === rule.operator)?.label} “${rule.value}”`; +} diff --git a/src/bundled/review-configuration/README.md b/src/bundled/review-configuration/README.md new file mode 100644 index 0000000000..5edf8d03ec --- /dev/null +++ b/src/bundled/review-configuration/README.md @@ -0,0 +1,198 @@ +# Review Configuration + +An optional bundled plugin with a separate sidebar page for repository review +settings. Enable **Review Configuration** in Settings → Plugins → Manage plugins. +Choose a channel, register a GitHub repository, add named lanes, reviewer agents, and instructions, +and save. Open a repository row to manage its lanes. Saved cards show agents, +instructions, and routing rules. **Edit lane** opens one lane; **Save lane** persists +it before another can be added. **Back to lanes** cancels edits; **Back to repositories** +returns to the list. The main page has matching **Register repository**, **Submit PR**, and **Pull requests** actions above the **Repositories** section. + +The **Submit PR** page has a full GitHub PR URL field and one +Submit button. It finds the saved repository in the selected channel, loads current +PR metadata, and assigns the first configured reviewer per matching lane. Missing +configuration, unavailable reviewers, and zero matching lanes produce an error +without starting threads. Results show assigned reviewers and thread buttons. +The separate **Pull requests** page shows previous submissions in a bordered table with delivery status, reviewer identities, and thread links. The table shows GitHub status (Open, Draft, Closed, or Merged); Reload refreshes it. Remind is disabled for closed, merged, loading, or unavailable PRs and rechecks GitHub before sending. The submitter can click **Remind** to notify saved reviewers in their current threads, with delivery feedback and retry on failure. Incomplete submissions must finish delivery through Submit PR first. The submitter can confirm **Delete** to remove a submission from the shared list. This does not stop active reviews, delete conversations, or close the GitHub PR. Submitting that URL again applies current routing and creates new reviewer threads. + +Submitting an already-delivered PR sends a reviewer mention in each saved lane +thread. It preserves the saved reviewers and instructions and creates no new +threads. It resets each lane to Needs review. Incomplete original submissions resume delivery from +the original browser profile instead. These actions run only on Submit; opening +the page or a thread never dispatches messages. + +All three pages use the shell's page navigation. Only Review Configuration appears in the sidebar; Submit PR has a Back button returning to it. Pull requests opens the existing All PRs page with Back navigation. Repository and lane editing +remain local sections; reloading returns Configuration to its repository list. +Restarted submissions use a versioned delivery generation; older plugin versions cannot read or resume them. +The channel selection is remembered per identity/community; one eligible channel +is selected automatically. Submit results can be recovered from the Pull requests table +following reload; navigation never replays Submit automatically. + +## Getting started + +Enable this plugin in **Settings → Plugins → Manage plugins**, then open its +sidebar entry. Choose a channel and register a repository. For each lane, select +reviewer agents, enter instructions, and configure at least one routing rule, then save. Open the repository to edit settings. +These selections do not start reviews or grant agents access to the channel. + +--- + +## Storage and access + +`configuration.ts` owns the save/load boundary. Each channel/repository pair has +one deterministic NIP-AR artifact of type `buzz.review.config` (kind 45010). +The signed event's `h` tag is its review channel. Its content is: + +```json +{ + "version": 1, + "repository": "example-org/project", + "lanes": [ + { + "id": "general", + "name": "General", + "instructions": "Check authorization, error handling, and tests.", + "reviewers": [] + } + ], + "reminderMinutes": 15 +} +``` + +The existing relay artifact writer persists the current head in `artifact_heads`, +the accepted revisions in `artifact_revisions`, and the signed payload in `events`. +There is no new SQL migration or local-only settings store. Ordinary channel +authorization applies: readers can see instructions, and members who can post +can edit them. Configuration is scoped to a channel, not globally unique across +the community. No GitHub credentials are stored or requested, and repository +existence/access is checked when loading PR details for submission. + +Edits carry the revision that the form loaded in `prev`; the relay rejects a +stale edit atomically. A failed or uncertain save keeps the draft. Copy any +changes before Cancel → Reload, then inspect the saved version before retrying. +An unsupported or malformed row shows a warning without hiding valid rows. +Channel metadata reconfirmation disables Save while retaining an open draft. + +Opening a channel or clicking Reload makes one bounded snapshot query. There +are no reconnect subscriptions or polling timers. The first version supports +100 configurations per channel, 12 lanes per repository, and a 32 KB payload. +Larger result sets are explicitly marked incomplete; use a separate channel. +The reminder interval is saved for the planned executor but has no effect yet. + +## Try it + +1. Enable the plugin and open **Review Configuration** in the Messages sidebar. +2. Choose a writable stream or forum channel and click **Register repository**. +3. Enter a repository, select lane reviewers, add instructions, then **Save settings**. +4. Reload the page, choose the same channel, and verify the repository row. +5. Edit the instructions, save, and use **Reload** to confirm the new version. +6. In two clients, edit the same version; the second save must fail without + discarding its draft after the first client saves. + +Use a test channel. Saving does not activate any review automation. + +Reviewer choices come from the session’s shared agent inventory. Saved public keys +remain visible and removable if an agent disappears or is archived. Unassigned +lanes may be saved as drafts. Submission requires one available reviewer per matching lane, +verified ownership by the signed-in user, and existing channel membership. The +outbox rechecks membership before publishing mentions. Agent ownership is checked +from fresh signed profiles; archive evidence and shared agent choices must be +available. Run a connected agent in the desktop app to receive replies. A browser +lab with registered identities alone cannot execute a review. + +## Submissions and recovery + +One `buzz.review.submission` artifact per channel/repository/PR stores the selected +reviewers, instructions, configuration revision, and message IDs. New version-2 +submissions store only matching lane threads. Earlier version-1 submissions retain +their overview and original lane IDs. Explicit resubmission notifies lane reviewers +only, never the overview. Reviewer ownership and availability are revalidated; +the outbox verifies channel membership before publishing mentions. + +The URL identifies the repository. Each Submit reads saved configuration fresh. +For a new submission, the reader obtains complete and consistent GitHub title, +labels, file count, paths, and base/head commits before routing. Existing submissions +reuse their snapshot without requiring a GitHub read. Reviewers must verify the +current PR and report the commit they reviewed; this does not pin their checkout. + +Artifact create/CAS prevents competing initial submissions. A Web Lock serializes +operations in the same profile. Original delivery belongs to the submitting profile; +other profiles may read its links but cannot take over incomplete initial delivery. +Dispatch intent is saved before enqueue, and the durable outbox retains message +identities through interrupted saves or uncertain receipts. Missing recovery data +stops delivery instead of creating replacement threads. + +Explicit notifications use a recovery key per saved lane thread in the current +identity/community's outbox. Successful receipts remain until the batch succeeds, +so retrying a partial batch does not recreate its acknowledged messages. After +completion another explicit Submit starts another notification batch. These are +user-triggered follow-ups, not an exactly-once cross-device notification guarantee. +A failed notification leaves existing thread buttons available and reports failure. + +Opening Submit PR reads the latest 100 submissions once, with truncation/error +feedback. Submit reads configurations and the current submission, then verifies +reviewer profiles and ownership before dispatch. No polling or reconnect reads +are added. No GitHub comments or scheduled reminders are added. + +Try a fresh test PR URL → Submit PR → Open a lane thread. Check its URL, reviewer +mention, and complete instructions. Submit the same URL again: the same threads +must receive follow-ups. Check unknown repositories, zero matching lanes, and +interrupted delivery. Required channel membership and connected reviewer runtimes +are separate from configuration. + +## Routing rules + +New or edited lanes require 1–12 rules, combined with **Match all rules** or **Match any +rule**. Earlier rule-free lanes remain readable and match every PR until edited; editing one requires adding a rule. A PR can match several lanes; zero matches +prevents submission. Title contains/does-not-contain and exact label matching +ignore case. File counts support inclusive minimum and maximum values. Paths are +case-sensitive and relative to the repository: `*` and `?` match within a path +segment, while `**` matches zero or more complete segments. `src/**/*.tsx` matches +TSX files below `src`. A negative path rule matches only when no changed path +matches. Renames use the new path. + +Desktop reads use the existing signed-in GitHub CLI. Browser reads use GitHub's +public API without credentials, so private PRs and rate-limited requests need the +desktop host. The reader rejects incomplete/changing metadata and supports at +most 3,000 changed files and 100 labels; it never routes on a truncated file list. +Metadata is fetched during new submission only; there is no poller. + + +## Internal review status (POC) + +Each lane starts at **Needs review**. Its assigned reviewer calls the standard +Buzz tool `pr_review_status_update` with only `status: "done"` or +`status: "wait_author"`. The host supplies the review thread outside the model's +arguments; the tool resolves its current assignment and checks the reviewer. +One review is active per PR; re-reviews happen sequentially in the saved lane +threads. This POC does not bind a late call from an older turn to an older round. + +**Remind** and resubmitting a listed PR reset its lanes to **Needs review** and +send updated requests in those same threads. Failed notification batches resume +from the original browser profile without replacing accepted messages. Older +submissions gain status reporting when reminded. Overall status is **Waiting on +author** if any lane needs changes, **Needs review** if any lane is pending, and +**Done** when all lanes are done. GitHub status remains separate. + +Signed kind-9 status messages are both the durable report and the thread +notification. The tool and dashboard validate the assigned reviewer and current +request token. These are application checks; the generic relay enforces its +existing signature and channel-access rules. Report retries reuse the saved +status; sequential updates use a sequence number for ordering within a second. +The table applies incoming reports without polling. Reload reconciles shared +submission changes and missed reports. A full snapshot uses one artifact read +and up to twelve report reads, in batches of 100 current request tokens; capped +report pages fail visibly rather than supplying incomplete statuses. + +The plugin obtains the tool's description and schema with +`agents.describeTool("pr_review_status_update")` when composing a request and +appends them after lane instructions. It maintains no copy of that documentation. + +Native Codex thread sessions and the shared HarnessKit provide review context. +A standalone MCP host can set `BUZZ_CHANNEL` and `BUZZ_REVIEW_THREAD` when starting +a process dedicated to that thread. Never reuse that process across threads. +Channel-wide sessions, external MCP, and adapters without this host wiring refuse +status updates. Merely including a thread ID in the prompt does not enable it. +ACP hosts must pass those variables when creating each thread session and +recreate existing sessions after adding the wiring. Updating MCP alone does not +provide review context. diff --git a/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx b/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx new file mode 100644 index 0000000000..5ad9130c31 --- /dev/null +++ b/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx @@ -0,0 +1,677 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { EventTemplate } from "nostr-tools"; +import type { RelaySession } from "../../features/relay/session"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { EventData } from "../../features/relay/events"; +import { keypair, signed } from "../../features/relay/testing"; +import { ReviewConfigurationCommunity } from "./ReviewConfigurationPage"; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); +const channel = "11111111-1111-4111-8111-111111111111"; +function fixture() { + const key = keypair(); + let events: EventData[] = []; + let list = { + status: "ready", + channels: [ + { id: channel, name: "Reviews", channelType: "stream" } as ChannelSummary, + ], + }; + const listeners = new Set<() => void>(); + const agents = [ + { + pubkey: "a".repeat(64), + name: "Backend reviewer", + managed: true, + avatar: "https://example.org/agent.png", + }, + { pubkey: "b".repeat(64), name: "UI reviewer", managed: true }, + ]; + let choices = { + error: undefined as string | undefined, + status: "ready", + pending: false, + complete: true, + identities: agents, + selectable: agents, + archives: { + status: "ready", + archived: [], + error: undefined as string | undefined, + }, + definitions: [], + }; + const snapshotRead = vi.fn(async () => events); + const publish = vi.fn(async (event: EventData) => { + events = [event]; + return event.id; + }); + const session = { + scope: "review-test-scope", + agentChoices: { + snapshot: () => choices, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure() {}, + refresh: vi.fn(async () => { + choices = { + ...choices, + error: undefined, + selectable: agents, + archives: { ...choices.archives, error: undefined }, + }; + for (const listener of listeners) listener(); + }), + retain: () => () => {}, + }, + channels: { + ensureList() {}, + list: () => list, + subscribeList: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + snapshotRead, + configArtifactWriter: { + sign: async (template: EventTemplate) => signed(key, template), + publish, + }, + } as unknown as RelaySession; + return { + session, + publish, + snapshotRead, + setChannels(channels: ChannelSummary[]) { + list = { ...list, channels }; + for (const listener of listeners) listener(); + }, + failAgents() { + choices = { + ...choices, + error: "Inventory unavailable", + selectable: [], + archives: { ...choices.archives, error: "Archive read failed" }, + }; + for (const listener of listeners) listener(); + }, + hideAgents() { + choices = { ...choices, selectable: [] }; + for (const listener of listeners) listener(); + }, + metadataCached(cached: boolean) { + list = { + ...list, + channels: [ + { + id: channel, + name: "Reviews", + channelType: "stream", + ...(cached ? { metadataCached: true } : {}), + }, + ], + }; + for (const listener of listeners) listener(); + }, + }; +} + +async function openDraft() { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Review channel" })); + await user.click(await screen.findByRole("option", { name: "Reviews" })); + await screen.findByText( + "No repositories configured in this channel. Register one to save its review lanes.", + ); + await user.click(screen.getByRole("button", { name: "Register repository" })); + await user.type( + screen.getByRole("textbox", { name: "GitHub repository" }), + "example-org/project", + ); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Check authorization and keyboard navigation.", + ); + await user.type(screen.getByRole("textbox", { name: "Value" }), "review"); + return user; +} + +it("registers, reloads and edits a repository through the real form and artifact writer", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + await user.click(screen.getByRole("button", { name: "Reload" })); + await screen.findByRole("button", { name: "Edit General lane" }); + await user.click(screen.getByRole("button", { name: "Edit General lane" })); + expect( + ( + screen.getByRole("textbox", { + name: "Instructions", + }) as HTMLTextAreaElement + ).value, + ).toBe("Check authorization and keyboard navigation."); + await user.clear(screen.getByRole("textbox", { name: "Instructions" })); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Check database migrations.", + ); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + expect(test.publish).toHaveBeenCalledTimes(2); + expect(test.publish.mock.calls[1]?.[0].tags).toContainEqual([ + "prev", + test.publish.mock.calls[0]?.[0].id, + ]); +}); + +it("keeps the draft on rejection, prevents duplicate submissions, and recovers", async () => { + const test = fixture(); + let reject: (error: Error) => void = () => {}; + test.publish.mockImplementationOnce( + () => + new Promise((_resolve, rejectSave) => { + reject = rejectSave; + }), + ); + render(); + const user = await openDraft(); + await user.dblClick(screen.getByRole("button", { name: "Save lane" })); + await waitFor(() => expect(test.publish).toHaveBeenCalledTimes(1)); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await act(async () => reject(new Error("conflict: artifact head changed"))); + expect((await screen.findByRole("alert")).textContent).toContain( + "Your draft is still here", + ); + expect( + ( + screen.getByRole("textbox", { + name: "Instructions", + }) as HTMLTextAreaElement + ).value, + ).toContain("keyboard navigation"); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); +}); + +it("preserves the draft while channel authorization reconfirms, but gates Save", async () => { + const test = fixture(); + render(); + await openDraft(); + act(() => test.metadataCached(true)); + expect( + (screen.getByRole("button", { name: "Save lane" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + ( + screen.getByRole("textbox", { + name: "Instructions", + }) as HTMLTextAreaElement + ).value, + ).toContain("keyboard navigation"); + act(() => test.metadataCached(false)); + expect( + (screen.getByRole("button", { name: "Save lane" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + expect( + ( + screen.getByRole("textbox", { + name: "Instructions", + }) as HTMLTextAreaElement + ).value, + ).toContain("keyboard navigation"); + expect(test.publish).not.toHaveBeenCalled(); +}); + +it("aborts an unfinished write when the page leaves its session", async () => { + const test = fixture(); + const publish = vi.fn( + (_event: EventData, signal: AbortSignal) => + new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason)), + ), + ); + const session = { + ...test.session, + configArtifactWriter: { ...test.session.configArtifactWriter, publish }, + } as RelaySession; + const view = render(); + const user = await openDraft(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await waitFor(() => expect(publish).toHaveBeenCalledTimes(1)); + view.unmount(); + expect(publish.mock.calls[0]?.[1].aborted).toBe(true); +}); + +it("counts unreadable records toward the limit when a new repository is saved", async () => { + const test = fixture(); + test.snapshotRead.mockResolvedValue( + Array.from({ length: 99 }, () => ({ + kind: 45010, + id: "a".repeat(64), + pubkey: "b".repeat(64), + sig: "", + created_at: 1, + tags: [ + ["h", channel], + ["type", "buzz.review.config"], + ], + content: "{broken", + })), + ); + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Review channel" })); + await user.click(await screen.findByRole("option", { name: "Reviews" })); + await screen.findByText("No readable configurations in this channel."); + await user.click(screen.getByRole("button", { name: "Register repository" })); + await user.type( + screen.getByRole("textbox", { name: "GitHub repository" }), + "example-org/project", + ); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Check tests.", + ); + await user.type(screen.getByRole("textbox", { name: "Value" }), "review"); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + await user.click( + screen.getByRole("button", { name: "Back to repositories" }), + ); + expect( + ( + screen.getByRole("button", { + name: "Register repository", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); +}); + +it("saves multiple lane reviewers, reloads their identities, and preserves unavailable selections", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + await user.click(screen.getByRole("combobox", { name: "Agents" })); + await user.click( + await screen.findByRole("option", { name: "Backend reviewer (aaaaaaaa)" }), + ); + await user.click(screen.getByRole("combobox", { name: "Agents" })); + await user.click( + await screen.findByRole("option", { name: "UI reviewer (bbbbbbbb)" }), + ); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + const content = test.publish.mock.calls[0]?.[0].content; + expect(JSON.parse(content ?? "{}").lanes[0].reviewers).toEqual([ + "a".repeat(64), + "b".repeat(64), + ]); + await user.click(screen.getByRole("button", { name: "Reload" })); + await user.click( + await screen.findByRole("button", { name: "Edit General lane" }), + ); + await screen.findByRole("button", { + name: "Remove Backend reviewer from lane 1", + }); + act(() => test.hideAgents()); + await screen.findByText("Backend reviewer — unavailable in this session"); + await user.click( + screen.getByRole("button", { name: "Remove Backend reviewer from lane 1" }), + ); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + expect( + JSON.parse(test.publish.mock.calls[1]?.[0].content ?? "{}").lanes[0] + .reviewers, + ).toEqual(["b".repeat(64)]); +}); + +it("refreshes failed agent and archive reads without discarding the draft", async () => { + const test = fixture(); + test.failAgents(); + render(); + const user = await openDraft(); + expect(screen.getByText(/Inventory unavailable/)).toBeTruthy(); + expect(screen.getByText(/Archive read failed/)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Refresh agents" })); + expect(test.session.agentChoices.refresh).toHaveBeenCalledWith(true); + await user.click(screen.getByRole("combobox", { name: "Agents" })); + await user.click( + await screen.findByRole("option", { name: "Backend reviewer (aaaaaaaa)" }), + ); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + expect( + JSON.parse(test.publish.mock.calls[0]?.[0].content ?? "null").lanes[0] + .reviewers, + ).toEqual(["a".repeat(64)]); +}); + +it("selects the only channel, remembers another selection, and scopes it to the session", async () => { + const test = fixture(); + const view = render(); + await screen.findByRole("button", { name: "Register repository" }); + expect(localStorage.getItem("buzz.review.channel:review-test-scope")).toBe( + channel, + ); + const second = "22222222-2222-4222-8222-222222222222"; + act(() => + test.setChannels([ + { id: channel, name: "Reviews", channelType: "stream" } as ChannelSummary, + { + id: second, + name: "Other reviews", + channelType: "stream", + } as ChannelSummary, + ]), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: "Review channel" })); + await user.click( + await screen.findByRole("option", { name: "Other reviews" }), + ); + expect(localStorage.getItem("buzz.review.channel:review-test-scope")).toBe( + second, + ); + view.unmount(); + const restored = render( + , + ); + expect( + screen.getByRole("combobox", { name: "Review channel" }).textContent, + ).toContain("Other reviews"); + restored.unmount(); + render( + , + ); + expect( + screen.queryByRole("button", { name: "Register repository" }), + ).toBeNull(); + expect(localStorage.getItem("buzz.review.channel:review-test-scope")).toBe( + second, + ); +}); + +it("saves path rules through field changes and preserves them after reload", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + await user.click(screen.getByRole("combobox", { name: "Field" })); + await user.click( + await screen.findByRole("option", { name: "Changed file path" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Value" }), + "src/**/*.tsx", + ); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + const saved = JSON.parse(test.publish.mock.calls[0]?.[0].content ?? "{}") + .lanes[0].routing; + expect(saved.rules[0]).toMatchObject({ + field: "path", + operator: "matches", + value: "src/**/*.tsx", + id: expect.any(String), + }); + await user.click(screen.getByRole("button", { name: "Reload" })); + await user.click( + await screen.findByRole("button", { name: "Edit General lane" }), + ); + expect( + ( + screen.getByRole("textbox", { + name: "Value", + }) as HTMLInputElement + ).value, + ).toBe("src/**/*.tsx"); + expect( + ( + screen.getByRole("button", { + name: "Remove rule 1 from lane 1", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + expect(test.publish).toHaveBeenCalledTimes(1); +}); + +it("preserves the remembered channel until pending metadata finishes", async () => { + const test = fixture(); + const second = "22222222-2222-4222-8222-222222222222"; + localStorage.setItem("buzz.review.channel:review-test-scope", second); + test.setChannels([ + { id: channel, name: "Reviews", channelType: "stream" } as ChannelSummary, + { id: second, metadataPending: true } as ChannelSummary, + ]); + render(); + expect(localStorage.getItem("buzz.review.channel:review-test-scope")).toBe( + second, + ); + act(() => + test.setChannels([ + { id: channel, name: "Reviews", channelType: "stream" } as ChannelSummary, + { + id: second, + name: "Remembered reviews", + channelType: "stream", + } as ChannelSummary, + ]), + ); + await screen.findByRole("button", { name: "Register repository" }); + expect( + screen.getByRole("combobox", { name: "Review channel" }).textContent, + ).toContain("Remembered reviews"); + expect(localStorage.getItem("buzz.review.channel:review-test-scope")).toBe( + second, + ); +}); + +it("opens dedicated pages from configuration and blocks leaving lane drafts", async () => { + const test = fixture(); + const onNavigate = vi.fn(async () => {}); + render( + , + ); + const user = await openDraft(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + expect(screen.queryByRole("region", { name: "PR submissions" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Edit General lane" })); + expect( + screen.queryByRole("button", { name: "Back to repositories" }), + ).toBeNull(); + const field = screen.getByRole("textbox", { name: "Instructions" }); + await user.clear(field); + await user.type(field, "Preserve draft"); + expect(screen.queryByRole("button", { name: "Submit PR" })).toBeNull(); + expect((field as HTMLTextAreaElement).value).toBe("Preserve draft"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click( + screen.getByRole("button", { name: "Back to repositories" }), + ); + await user.click(screen.getByRole("button", { name: "Submit PR" })); + expect(onNavigate).toHaveBeenCalledWith("submit"); + await user.click( + screen.getByRole("button", { name: "Open example-org/project" }), + ); + expect( + screen.getByRole("button", { name: "Edit General lane" }), + ).toBeTruthy(); +}); + +it("blocks editor entry while a reload can replace the repository", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + let finish!: (events: EventData[]) => void; + test.snapshotRead.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await user.click(screen.getByRole("button", { name: "Reload" })); + try { + expect( + ( + screen.getByRole("button", { + name: "Edit General lane", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + } finally { + await act(async () => finish([])); + } + await screen.findByRole("button", { name: "Register repository" }); + expect( + ( + screen.getByRole("combobox", { + name: "Review channel", + }) as HTMLButtonElement + ).disabled, + ).toBe(false); +}); + +it("opens the separate submit page without repository or lane configuration", async () => { + const test = fixture(); + render(); + await screen.findByRole("textbox", { name: "GitHub PR URL" }); + expect(screen.getByRole("button", { name: "Submit PR" })).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Register repository" }), + ).toBeNull(); + expect(screen.queryByText("Repositories")).toBeNull(); +}); + +it("requires a routing value and saves each lane before allowing another", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + const value = screen.getByRole("textbox", { name: "Value" }); + await user.clear(value); + await user.click(screen.getByRole("button", { name: "Save lane" })); + expect((await screen.findByRole("alert")).textContent).toContain( + "Each routing rule needs", + ); + expect(test.publish).not.toHaveBeenCalled(); + expect( + (screen.getByRole("button", { name: "Add lane" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await user.type(value, "review"); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + await user.click(screen.getByRole("button", { name: "Add lane" })); + await user.type( + screen.getByRole("textbox", { name: "Lane name" }), + "Backend", + ); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Check quantity validation.", + ); + await user.type(screen.getByRole("textbox", { name: "Value" }), "backend"); + expect( + (screen.getByRole("button", { name: "Add lane" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await user.click(screen.getByRole("combobox", { name: "Agents" })); + await user.click( + await screen.findByRole("option", { name: "Backend reviewer (aaaaaaaa)" }), + ); + expect( + screen.getByRole("img", { name: "Backend reviewer avatar" }), + ).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByRole("region", { name: "Backend lane" }); + const saved = JSON.parse(test.publish.mock.calls[1]?.[0].content ?? "{}"); + expect(saved.lanes.map((lane: { name: string }) => lane.name)).toEqual([ + "General", + "Backend", + ]); + expect(test.publish.mock.calls[1]?.[0].tags).toContainEqual([ + "prev", + test.publish.mock.calls[0]?.[0].id, + ]); + await user.click(screen.getByRole("button", { name: "Edit Backend lane" })); + expect( + (screen.getByRole("textbox", { name: "Value" }) as HTMLInputElement).value, + ).toBe("backend"); + await user.click(screen.getByRole("button", { name: "Back to lanes" })); + expect(screen.getByRole("button", { name: "Edit Backend lane" })).toBe( + document.activeElement, + ); + expect(test.publish).toHaveBeenCalledTimes(2); +}); + +it("returns focus to the lane region after removing the edited lane", async () => { + const test = fixture(); + render(); + const user = await openDraft(); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await screen.findByText("Saved example-org/project."); + await user.click(screen.getByRole("button", { name: "Add lane" })); + await user.type(screen.getByRole("textbox", { name: "Lane name" }), "Second"); + await user.type( + screen.getByRole("textbox", { name: "Instructions" }), + "Review tests", + ); + await user.type(screen.getByRole("textbox", { name: "Value" }), "tests"); + await user.click(screen.getByRole("button", { name: "Save lane" })); + await user.click( + await screen.findByRole("button", { name: "Edit Second lane" }), + ); + await user.click(screen.getByRole("button", { name: "Remove lane" })); + expect(test.publish).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole("button", { name: "Confirm remove lane" })); + await waitFor(() => + expect( + screen.getByRole("region", { name: "Repository configuration" }), + ).toBe(document.activeElement), + ); + expect( + JSON.parse(test.publish.mock.calls[2]?.[0].content ?? "{}").lanes.map( + (lane: { name: string }) => lane.name, + ), + ).toEqual(["General"]); +}); + +it("opens the PR table from the home page action", async () => { + const test = fixture(); + const onNavigate = vi.fn(async () => {}); + render( + , + ); + const user = userEvent.setup(); + await screen.findByRole("button", { name: "Register repository" }); + expect(screen.queryByRole("region", { name: "All PRs" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Pull requests" })); + expect(onNavigate).toHaveBeenCalledWith("all"); + await user.click(screen.getByRole("button", { name: "Submit PR" })); + expect(onNavigate).toHaveBeenCalledWith("submit"); +}); diff --git a/src/bundled/review-configuration/ReviewConfigurationPage.tsx b/src/bundled/review-configuration/ReviewConfigurationPage.tsx new file mode 100644 index 0000000000..4e84f7ddde --- /dev/null +++ b/src/bundled/review-configuration/ReviewConfigurationPage.tsx @@ -0,0 +1,926 @@ +import type { ToolDescription } from "../../buzz-mcp/session-tools"; +import { + createPullReader, + createPullStatusReader, + type ReadPull, + type ReadPullStatus, +} from "./pull-facts"; +const publicPullReader = createPullReader(); +const publicStatusReader = createPullStatusReader(); +import { LaneRules, ruleDescription } from "./LaneRules"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import { ReviewSubmissions } from "./ReviewSubmissions"; +import type { Navigation } from "../../features/navigation/controller"; +import { splitPartition } from "../../features/relay/partition"; +import { LaneReviewers, ReviewerIdentity } from "./LaneReviewers"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { + ArrowLeftIcon, + ListChecksIcon, +} from "../../shared/design-system/icons"; +import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { FullPageSurface } from "../../shared/design-system/ui/FullPageSurface"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { Input } from "../../shared/design-system/ui/Input"; +import { + PanelHeader, + PanelHeaderLabel, +} from "../../shared/design-system/ui/PanelHeader"; +import { Select } from "../../shared/design-system/ui/Select"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import { + loadConfigurations, + repositoryName, + saveConfiguration, + type ReviewConfig, + type ReviewLane, + type SavedReviewConfig, +} from "./configuration"; +import "./review-configuration.css"; + +export type ReviewPage = "configuration" | "submit" | "all"; +export const reviewPageTitle = (page: ReviewPage) => + page === "submit" + ? "Submit PR" + : page === "all" + ? "All PRs" + : "Review Configuration"; + +function Frame({ + children, + page = "configuration", +}: { + children: ReactNode; + page?: ReviewPage; +}) { + const title = reviewPageTitle(page); + return ( + +
+ } />} + /> +
+
+ {children} +
+
+
+
+ ); +} + +export function ReviewConfigurationPage({ + relay, + navigator, + readPull = publicPullReader, + readStatus = publicStatusReader, + describeTool, + page = "configuration", +}: { + page?: ReviewPage; + relay: RelayData; + navigator?: Navigation; + readPull?: ReadPull; + readStatus?: ReadPullStatus; + describeTool?: ((name: string) => ToolDescription | undefined) | undefined; +}) { + const connection = useRelayConnection(relay); + if (connection.status === "ready") + return ( + { + const result = await navigator.open({ + version: 1, + kind: "page", + pluginId: "buzz.review-configuration", + pageId: page, + }); + if (result.status === "failed") + throw new Error("Could not open review page."); + } + : undefined + } + onOpenThread={async (channelId, id) => { + const scope = splitPartition(connection.scope ?? ""); + if (!navigator || !scope) + throw new Error("Thread navigation is unavailable."); + const result = await navigator.open({ + version: 1, + kind: "conversation", + scope, + channelId, + messageId: id, + threadRootId: id, + }); + if (result.status === "failed") + throw new Error("Could not open the review thread."); + }} + /> + ); + return ( + +

+ {connection.status === "connecting" + ? "Connecting to your community…" + : page !== "configuration" + ? "Connect to a community to submit PRs." + : "Connect to a community to configure reviews."} +

+ {connection.status === "error" && ( + <> +

{connection.error}

+ + + )} + + ); +} + +export function ReviewConfigurationCommunity({ + session, + onOpenThread, + onNavigate, + readPull = publicPullReader, + readStatus = publicStatusReader, + describeTool, + page = "configuration", +}: { + session: RelaySession; + page?: ReviewPage; + readPull?: ReadPull; + readStatus?: ReadPullStatus; + describeTool?: ((name: string) => ToolDescription | undefined) | undefined; + onNavigate?: ((page: ReviewPage) => Promise) | undefined; + onOpenThread?: ((channel: string, id: string) => Promise) | undefined; +}) { + const list = useChannelList(session.channels); + const selectionKey = `buzz.review.channel:${session.scope}`; + const [channel, setChannel] = useState(() => { + try { + return localStorage.getItem(selectionKey) ?? ""; + } catch { + return ""; + } + }); + const [editing, setEditing] = useState(false); + const [navigationError, setNavigationError] = useState(""); + async function navigate(destination: ReviewPage) { + try { + await onNavigate?.(destination); + } catch (reason) { + setNavigationError( + reason instanceof Error + ? reason.message + : "Could not open review page.", + ); + } + } + const channels = list.channels.filter( + (item) => + !item.metadataPending && + !item.huddle && + !item.archived && + !item.pending && + (item.channelType === "stream" || item.channelType === "forum"), + ); + useEffect(() => { + if (list.status !== "ready" || list.coverage === "partial" || editing) + return; + if (list.channels.some((item) => item.metadataPending)) return; + if (channel && channels.some((item) => item.id === channel)) return; + setChannel(channels.length === 1 ? (channels[0]?.id ?? "") : ""); + }, [list.status, list.coverage, list.channels, channels, channel, editing]); + useEffect(() => { + try { + if (channel) localStorage.setItem(selectionKey, channel); + else localStorage.removeItem(selectionKey); + } catch { + /* Storage may be unavailable; selection still works for this visit. */ + } + }, [selectionKey, channel]); + const selected = channels.find((item) => item.id === channel); + const writable = + !!selected && + !selected.metadataCached && + !selected.cached && + !selected.readOnly && + !!session.configArtifactWriter; + const channelSelector = ( + <> + {page !== "configuration" && channels.length === 1 ? ( +

+ Review channel: {selected?.name} +

+ ) : ( + + + + )} + + + setLane({ ...lane, name })} + /> + + setLane({ ...lane, reviewers })} + /> + {choices.pending &&

Loading reviewer agents…

} + {!choices.pending && !choices.selectable.length && ( +

+ No reviewer agents are available. Create or connect an agent, then + refresh. +

+ )} + {choices.error && ( +

Some agents could not be loaded: {choices.error}

+ )} + {choices.archives.error && ( +

+ Archived agents could not be checked: {choices.archives.error} +

+ )} + {agentRefreshError &&

{agentRefreshError}

} +
+ +
+ +