From f9628dd0d782cb82cfa8f71d60458acf9c07b521 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Mon, 26 Oct 2026 18:40:08 +0000 Subject: [PATCH 1/2] stewards: sidebar question and decide rows show only Details (BB-68) --- plugins/stewards/DESIGN.md | 23 +++++++++++--------- plugins/stewards/README.md | 22 ++++++++++--------- plugins/stewards/app.test.tsx | 39 +++++++++++++++++++++++++--------- plugins/stewards/app.tsx | 40 +++++++++++------------------------ 4 files changed, 66 insertions(+), 58 deletions(-) diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index a97e511..3ab09b3 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -1472,19 +1472,22 @@ user already looks, so what waits on them is shown there, not only on the Stewards page. The project dropdown carries each project's count in its option label and the summed count in a pill beside it. Above In flight, a **Needs you** block lists one row per item -with the entity key (opening the entity), one line of text, and a question's -or `decide` item's fixed `choices` as small buttons. A choice makes exactly -the write the page makes (`stewardsAnswerQuestion` with `answer: null` for a -question, `stewardsNeedAction { action: "choose" }` for a `decide` item) and -refetches. An `approve-pr` row carries the page's three actions — **Open the -pull request** (`navigate.openUrl` on the item's `url`), **Approve** +with the entity key (opening the entity), one line of text, and **Details**, +which opens the board at that project. A question's or `decide` item's fixed +`choices` are not rendered here: the board carries the full question, its +context and the choices, so a truncated one-line row never asks the user to +pick blind. An `approve-pr` row carries the page's actions — **Open the pull +request** (`navigate.openUrl` on the item's `url`), **Approve** (`stewardsNeedAction { action: "approve" }`, no confirmation, the server's refusal shown in the row) and **Details** (the board at that project) — and its text wraps instead of truncating, so the repository, number and title are -readable before the click. Every other row's page button is **Details** too: a -row without choices, or a `provide-secret` item, opens the Stewards page at -that project, where the free-text answer and the secret form live. The block -adds no write path the page does not already make. +readable before the click. A `merge` row adds **Merge** +(`stewardsNeedAction { action: "merge" }`, the lane gate re-checked +server-side) beside **Open the pull request** and **Details**. Every other +row's page button is **Details** too: a row without choices, or a +`provide-secret` item, opens the Stewards page at that project, where the +free-text answer and the secret form live. The block adds no write path the +page does not already make. **One source of truth.** `server.ts`'s `needsYou(projectId, { actionableOnly })` builds the board's `questions`, `withUser` and `needs` lists in one place; diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index d9dbd4b..760f3af 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -401,16 +401,18 @@ plugins baked into the `bb-server` image.) **The Needs you block and the picker badge (BB-46).** The project dropdown shows each project's count in its option label and the summed count in a small pill beside it. Above **In flight**, a **Needs you** block lists one row per -item: the entity key opens the entity, one line of what, and a question's or -`decide` item's fixed `choices` as small buttons. A choice makes the same -write the page does (`stewardsAnswerQuestion` for a question, -`stewardsNeedAction { action: "choose" }` for a `decide` item) and refetches. -An `approve-pr` row carries the page's three actions: **Open the pull -request** opens the item's `url`, **Approve** makes the page's one-click -approval (`stewardsNeedAction { action: "approve" }`, no confirmation, the -server's refusal shown in the row), and **Details** opens the Stewards page at -that project; its text wraps so the repository, number and title are readable. -Every other row's page button is **Details** too: a row without choices, or a +item: the entity key opens the entity, one line of what, and **Details**, which +opens the Stewards page at that project. A question's or `decide` item's fixed +`choices` are not rendered here: the page shows the full question, its context +and the choices, so a one-line row never asks the user to pick blind. An +`approve-pr` row carries the page's three actions: **Open the pull request** +opens the item's `url`, **Approve** makes the page's one-click approval +(`stewardsNeedAction { action: "approve" }`, no confirmation, the server's +refusal shown in the row), and **Details** opens the Stewards page at that +project; its text wraps so the repository, number and title are readable. A +`merge` row adds **Merge** (`stewardsNeedAction { action: "merge" }`, the gate +re-checked server-side) beside **Open the pull request** and **Details**. Every +other row's page button is **Details** too: a row without choices, or a `provide-secret` item, opens the Stewards page at that project. The block and the board page read one server function (`needsYou`), so the sidebar and the page can never disagree; a diff --git a/plugins/stewards/app.test.tsx b/plugins/stewards/app.test.tsx index ec6db39..07959a4 100644 --- a/plugins/stewards/app.test.tsx +++ b/plugins/stewards/app.test.tsx @@ -445,25 +445,45 @@ describe("the stewards sidebar's needs you block (BB-46)", () => { slot.lifecycle.unmount(); }); - it("sends a question's choice through stewardsAnswerQuestion with the page's arguments", async () => { + it("renders only Details for a question with choices, never the choices", async () => { const slot = await renderSidebar({ stewardsSidebar: () => sidebarData({ count: 1, items: [questionItem({ choices: ["Merge", "Close"] })] }), stewardsAnswerQuestion: () => ({ ok: true, message: "closed" }), }); - await waitFor(() => expect(slot.getByRole("button", { name: "Merge" })).toBeTruthy()); - fireEvent.click(slot.getByRole("button", { name: "Merge" })); - await waitFor(() => expect(slot.inspection.rpcCalls).toContainEqual({ method: "stewardsAnswerQuestion", input: { questionId: "qst_1", answer: null, choice: "Merge" } })); + await waitFor(() => expect(slot.getByRole("button", { name: "Details" })).toBeTruthy()); + expect(slot.queryByRole("button", { name: "Merge" })).toBeNull(); + expect(slot.queryByRole("button", { name: "Close" })).toBeNull(); + fireEvent.click(slot.getByRole("button", { name: "Details" })); + await waitFor(() => expect(slot.inspection.navigateCalls).toContainEqual({ method: "toPluginPanel", path: "board", options: { subPath: "p1" } })); + expect(slot.inspection.rpcCalls.some((c) => c.method === "stewardsAnswerQuestion")).toBe(false); slot.lifecycle.unmount(); }); - it("sends a decide need's choice through stewardsNeedAction with the page's arguments", async () => { + it("renders only Details for a decide need with choices, never the choices", async () => { const slot = await renderSidebar({ stewardsSidebar: () => sidebarData({ count: 1, items: [needItem({ choices: ["Hold", "Start"] })] }), stewardsNeedAction: () => ({ ok: true, message: "done" }), }); - await waitFor(() => expect(slot.getByRole("button", { name: "Hold" })).toBeTruthy()); - fireEvent.click(slot.getByRole("button", { name: "Hold" })); - await waitFor(() => expect(slot.inspection.rpcCalls).toContainEqual({ method: "stewardsNeedAction", input: { needId: "need_1", action: "choose", note: null, choice: "Hold" } })); + await waitFor(() => expect(slot.getByRole("button", { name: "Details" })).toBeTruthy()); + expect(slot.queryByRole("button", { name: "Hold" })).toBeNull(); + expect(slot.queryByRole("button", { name: "Start" })).toBeNull(); + fireEvent.click(slot.getByRole("button", { name: "Details" })); + await waitFor(() => expect(slot.inspection.navigateCalls).toContainEqual({ method: "toPluginPanel", path: "board", options: { subPath: "p1" } })); + expect(slot.inspection.rpcCalls.some((c) => c.method === "stewardsNeedAction")).toBe(false); + slot.lifecycle.unmount(); + }); + + it("offers Merge, Open the pull request and Details on a merge row, and sends the merge action", async () => { + const url = "https://github.com/bitcomplete/bb-gate/pull/17"; + const slot = await renderSidebar({ + stewardsSidebar: () => sidebarData({ count: 1, items: [needItem({ kind: "merge", url })] }), + stewardsNeedAction: () => ({ ok: true, message: "merged" }), + }); + await waitFor(() => expect(slot.getByRole("button", { name: "Merge" })).toBeTruthy()); + expect(slot.getByRole("button", { name: "Open the pull request" })).toBeTruthy(); + expect(slot.getByRole("button", { name: "Details" })).toBeTruthy(); + fireEvent.click(slot.getByRole("button", { name: "Merge" })); + await waitFor(() => expect(slot.inspection.rpcCalls).toContainEqual({ method: "stewardsNeedAction", input: { needId: "need_1", action: "merge", note: null, choice: null } })); slot.lifecycle.unmount(); }); @@ -538,14 +558,13 @@ describe("the stewards sidebar's needs you block (BB-46)", () => { it("labels the page button Details on every other row, with no Open label", async () => { const items = [ - needItem({ id: "need_merge", kind: "merge", url: "https://github.com/bitcomplete/bb-gate/pull/17" }), needItem({ id: "need_secret", kind: "provide-secret", choices: [] }), needItem({ id: "need_config", kind: "configure", choices: [] }), needItem({ id: "need_decide", kind: "decide", choices: [] }), questionItem({ id: "qst_1", choices: [] }), ]; const slot = await renderSidebar({ stewardsSidebar: () => sidebarData({ count: items.length, items }) }); - await waitFor(() => expect(slot.getAllByRole("button", { name: "Details" })).toHaveLength(5)); + await waitFor(() => expect(slot.getAllByRole("button", { name: "Details" })).toHaveLength(4)); expect(slot.queryByRole("button", { name: "Open" })).toBeNull(); slot.lifecycle.unmount(); }); diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index ffc6ce4..524b3d1 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -1064,22 +1064,23 @@ function ProjectTabs({ tabs, counts, activeId, onPick, onNewThread }: { tabs: Ar /** * One item in the sidebar's "Needs you" block: the key, one line of text, and - * the fixed choices as buttons. A choice writes through the page's own RPC; a - * row without choices (or a provide-secret item) opens the board, where the - * free-text answer or the secret form lives. + * Details, which opens the board at the project. The board carries the full + * question, its context and its choices, so a `question` or `decide` row does + * not render them here. An `approve-pr` or `merge` row keeps its one-click + * button — the server re-checks the gate before acting — beside Open the pull + * request; every other row opens the page. */ function SidebarNeedRow({ item, onChanged, onOpenBoard }: { item: SidebarNeedItem; onChanged: () => void; onOpenBoard: () => void }) { const rpc = useRpc(); const navigate = useBbNavigate(); const [state, setState] = useState<"idle" | "running" | { error: string }>("idle"); - // Choices are a decide item's or a question's; every other item opens the page. - const choices = item.type === "question" ? item.choices : item.type === "need" && item.kind === "decide" ? item.choices : []; const text = item.type === "need" ? item.what : item.question; const entityUrl = item.type === "need" ? item.entityUrl : item.url; const prUrl = item.url; - // An approve-pr row offers the page's one-click approval and the pull - // request beside Details; every other row without choices opens the page. + // approve-pr and merge are the mechanical items the server verifies before + // acting; every other row opens the page. const isApprovePr = item.type === "need" && item.kind === "approve-pr"; + const isMerge = item.type === "need" && item.kind === "merge"; const run = (call: Promise<{ ok: boolean; message: string }>) => { setState("running"); call.then( @@ -1090,13 +1091,8 @@ function SidebarNeedRow({ item, onChanged, onOpenBoard }: { item: SidebarNeedIte (e: unknown) => setState({ error: message(e) }), ); }; - const choose = (choice: string) => - run( - item.type === "question" - ? rpc.call("stewardsAnswerQuestion", { questionId: item.id, answer: null, choice }) - : rpc.call("stewardsNeedAction", { needId: item.id, action: "choose", note: null, choice }), - ); const approve = () => run(rpc.call("stewardsNeedAction", { needId: item.id, action: "approve", note: null, choice: null })); + const merge = () => run(rpc.call("stewardsNeedAction", { needId: item.id, action: "merge", note: null, choice: null })); return (
  • @@ -1106,7 +1102,7 @@ function SidebarNeedRow({ item, onChanged, onOpenBoard }: { item: SidebarNeedIte

    {text}

    - {isApprovePr ? ( + {isApprovePr || isMerge ? ( <> {prUrl !== null ? ( - ) : choices.length > 0 ? ( - choices.map((choice) => ( - - )) ) : (