diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 56193fcc0f..180d4d62f3 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -330,6 +330,9 @@ available rather than silently discarding it. macOS (inside the app's installed name, such as `Buzz Foundation.app`), with no setup or token. Without such a plugin there are no tools to offer; do not promise them. +- The **GitHub** plugin’s pull request panel offers **Queue for merge** when + the target branch requires a merge queue. After confirmation, it queues the + displayed commit and shows **Queued for merge**; GitHub controls when it merges. - Some desktop plugins can upload images you provide to services they declare. - **Attention**, when available, lets agents use Interests, watches, and timers to follow work. Its tab in an agent's profile shows what that agent is watching diff --git a/src/bundled/github/GitHubPanel.test.tsx b/src/bundled/github/GitHubPanel.test.tsx index 0986526a29..5a9d1245f1 100644 --- a/src/bundled/github/GitHubPanel.test.tsx +++ b/src/bundled/github/GitHubPanel.test.tsx @@ -594,19 +594,24 @@ it("merges after confirming, at the head it showed, then shows it merged", async ).not.toBeInTheDocument(); }); -it("closes the merge confirmation when a refresh brings a newer push", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - try { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - let head = "abc123"; - const gh = vi.fn(async (query: string, _variables?: unknown) => - query.startsWith("mutation") - ? {} - : pullData({ - headRefOid: head, - ...(head !== "abc123" && { title: "Pushed again" }), - }), - ); +it.each(["CLEAN", "BLOCKED", "BEHIND"])( + "queues the displayed head on a queue-required branch (%s), then shows it queued", + async (mergeStateStatus) => { + const user = userEvent.setup(); + let queued = false; + const gh = vi.fn(async (query: string, _variables?: unknown) => { + if (query.startsWith("mutation")) { + queued = true; + return {}; + } + return pullData({ + isMergeQueueEnabled: true, + isInMergeQueue: queued, + mergeStateStatus, + viewerCanMergeAsAdmin: true, + viewerCanEnableAutoMerge: true, + }); + }); render( , ); - await screen.findByRole("heading", { name: "Private change #9" }); - await user.click(screen.getByRole("button", { name: "Squash and merge" })); + const button = await screen.findByRole("button", { + name: "Queue for merge", + }); + expect(button).toHaveAttribute("data-variant", "prominent"); + expect( + screen.queryByRole("button", { name: "Squash and merge" }), + ).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "More pull request actions" }), + ); + expect( + (await screen.findAllByRole("menuitem")).map((item) => item.textContent), + ).toEqual(["Convert to draft"]); + await user.keyboard("{Escape}"); + await user.click(button); const dialog = await screen.findByRole("alertdialog"); - head = "def456"; - await vi.advanceTimersByTimeAsync(REFRESH_MS); - await screen.findByRole("heading", { name: "Pushed again #9" }); - expect(dialog).not.toBeInTheDocument(); - // Confirming again names the head now shown. - await user.click(screen.getByRole("button", { name: "Squash and merge" })); + expect(dialog).toHaveAccessibleName("Queue for merge #9?"); + expect(gh).toHaveBeenCalledTimes(1); await user.click( - within(await screen.findByRole("alertdialog")).getByRole("button", { - name: "Merge", + within(dialog).getByRole("button", { name: "Queue for merge" }), + ); + await screen.findByText("Queued for merge"); + expect(row("Merge")).toHaveTextContent("Queued for merge into main"); + expect(gh.mock.calls[0]?.[0]).toContain( + "isMergeQueueEnabled isInMergeQueue", + ); + expect(gh.mock.calls[1]).toEqual([ + "mutation($input:EnqueuePullRequestInput!){enqueuePullRequest(input:$input){clientMutationId}}", + { input: { pullRequestId: "PR_9", expectedHeadOid: "abc123" } }, + undefined, + ]); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Queue for merge" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Merge when ready" }), + ).not.toBeInTheDocument(); + }, +); + +it.each([ + { mergeStateStatus: "DIRTY" }, + { mergeStateStatus: "UNKNOWN" }, + { isDraft: true }, + { isInMergeQueue: true }, +])( + "does not offer queueing an ineligible or already queued pull (%j)", + async (over) => { + const gh = vi.fn(async () => + pullData({ + isMergeQueueEnabled: true, + viewerCanEnableAutoMerge: true, + ...over, }), ); - await vi.waitFor(() => expect(gh).toHaveBeenCalledTimes(4)); - expect(gh.mock.calls[2]?.[1]).toEqual({ - input: { - pullRequestId: "PR_9", - expectedHeadOid: "def456", - mergeMethod: "SQUASH", - }, - }); - } finally { - vi.useRealTimers(); - } + render( + {}} + gh={gh as unknown as GitHubCli} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + expect( + screen.queryByRole("button", { name: "Queue for merge" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Merge when ready" }), + ).not.toBeInTheDocument(); + }, +); + +it("shows GitHub's queue rejection and allows retrying the confirmed head", async () => { + const user = userEvent.setup(); + const gh = vi.fn(async (query: string) => { + if (query.startsWith("mutation")) + throw new GitHubError("Required approvals are missing"); + return pullData({ isMergeQueueEnabled: true, mergeStateStatus: "BLOCKED" }); + }); + render( + {}} + gh={gh as unknown as GitHubCli} + />, + ); + await user.click( + await screen.findByRole("button", { name: "Queue for merge" }), + ); + const dialog = await screen.findByRole("alertdialog"); + await user.click( + within(dialog).getByRole("button", { name: "Queue for merge" }), + ); + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "Required approvals are missing", + ); + expect( + within(dialog).getByRole("button", { name: "Queue for merge" }), + ).toBeEnabled(); }); -it("closes the merge confirmation when the pull request is retargeted, for good", async () => { +it("closes a merge confirmation if the branch starts requiring a queue", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - let base = "main"; + let queue = false; const gh = vi.fn(async () => pullData({ - baseRefName: base, - ...(base !== "main" && { title: "Retargeted" }), + isMergeQueueEnabled: queue, + ...(queue && { title: "Queue required" }), }), ); render( @@ -659,23 +739,124 @@ it("closes the merge confirmation when the pull request is retargeted, for good" gh={gh as unknown as GitHubCli} />, ); - await screen.findByRole("heading", { name: "Private change #9" }); - await user.click(screen.getByRole("button", { name: "Squash and merge" })); + await user.click( + await screen.findByRole("button", { name: "Squash and merge" }), + ); const dialog = await screen.findByRole("alertdialog"); - base = "release"; + queue = true; await vi.advanceTimersByTimeAsync(REFRESH_MS); - await screen.findByRole("heading", { name: "Retargeted #9" }); + await screen.findByRole("heading", { name: "Queue required #9" }); expect(dialog).not.toBeInTheDocument(); - // Changing back does not open it again without another click. - base = "main"; - await vi.advanceTimersByTimeAsync(REFRESH_MS); - await screen.findByRole("heading", { name: "Private change #9" }); - expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Queue for merge" }), + ).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); +it.each([false, true])( + "closes the merge confirmation when a refresh brings a newer push (queue=%s)", + async (queue) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let head = "abc123"; + const gh = vi.fn(async (query: string, _variables?: unknown) => + query.startsWith("mutation") + ? {} + : pullData({ + isMergeQueueEnabled: queue, + headRefOid: head, + ...(head !== "abc123" && { title: "Pushed again" }), + }), + ); + render( + {}} + gh={gh as unknown as GitHubCli} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + await user.click( + screen.getByRole("button", { + name: queue ? "Queue for merge" : "Squash and merge", + }), + ); + const dialog = await screen.findByRole("alertdialog"); + head = "def456"; + await vi.advanceTimersByTimeAsync(REFRESH_MS); + await screen.findByRole("heading", { name: "Pushed again #9" }); + expect(dialog).not.toBeInTheDocument(); + // Confirming again names the head now shown. + await user.click( + screen.getByRole("button", { + name: queue ? "Queue for merge" : "Squash and merge", + }), + ); + await user.click( + within(await screen.findByRole("alertdialog")).getByRole("button", { + name: queue ? "Queue for merge" : "Merge", + }), + ); + await vi.waitFor(() => expect(gh).toHaveBeenCalledTimes(4)); + expect(gh.mock.calls[2]?.[1]).toEqual({ + input: { + pullRequestId: "PR_9", + expectedHeadOid: "def456", + ...(!queue && { mergeMethod: "SQUASH" }), + }, + }); + } finally { + vi.useRealTimers(); + } + }, +); + +it.each([false, true])( + "closes the merge confirmation when the pull request is retargeted, for good (queue=%s)", + async (queue) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let base = "main"; + const gh = vi.fn(async () => + pullData({ + isMergeQueueEnabled: queue, + baseRefName: base, + ...(base !== "main" && { title: "Retargeted" }), + }), + ); + render( + {}} + gh={gh as unknown as GitHubCli} + />, + ); + await screen.findByRole("heading", { name: "Private change #9" }); + await user.click( + screen.getByRole("button", { + name: queue ? "Queue for merge" : "Squash and merge", + }), + ); + const dialog = await screen.findByRole("alertdialog"); + base = "release"; + await vi.advanceTimersByTimeAsync(REFRESH_MS); + await screen.findByRole("heading", { name: "Retargeted #9" }); + expect(dialog).not.toBeInTheDocument(); + // Changing back does not open it again without another click. + base = "main"; + await vi.advanceTimersByTimeAsync(REFRESH_MS); + await screen.findByRole("heading", { name: "Private change #9" }); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }, +); + it("says when threads beyond the latest 100 went unread, and gates actions on GitHub's viewer flags", async () => { const gh = vi.fn(async () => pullData({ diff --git a/src/bundled/github/PullChecklist.tsx b/src/bundled/github/PullChecklist.tsx index 14df5ed063..6687cb1a44 100644 --- a/src/bundled/github/PullChecklist.tsx +++ b/src/bundled/github/PullChecklist.tsx @@ -212,6 +212,7 @@ function ReviewRow({ review }: { review: Checklist["review"] }) { function MergeRow({ pull, action }: { pull: Checklist; action?: ReactNode }) { const base = {pull.base}; const [tone, text]: [Tone, ReactNode] = (() => { + if (pull.queued) return ["pending", <>Queued for merge into {base}]; switch (pull.merge) { case "CLEAN": case "HAS_HOOKS": @@ -223,7 +224,7 @@ function MergeRow({ pull, action }: { pull: Checklist; action?: ReactNode }) { "pending", <> Blocked by {base}’s rules - {pull.canBypass && " · you can bypass them"} + {pull.canBypass && !pull.queueRequired && " · you can bypass them"} , ]; case "BEHIND": @@ -245,7 +246,17 @@ function MergeRow({ pull, action }: { pull: Checklist; action?: ReactNode }) { /** Whether the merge button can merge now, and whether that bypasses rules. */ function mergeability(pull: Checklist) { - if (pull.state !== "open") return { now: false, bypass: false }; + if (pull.state !== "open" || pull.queued) + return { now: false, bypass: false }; + // A queue-required branch can report BLOCKED until enqueued; GitHub + // validates its prerequisites when accepting the enqueue request. + if (pull.queueRequired) + return { + now: ["CLEAN", "HAS_HOOKS", "UNSTABLE", "BLOCKED", "BEHIND"].includes( + pull.merge, + ), + bypass: false, + }; if (["CLEAN", "HAS_HOOKS", "UNSTABLE"].includes(pull.merge)) return { now: true, bypass: false }; if (["BLOCKED", "BEHIND"].includes(pull.merge) && pull.canBypass) @@ -278,6 +289,7 @@ function Actions({ head: string; base: string; bypass: boolean; + queue: boolean; }> >(); const alive = useRef(true); @@ -309,23 +321,30 @@ function Actions({ head: pull.head, base: pull.base, bypass: merge.bypass, + queue: pull.queueRequired, }); }; const confirming = merge.now && asked?.head === pull.head && asked.base === pull.base && - asked.bypass === merge.bypass + asked.bypass === merge.bypass && + asked.queue === pull.queueRequired ? asked : undefined; // Forgotten during render, so changing back does not reopen it. if (asked && !confirming) setConfirming(undefined); const canAutoMerge = + !pull.queueRequired && + !pull.queued && pull.canEnableAutoMerge && !pull.autoMerge && pull.state === "open" && !["CLEAN", "HAS_HOOKS", "DIRTY"].includes(pull.merge); - const others = pull.methods.filter((method) => method !== pull.method); + const others = + pull.queueRequired || pull.queued + ? [] + : pull.methods.filter((method) => method !== pull.method); const menu = [ ...(merge.now ? others.map((method) => ( @@ -369,7 +388,9 @@ function Actions({ Ready for review )} - {pull.autoMerge ? ( + {pull.queued ? ( + Queued for merge + ) : pull.autoMerge ? ( <> Merges automatically when ready @@ -393,7 +414,7 @@ function Actions({ disabled={!!busy} onClick={() => confirm(pull.method)} > - {mergeLabels[pull.method]} + {pull.queueRequired ? "Queue for merge" : mergeLabels[pull.method]} ) : ( canAutoMerge && ( @@ -443,13 +464,15 @@ function Actions({ )} {confirming && ( { setConfirming(undefined); setError(undefined); @@ -457,7 +480,7 @@ function Actions({ actions={ <> } diff --git a/src/bundled/github/pull.ts b/src/bundled/github/pull.ts index 7d24bde428..66c4740df7 100644 --- a/src/bundled/github/pull.ts +++ b/src/bundled/github/pull.ts @@ -42,6 +42,9 @@ export type PullChecklist = Readonly<{ authored: boolean; /** The viewer may merge past branch rules. */ canBypass: boolean; + /** The target branch requires GitHub’s merge queue. */ + queueRequired: boolean; + queued: boolean; methods: readonly MergeMethod[]; method: MergeMethod; canEnableAutoMerge: boolean; @@ -67,6 +70,7 @@ export const PANEL_PULL_FIELDS = `id title body bodyHTML createdAt updatedAt mer headRepository{nameWithOwner} headRepositoryOwner{login} baseRepository{nameWithOwner} additions deletions changedFiles reviewDecision viewerDidAuthor viewerCanUpdate viewerCanUpdateBranch viewerCanMergeAsAdmin + isMergeQueueEnabled isInMergeQueue viewerCanEnableAutoMerge viewerCanDisableAutoMerge autoMergeRequest{mergeMethod enabledBy{login}} reviewRequests(first:20){nodes{requestedReviewer{__typename ... on User{login} ... on Team{name} ... on Bot{login} ... on Mannequin{login}}}} @@ -106,6 +110,8 @@ export type PullData = { changedFiles?: number; mergeStateStatus?: string; reviewDecision?: PullChecklist["review"]["decision"]; + isMergeQueueEnabled?: boolean; + isInMergeQueue?: boolean; viewerDidAuthor?: boolean; viewerCanUpdate?: boolean; viewerCanUpdateBranch?: boolean; @@ -281,6 +287,8 @@ export function parsePull( canUpdateBranch: !!pull.viewerCanUpdateBranch, authored: !!pull.viewerDidAuthor, canBypass: !!pull.viewerCanMergeAsAdmin, + queueRequired: !!pull.isMergeQueueEnabled, + queued: !!pull.isInMergeQueue, methods, method: preferred && methods.includes(preferred) @@ -302,6 +310,7 @@ export type ReviewEvent = "COMMENT" | "APPROVE" | "REQUEST_CHANGES"; export type PullAction = | { type: "merge"; method: MergeMethod } + | { type: "enqueue" } | { type: "review"; event: ReviewEvent; body: string } | { type: "auto-merge"; method: MergeMethod } | { type: "cancel-auto-merge" } @@ -316,6 +325,8 @@ function mutationOf( const id = { pullRequestId: pull.id }; const head = { ...id, expectedHeadOid: pull.head }; switch (action.type) { + case "enqueue": + return ["enqueuePullRequest", head]; case "merge": return ["mergePullRequest", { ...head, mergeMethod: action.method }]; case "auto-merge": diff --git a/tests/fixtures/github-actions.tsx b/tests/fixtures/github-actions.tsx index 1c9e988eed..079cc0dad5 100644 --- a/tests/fixtures/github-actions.tsx +++ b/tests/fixtures/github-actions.tsx @@ -7,14 +7,20 @@ import { PanelWorkspace } from "../../src/features/panels/PanelWorkspace"; import { Panel } from "../../src/shared/design-system/ui/Panel"; import { useKeyboardFocusVisibility } from "../../src/shared/design-system/useKeyboardFocusVisibility"; -// Real panel and menu; GitHub reads and draft conversion stay in memory. +// Real panel and menu; GitHub reads and changes stay in memory. const ready = new URLSearchParams(location.search).has("ready"); +const queue = new URLSearchParams(location.search).has("queue"); +let queued = false; let draft = false; const gh: GitHubCli = async (query) => { if (query.startsWith("mutation($input:ConvertPullRequestToDraftInput!")) { draft = true; return JSON.parse("{}"); } + if (query.startsWith("mutation($input:EnqueuePullRequestInput!")) { + queued = true; + return JSON.parse("{}"); + } if (!query.startsWith("query(")) throw new Error("Unsupported fixture action"); // The plugin's store reads each pull request under an alias. @@ -29,6 +35,8 @@ const gh: GitHubCli = async (query) => { title: "Keep the release notes easy to scan", state: "OPEN", isDraft: draft, + isMergeQueueEnabled: queue, + isInMergeQueue: queued, body: "This sample change groups release notes by feature.\n\nReview the checklist above, then open the action menu to see the available options. All names and pull request data on this page are fictional.", author: { login: "alex-example" }, baseRefName: "main",