From a87a8b0dfb999118c5da8e3878cfc55a9bcaad34 Mon Sep 17 00:00:00 2001 From: Ravneet Arora Date: Wed, 11 Nov 2026 12:33:34 -0500 Subject: [PATCH 01/17] Add review configuration plugin Save channel-scoped repository and lane settings through the existing signed relay artifact path. Keep configuration separate from review execution and preserve drafts on failed saves or reconnects. Co-authored-by: maincourse <37ce551b493d4a998caf030929c47bce0b6a0e43f1178b3a63c4151d7c16b76e@buzz.block.builderlab.xyz> Signed-off-by: Ravneet Arora --- docs/buzz-help.md | 9 + src/app/shell/presentation.ts | 2 + src/bundled/index.ts | 7 + src/bundled/navigation-owner.test.ts | 1 + src/bundled/review-configuration/README.md | 62 +++ .../ReviewConfigurationPage.test.tsx | 248 +++++++++ .../ReviewConfigurationPage.tsx | 505 ++++++++++++++++++ .../configuration.test.ts | 226 ++++++++ .../review-configuration/configuration.ts | 196 +++++++ src/bundled/review-configuration/index.tsx | 15 + .../review-configuration/manifest.json | 7 + .../review-configuration.css | 44 ++ 12 files changed, 1322 insertions(+) create mode 100644 src/bundled/review-configuration/README.md create mode 100644 src/bundled/review-configuration/ReviewConfigurationPage.test.tsx create mode 100644 src/bundled/review-configuration/ReviewConfigurationPage.tsx create mode 100644 src/bundled/review-configuration/configuration.test.ts create mode 100644 src/bundled/review-configuration/configuration.ts create mode 100644 src/bundled/review-configuration/index.tsx create mode 100644 src/bundled/review-configuration/manifest.json create mode 100644 src/bundled/review-configuration/review-configuration.css diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b7caebe750..7899199002 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1153,6 +1153,15 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents. 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 instructions. Saved + repositories appear in a table; **Edit** changes their settings and **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. + This first version saves configuration only. It does not assign reviewers, + submit PRs, or send the planned 15-minute 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 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/index.ts b/src/bundled/index.ts index 6077e70036..3c223b3b22 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -60,6 +60,8 @@ import reposManifest from "./repos/manifest.json"; import * as repos from "./repos"; 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"; @@ -251,6 +253,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/README.md b/src/bundled/review-configuration/README.md new file mode 100644 index 0000000000..933723e31c --- /dev/null +++ b/src/bundled/review-configuration/README.md @@ -0,0 +1,62 @@ +# 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 and instructions, +and save. The repository table supports editing and an explicit reload. + +This is configuration only. It does not submit PRs, choose reviewers, create +review threads, or send reminders. No agent or timer runs in the background. + +## 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." + } + ], + "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 not checked in this first slice. + +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 and lane 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. diff --git a/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx b/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx new file mode 100644 index 0000000000..2e7b961126 --- /dev/null +++ b/src/bundled/review-configuration/ReviewConfigurationPage.test.tsx @@ -0,0 +1,248 @@ +// @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); +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 snapshotRead = vi.fn(async () => events); + const publish = vi.fn(async (event: EventData) => { + events = [event]; + return event.id; + }); + const session = { + 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, + 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: "Lane 1 instructions" }), + "Check authorization and keyboard navigation.", + ); + 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 settings" })); + await screen.findByText( + "Saved example-org/project. Reviews are not enabled yet.", + ); + await user.click(screen.getByRole("button", { name: "Reload" })); + await screen.findByRole("button", { name: "Edit example-org/project" }); + await user.click( + screen.getByRole("button", { name: "Edit example-org/project" }), + ); + expect( + ( + screen.getByRole("textbox", { + name: "Lane 1 instructions", + }) as HTMLTextAreaElement + ).value, + ).toBe("Check authorization and keyboard navigation."); + await user.clear( + screen.getByRole("textbox", { name: "Lane 1 instructions" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Lane 1 instructions" }), + "Check database migrations.", + ); + await user.click(screen.getByRole("button", { name: "Save settings" })); + await screen.findByText( + "Saved example-org/project. Reviews are not enabled yet.", + ); + 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 settings" })); + 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: "Lane 1 instructions", + }) as HTMLTextAreaElement + ).value, + ).toContain("keyboard navigation"); + await user.click(screen.getByRole("button", { name: "Save settings" })); + await screen.findByText( + "Saved example-org/project. Reviews are not enabled yet.", + ); +}); + +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 settings" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + ( + screen.getByRole("textbox", { + name: "Lane 1 instructions", + }) as HTMLTextAreaElement + ).value, + ).toContain("keyboard navigation"); + act(() => test.metadataCached(false)); + expect( + (screen.getByRole("button", { name: "Save settings" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + expect( + ( + screen.getByRole("textbox", { + name: "Lane 1 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 settings" })); + 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.mockResolvedValueOnce( + 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: "Lane 1 instructions" }), + "Check tests.", + ); + await user.click(screen.getByRole("button", { name: "Save settings" })); + await screen.findByText( + "Saved example-org/project. Reviews are not enabled yet.", + ); + expect( + ( + screen.getByRole("button", { + name: "Register repository", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); +}); diff --git a/src/bundled/review-configuration/ReviewConfigurationPage.tsx b/src/bundled/review-configuration/ReviewConfigurationPage.tsx new file mode 100644 index 0000000000..cd1fbc4718 --- /dev/null +++ b/src/bundled/review-configuration/ReviewConfigurationPage.tsx @@ -0,0 +1,505 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { ListChecksIcon } from "../../shared/design-system/icons"; +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 SavedReviewConfig, +} from "./configuration"; +import "./review-configuration.css"; + +function Frame({ children }: { children: ReactNode }) { + return ( + +
+ } + /> + } + /> +
+
{children}
+
+
+
+ ); +} + +export function ReviewConfigurationPage({ relay }: { relay: RelayData }) { + const connection = useRelayConnection(relay); + if (connection.status === "ready") + return ( + + ); + return ( + +

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

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

{connection.error}

+ + + )} + + ); +} + +export function ReviewConfigurationCommunity({ + session, +}: { + session: RelaySession; +}) { + const list = useChannelList(session.channels); + const [channel, setChannel] = useState(""); + const [editing, setEditing] = useState(false); + const channels = list.channels.filter( + (item) => + !item.metadataPending && + !item.huddle && + !item.archived && + !item.pending && + (item.channelType === "stream" || item.channelType === "forum"), + ); + const selected = channels.find((item) => item.id === channel); + const writable = + !!selected && + !selected.metadataCached && + !selected.cached && + !selected.readOnly && + !!session.configArtifactWriter; + return ( + +

+ Register repositories and save their review lane instructions. These are + draft settings; saving does not start reviews or send reminders. +

+ setDraft({ ...draft, repository })} + /> + + + {draft.lanes.map((lane, index) => ( +
+
+ + +
+ + + + setDraft({ + ...draft, + lanes: draft.lanes.map((item) => + item.id === lane.id ? { ...item, name } : item, + ), + }) + } + /> + + +