diff --git a/package.json b/package.json index 3e57a660..ffe5c625 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "pro": true, "stripePayments": false, "emailSignup": false, - "mergeQueue": false + "mergeQueue": true }, "env": { "dev": { diff --git a/scripts/screenshot/specs/merge-queue-flag-off.spec.tsx b/scripts/screenshot/specs/merge-queue-flag-off.spec.tsx new file mode 100644 index 00000000..c2d32deb --- /dev/null +++ b/scripts/screenshot/specs/merge-queue-flag-off.spec.tsx @@ -0,0 +1,139 @@ +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { getWorkspaces } from "../../../src/lib/api"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { + commitRepoFile, + commitWorkspaceFile, + createTestRepo, + openRepo, +} from "../../../test/utils"; +import { captureDocument } from "../capture"; + +const BRANCH_NAME = "feat/flag-off-demo"; + +// Regression guard: with the mergeQueue build flag off, the merge queue must +// leave no trace -- no button, no sidebar dots, no tab, and crucially no +// Supabase polling. The flag used to gate only the button, so every user with a +// GitHub remote was polling the queue RPCs every 30s for a feature that was +// switched off. +const { rpcCalls, mockGetGitRemoteUrl } = vi.hoisted(() => ({ + rpcCalls: [] as string[], + mockGetGitRemoteUrl: vi.fn(), +})); + +vi.mock("../../../src/lib/features", () => ({ + FEATURES: { + pro: true, + stripePayments: false, + emailSignup: false, + mergeQueue: false, + }, +})); + +vi.mock("../../../src/lib/supabase", () => ({ + supabase: { + rpc: vi.fn(async (fn: string) => { + rpcCalls.push(fn); + if (fn === "get_merge_queue_enabled") { + return { data: true, error: null }; + } + return { data: [], error: null }; + }), + functions: { invoke: vi.fn() }, + }, + SUPABASE_URL: "http://localhost:54321", + SUPABASE_ANON_KEY: "anon", + WEB_URL: "http://localhost:3000", +})); + +vi.mock("../../../src/lib/api", async () => { + const actual = await vi.importActual( + "../../../src/lib/api", + ); + return { ...actual, getGitRemoteUrl: mockGetGitRemoteUrl }; +}); + +it("captures a pushed workspace with the merge queue flag switched off", async () => { + const { repoPath } = createTestRepo(true); + openRepo(repoPath); + await commitRepoFile(repoPath, "base.txt", "base", "Base commit"); + + // A GitHub remote *and* a repo whose queue is enabled server-side: the only + // thing keeping the merge queue out of the UI is the build flag. + mockGetGitRemoteUrl.mockResolvedValue({ + owner: "treq-dev", + repo: "treq", + full_name: "treq-dev/treq", + }); + rpcCalls.length = 0; + + const user = userEvent.setup(); + render(); + + await screen.findByTestId("show-workspace-header"); + await user.click(await screen.findByRole("button", { name: "Stack" })); + const dialog = await screen.findByTestId("modal"); + await user.type(within(dialog).getByLabelText("Branch Name"), BRANCH_NAME); + await user.click( + within(dialog).getByRole("button", { name: "Create Workspace" }), + ); + await waitFor(() => { + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + const workspace = (await getWorkspaces(repoPath)).find( + (candidate) => candidate.branch_name === BRANCH_NAME, + ); + if (!workspace) throw new Error(`Expected ${BRANCH_NAME} workspace to exist`); + await commitWorkspaceFile( + repoPath, + { id: workspace.id, path: workspace.workspace_path }, + "queue-file.txt", + "queue content", + "Workspace commit", + ); + + await user.click( + await screen.findByRole("button", { name: /push to remote/i }), + ); + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /push to remote/i }), + ).not.toBeInTheDocument(); + }); + + // Same repo state that renders "Add to Queue" with the flag on. + expect( + screen.queryByRole("button", { name: /add to queue/i }), + ).not.toBeInTheDocument(); + + // Give any stray polling a chance to fire before asserting it did not. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(rpcCalls).not.toContain("get_merge_queue_enabled"); + expect(rpcCalls).not.toContain("get_workspace_queue_status"); + expect(rpcCalls).not.toContain("get_repo_branch_queue_statuses"); + + await captureDocument(document, { + name: "merge-queue-flag-off-01-workspace", + expectations: [ + 'The workspace header has no "Add to Queue" button -- only the PR button ("Create PR"), "Merge..." and the overflow menu.', + "The workspace row in the left sidebar has no coloured queue status dot.", + "The workspace is pushed to its remote, so this is the exact state that shows the queue button when the flag is on.", + ], + }); + + await user.click(await screen.findByRole("button", { name: /github/i })); + await screen.findByRole("tab", { name: /issues/i }); + expect( + screen.queryByRole("tab", { name: /merge queue/i }), + ).not.toBeInTheDocument(); + await captureDocument(document, { + name: "merge-queue-flag-off-02-github-panel", + expectations: [ + 'The GitHub panel shows only two tabs, "Issues" and "Pull Requests".', + 'There is no "Merge Queue" tab and no PRO upsell for it.', + ], + }); +}, 120000); diff --git a/scripts/screenshot/specs/merge-queue-setting.spec.tsx b/scripts/screenshot/specs/merge-queue-setting.spec.tsx new file mode 100644 index 00000000..f7ba7908 --- /dev/null +++ b/scripts/screenshot/specs/merge-queue-setting.spec.tsx @@ -0,0 +1,201 @@ +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { SettingsPage } from "../../../src/components/SettingsPage"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { createTestRepo, openRepo } from "../../../test/utils"; +import { captureDocument } from "../capture"; + +// The merge queue opt-in lives in Settings › Integrations and is stored in +// Postgres. Supabase and the repo's GitHub remote are stubbed (neither is +// reachable from the desktop harness); the settings page itself is real. +const { queueState, repoState, mockGetGitRemoteUrl, mockSetEnabled, auth } = + vi.hoisted(() => ({ + queueState: { enabled: false }, + repoState: { + repositories: [ + { + id: 1, + full_name: "treq-dev/treq", + private: false, + default_branch: "main", + installation_id: 99, + }, + ] as unknown[], + }, + mockGetGitRemoteUrl: vi.fn(), + mockSetEnabled: vi.fn(), + // Stable identities are no longer required (the effect keys on user.id), + // but a realistic useAuth returns a memoized object anyway. + auth: { + user: { id: "user-1" }, + session: { access_token: "token" }, + loading: false, + subscription: { plan: "pro", status: "active" }, + signIn: vi.fn(), + }, + })); + +vi.mock("../../../src/lib/features", () => ({ + FEATURES: { + pro: true, + stripePayments: false, + emailSignup: false, + mergeQueue: true, + }, +})); + +vi.mock("../../../src/hooks/useAuth", () => ({ useAuth: () => auth })); + +vi.mock("../../../src/lib/supabase", () => ({ + supabase: { + rpc: vi.fn(async (fn: string, args?: Record) => { + if (fn === "get_merge_queue_enabled") { + return { data: queueState.enabled, error: null }; + } + if (fn === "set_merge_queue_enabled") { + mockSetEnabled(args); + queueState.enabled = args?.p_enabled === true; + return { data: queueState.enabled, error: null }; + } + return { data: [], error: null }; + }), + from: () => ({ + select: () => + Promise.resolve({ data: repoState.repositories, error: null }), + }), + functions: { invoke: vi.fn() }, + }, + SUPABASE_URL: "http://localhost:54321", + SUPABASE_ANON_KEY: "anon", + WEB_URL: "http://localhost:3000", +})); + +vi.mock("../../../src/lib/api", async () => { + const actual = await vi.importActual( + "../../../src/lib/api", + ); + return { ...actual, getGitRemoteUrl: mockGetGitRemoteUrl }; +}); + +const LINKED_REPO = { + id: 1, + full_name: "treq-dev/treq", + private: false, + default_branch: "main", + installation_id: 99, +}; + +const REMOTE_INFO = { + owner: "treq-dev", + repo: "treq", + full_name: "treq-dev/treq", +}; + +it("captures turning the merge queue on from Settings › Integrations", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = false; + repoState.repositories = [LINKED_REPO]; + auth.subscription = { plan: "pro", status: "active" }; + mockSetEnabled.mockClear(); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /integrations/i })); + + // Eligible repo, queue off: a CTA to switch it on, not a prompt. + const section = await screen.findByTestId("merge-queue-setting"); + const cta = await within(section).findByRole("button", { + name: /enable merge queue/i, + }); + expect( + within(section).queryByRole("switch", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); + await captureDocument(document, { + name: "merge-queue-setting-01-off", + expectations: [ + 'There is a single "GitHub" header with an icon and a rule under it -- no bordered cards anywhere on the page.', + 'Under that header, a "Merge queue" row reads "Merge branches automatically once CI passes." and a "Connected repositories" row sits below it, separated by a thin divider.', + 'The merge queue row\'s control is a primary "Enable merge queue" CTA button -- there is no toggle switch while it is off.', + ], + }); + + await user.click(cta); + + const toggle = await within(section).findByRole("switch", { + name: /enable merge queue/i, + }); + await waitFor(() => { + expect(toggle).toHaveAttribute("aria-checked", "true"); + }); + expect(mockSetEnabled).toHaveBeenCalledWith( + expect.objectContaining({ + p_repo_full_name: REMOTE_INFO.full_name, + p_enabled: true, + }), + ); + await captureDocument(document, { + name: "merge-queue-setting-02-on", + expectations: [ + 'The merge queue row now reads "Queued branches merge automatically once CI passes."', + "Its control has become a toggle switch in the ON position (filled/primary, knob to the right) -- the CTA button is gone.", + "No error text is shown.", + ], + }); +}, 120000); + +it("captures the merge queue setting for a repo the GitHub App is not on", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = false; + // Signed in and on Pro, but this repo is not one of the installation's. + repoState.repositories = []; + auth.subscription = { plan: "pro", status: "active" }; + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /integrations/i })); + const section = await screen.findByTestId("merge-queue-setting"); + + await within(section).findByText(/install the treq github app/i); + expect( + within(section).queryByRole("button", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); + expect( + within(section).queryByRole("switch", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); + await captureDocument(document, { + name: "merge-queue-setting-03-not-eligible", + expectations: [ + "The merge queue row explains that the Treq GitHub App must be installed on treq-dev/treq to use the merge queue.", + "There is no Enable CTA and no toggle -- an ineligible repo gets the reason instead of a control that could only fail.", + ], + }); +}, 120000); + +it("offers an upgrade path instead of the CTA on a free plan", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = false; + repoState.repositories = [LINKED_REPO]; + auth.subscription = { plan: "free", status: "active" }; + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /integrations/i })); + const section = await screen.findByTestId("merge-queue-setting"); + + await within(section).findByText(/upgrade to pro to use the merge queue/i); + expect( + within(section).getByRole("button", { name: /upgrade to pro/i }), + ).toBeVisible(); + expect( + within(section).queryByRole("button", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); +}, 120000); diff --git a/scripts/screenshot/specs/merge-queue-tab.spec.tsx b/scripts/screenshot/specs/merge-queue-tab.spec.tsx new file mode 100644 index 00000000..5b952255 --- /dev/null +++ b/scripts/screenshot/specs/merge-queue-tab.spec.tsx @@ -0,0 +1,317 @@ +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { GitHubPanel } from "../../../src/components/GitHubPanel"; +import type { QueueEntryStatus } from "../../../src/lib/api-types"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { createTestRepo } from "../../../test/utils"; +import { captureDocument } from "../capture"; + +// The GitHub panel's Merge Queue tab reads entirely from Supabase (queue +// contents + the per-repo opt-in) and from `gh` for the repo's remote, none of +// which the desktop harness can reach. Those two boundaries are stubbed; the +// panel, its tabs and its rendering are the real components. +const { queueState, mockGetGitRemoteUrl, mockSetEnabled, mockInvoke } = + vi.hoisted(() => ({ + queueState: { + enabled: false, + entries: [] as { + branch_name: string; + pr_number: number | null; + status: QueueEntryStatus; + position: number; + target_branch: string; + }[], + }, + mockGetGitRemoteUrl: vi.fn(), + mockSetEnabled: vi.fn(), + mockInvoke: vi.fn(), + })); + +vi.mock("../../../src/lib/features", () => ({ + FEATURES: { + pro: true, + stripePayments: false, + emailSignup: false, + mergeQueue: true, + }, +})); + +vi.mock("../../../src/hooks/useAuth", () => ({ + useAuth: () => ({ + user: { id: "user-1" }, + session: { access_token: "token" }, + loading: false, + subscription: { plan: "pro", status: "active" }, + signIn: vi.fn(), + }), +})); + +vi.mock("../../../src/lib/supabase", () => ({ + supabase: { + rpc: vi.fn(async (fn: string, args?: Record) => { + if (fn === "get_merge_queue_enabled") { + return { data: queueState.enabled, error: null }; + } + if (fn === "set_merge_queue_enabled") { + mockSetEnabled(args); + queueState.enabled = args?.p_enabled === true; + return { data: queueState.enabled, error: null }; + } + if (fn === "get_repo_branch_queue_statuses") { + return { data: queueState.entries, error: null }; + } + return { data: [], error: null }; + }), + functions: { invoke: mockInvoke }, + }, + SUPABASE_URL: "http://localhost:54321", + SUPABASE_ANON_KEY: "anon", + WEB_URL: "http://localhost:3000", +})); + +vi.mock("../../../src/lib/api", async () => { + const actual = await vi.importActual( + "../../../src/lib/api", + ); + return { + ...actual, + getGitRemoteUrl: mockGetGitRemoteUrl, + ghListIssues: vi.fn().mockResolvedValue({ items: [], hasMore: false }), + ghListPrs: vi.fn().mockResolvedValue({ items: [], hasMore: false }), + }; +}); + +const REMOTE_INFO = { + owner: "treq-dev", + repo: "treq", + full_name: "treq-dev/treq", +}; + +const QUEUE = [ + // A three-deep stack: base → mid → top, landing on main. + { + branch_name: "feat/base", + pr_number: 101, + status: "merging" as QueueEntryStatus, + position: 1, + target_branch: "main", + }, + { + branch_name: "feat/mid", + pr_number: 102, + status: "testing" as QueueEntryStatus, + position: 2, + target_branch: "feat/base", + }, + { + branch_name: "feat/top", + pr_number: 103, + status: "queued" as QueueEntryStatus, + position: 3, + target_branch: "feat/mid", + }, + // An independent branch queued behind the stack. + { + branch_name: "fix/solo", + pr_number: 104, + status: "queued" as QueueEntryStatus, + position: 4, + target_branch: "main", + }, + // A second, two-deep stack with no PR on its upper branch. + { + branch_name: "chore/base", + pr_number: 105, + status: "queued" as QueueEntryStatus, + position: 5, + target_branch: "main", + }, + { + branch_name: "chore/top", + pr_number: null, + status: "queued" as QueueEntryStatus, + position: 6, + target_branch: "chore/base", + }, +]; + +it("captures the Merge Queue tab when the repo has not opted in", async () => { + const { repoPath } = createTestRepo(false); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = false; + queueState.entries = QUEUE; + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /merge queue/i })); + + // Opt-in defaults to off: no config row in Postgres means no queue. + await screen.findByText(/merge queue is off for this repository/i); + expect( + screen.queryByRole("switch", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); + await captureDocument(document, { + name: "merge-queue-tab-01-disabled", + expectations: [ + 'The Merge Queue tab is selected and shows a centred empty state: a merge icon over "The merge queue is off for this repository," with an outlined "Enable it in Settings › Integrations" button below.', + "There is no toggle switch anywhere on this tab -- the opt-in lives in Settings.", + "No queue entries are listed.", + ], + }); +}, 120000); + +it("captures the stacked PR queue for an opted-in repo", async () => { + const { repoPath } = createTestRepo(false); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = true; + queueState.entries = QUEUE; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue({ data: { ok: true }, error: null }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /merge queue/i })); + await screen.findByText("PR #101"); + await captureDocument(document, { + name: "merge-queue-tab-02-enabled-with-queue", + expectations: [ + "The tab lists the queue directly, with no toggle row above it.", + "A single vertical line runs down the left of the whole list, with a round node on it for every entry -- the line is continuous across the stack groupings, not restarted per stack, showing one merge sequence.", + "Node colours follow status: PR #101 (Merging) is green, PR #102 (Testing) is amber, the Queued ones are grey.", + ], + }); + await captureDocument(document, { + name: "merge-queue-tab-02b-stacks-and-terminator", + expectations: [ + 'Two "Stack of N" headers appear inline in the list: "Stack of 3 · merges bottom-up into main" above entries 1-3, and "Stack of 2" above entries 5-6. Entry #4 has no stack header.', + 'Each stack header has a "Remove stack" button, entries in a stack have a short vertical accent line to their left, and every entry row has its own small X remove button.', + 'At the very bottom of the line is a down-arrow and the target branch "main"; the entry with no PR number reads "No PR".', + ], + }); + + // Entries render in merge order across the stacks. + const entries = await screen.findAllByTestId(/^merge-queue-entry-/); + expect(entries.map((el) => el.getAttribute("data-testid"))).toEqual([ + "merge-queue-entry-1", + "merge-queue-entry-2", + "merge-queue-entry-3", + "merge-queue-entry-4", + "merge-queue-entry-5", + "merge-queue-entry-6", + ]); + expect(screen.getByTestId("merge-queue-stack-feat/base")).toBeInTheDocument(); + expect(screen.getByTestId("merge-queue-single-fix/solo")).toBeInTheDocument(); + expect( + screen.getByTestId("merge-queue-stack-chore/base"), + ).toBeInTheDocument(); +}, 120000); + +it("captures removing a single branch and a whole stack from the queue", async () => { + const { repoPath } = createTestRepo(false); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = true; + queueState.entries = QUEUE; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue({ data: { ok: true }, error: null }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /merge queue/i })); + await screen.findByText("PR #104"); + + // Remove the standalone branch: exactly one dequeue, for that branch only. + await user.click( + screen.getByRole("button", { name: "Remove fix/solo from queue" }), + ); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "enqueue-workspace", + expect.objectContaining({ + body: expect.objectContaining({ + branch_name: "fix/solo", + action: "dequeue", + }), + }), + ); + }); + expect(mockInvoke).toHaveBeenCalledTimes(1); + await captureDocument(document, { + name: "merge-queue-tab-04-removed-single", + expectations: [ + "The queue still lists both stack blocks and the standalone PR #104 row (the list refetches from the server, which this harness holds fixed).", + "No error is shown -- the remove click was accepted.", + ], + }); + + // Removing the whole stack dequeues every branch in it, top-down so no + // branch is ever left stacked on a parent that has already gone. + mockInvoke.mockClear(); + await user.click( + screen.getByRole("button", { name: "Remove stack of 3 from queue" }), + ); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledTimes(3); + }); + expect(mockInvoke.mock.calls.map((call) => call[1].body.branch_name)).toEqual( + ["feat/top", "feat/mid", "feat/base"], + ); + await captureDocument(document, { + name: "merge-queue-tab-05-removed-stack", + expectations: [ + 'The "Stack of 3" block\'s Remove stack button has been clicked; the three branches were dequeued top-down.', + "The view is otherwise unchanged since the stubbed backend keeps returning the same queue contents.", + ], + }); +}, 120000); + +it("removes the upper part of a stack when a middle branch is removed", async () => { + const { repoPath } = createTestRepo(false); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = true; + queueState.entries = QUEUE; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue({ data: { ok: true }, error: null }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /merge queue/i })); + await screen.findByText("PR #102"); + + // feat/top is stacked on feat/mid, so removing feat/mid alone would strand + // it. Both go, and nothing below feat/mid is touched. + await user.click( + screen.getByRole("button", { name: "Remove feat/mid from queue" }), + ); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + expect(mockInvoke.mock.calls.map((call) => call[1].body.branch_name)).toEqual( + ["feat/top", "feat/mid"], + ); +}, 120000); + +it("captures the empty queue for a repo that has the queue switched on", async () => { + const { repoPath } = createTestRepo(false); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = true; + queueState.entries = []; + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: /merge queue/i })); + await waitFor(async () => { + expect(await screen.findByText("Merge queue is empty.")).toBeVisible(); + }); + await captureDocument(document, { + name: "merge-queue-tab-03-enabled-empty", + expectations: [ + 'The toggle row reads "Enabled for this repository." with the switch in the ON position.', + 'The body shows the "Merge queue is empty." empty state with a merge icon -- distinct from the "off for this repository" state.', + ], + }); +}, 120000); diff --git a/scripts/screenshot/specs/merge-queue.spec.tsx b/scripts/screenshot/specs/merge-queue.spec.tsx new file mode 100644 index 00000000..246ac9c4 --- /dev/null +++ b/scripts/screenshot/specs/merge-queue.spec.tsx @@ -0,0 +1,353 @@ +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { getWorkspaces } from "../../../src/lib/api"; +import type { WorkspaceQueueStatus } from "../../../src/lib/api-types"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { + commitRepoFile, + commitWorkspaceFile, + createTestRepo, + openRepo, +} from "../../../test/utils"; +import { captureDocument } from "../capture"; + +const BRANCH_NAME = "feat/merge-queue-demo"; + +// The merge queue's server side lives in Supabase (RPCs + edge functions), which +// the desktop harness has no access to. Everything else here is real: real jj +// repo, real Rust dispatch, real React tree. Only the Supabase boundary and the +// `gh` subprocess are stubbed, and the stub is driven by a mutable holder so the +// spec can walk the workspace through each queue state the backend can emit. +const { queueState, mockInvoke, mockGetPrInfoViaGh, mockGetGitRemoteUrl } = + vi.hoisted(() => ({ + queueState: { + current: null as WorkspaceQueueStatus | null, + enabled: true, + }, + mockInvoke: vi.fn(), + mockGetPrInfoViaGh: vi.fn(), + mockGetGitRemoteUrl: vi.fn(), + })); + +vi.mock("../../../src/lib/features", () => ({ + FEATURES: { + pro: true, + stripePayments: false, + emailSignup: false, + mergeQueue: true, + }, +})); + +vi.mock("../../../src/lib/supabase", () => ({ + supabase: { + rpc: vi.fn(async (fn: string) => { + if (fn === "get_merge_queue_enabled") { + return { data: queueState.enabled, error: null }; + } + if (fn === "set_merge_queue_enabled") { + queueState.enabled = true; + return { data: true, error: null }; + } + if (fn === "get_workspace_queue_status") { + return { + data: queueState.current ? [queueState.current] : [], + error: null, + }; + } + if (fn === "get_repo_branch_queue_statuses") { + return { + data: queueState.current + ? [ + { + branch_name: BRANCH_NAME, + pr_number: queueState.current.pr_number, + status: queueState.current.status, + position: queueState.current.position, + target_branch: queueState.current.target_branch, + }, + ] + : [], + error: null, + }; + } + return { data: [], error: null }; + }), + functions: { invoke: mockInvoke }, + auth: { + getSession: vi + .fn() + .mockResolvedValue({ data: { session: null }, error: null }), + onAuthStateChange: vi.fn().mockReturnValue({ + data: { subscription: { unsubscribe: vi.fn() } }, + }), + }, + }, + SUPABASE_URL: "http://localhost:54321", + SUPABASE_ANON_KEY: "anon", + WEB_URL: "http://localhost:3000", +})); + +vi.mock("../../../src/lib/api", async () => { + const actual = await vi.importActual( + "../../../src/lib/api", + ); + return { + ...actual, + getPrInfoViaGh: mockGetPrInfoViaGh, + // createTestRepo's remote is a local bare repo, so the real + // get_git_remote_url returns null and every queue code path short-circuits + // on "Repository or branch not detected". Stub it to the GitHub remote a + // real user would have. + getGitRemoteUrl: mockGetGitRemoteUrl, + }; +}); + +const REMOTE_INFO = { + owner: "treq-dev", + repo: "treq", + full_name: "treq-dev/treq", +}; + +function entry( + status: WorkspaceQueueStatus["status"], + overrides: Partial = {}, +): WorkspaceQueueStatus { + return { + entry_id: "00000000-0000-0000-0000-000000000001", + pr_number: 42, + status, + position: 1, + target_branch: "main", + lane_number: null, + ci_run_status: null, + failure_reason: null, + enqueued_at: new Date().toISOString(), + merged_at: null, + ...overrides, + }; +} + +// Real repo + workspace + push, driven through the app's own affordances. +// Every spec below needs a branch that exists on the remote, since the merge +// queue button is gated on `!workspace.not_on_remote`. +async function setupRepo() { + const { repoPath } = createTestRepo(true); + openRepo(repoPath); + await commitRepoFile(repoPath, "base.txt", "base", "Base commit"); + return { repoPath }; +} + +async function createAndPushWorkspace( + user: ReturnType, + repoPath: string, +) { + // Create the workspace through the real "Stack" dialog rather than the + // createWorkspace helper -- workspace creation is part of the scenario. + await screen.findByTestId("show-workspace-header"); + await user.click(await screen.findByRole("button", { name: "Stack" })); + + const dialog = await screen.findByTestId("modal"); + await user.type(within(dialog).getByLabelText("Branch Name"), BRANCH_NAME); + await user.click( + within(dialog).getByRole("button", { name: "Create Workspace" }), + ); + await waitFor(() => { + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + const workspace = (await getWorkspaces(repoPath)).find( + (candidate) => candidate.branch_name === BRANCH_NAME, + ); + if (!workspace) throw new Error(`Expected ${BRANCH_NAME} workspace to exist`); + + await commitWorkspaceFile( + repoPath, + { id: workspace.id, path: workspace.workspace_path }, + "queue-file.txt", + "queue content", + "Workspace commit for queue", + ); + + await user.click( + await screen.findByRole("button", { name: /push to remote/i }), + ); + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /push to remote/i }), + ).not.toBeInTheDocument(); + }); + return workspace; +} + +function stubHappyPath() { + mockGetPrInfoViaGh.mockResolvedValue({ + number: 42, + title: "Merge queue demo", + state: "OPEN", + url: "https://github.com/treq-dev/treq/pull/42", + head_ref_name: BRANCH_NAME, + base_ref_name: "main", + merge_state_status: "CLEAN", + }); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue({ data: { ok: true }, error: null }); + mockGetGitRemoteUrl.mockResolvedValue(REMOTE_INFO); + queueState.enabled = true; +} + +it("captures enqueuing a workspace into the merge queue", async () => { + const user = userEvent.setup(); + stubHappyPath(); + queueState.current = null; + + const { repoPath } = await setupRepo(); + render(); + await createAndPushWorkspace(user, repoPath); + + const addToQueue = await screen.findByRole("button", { + name: /add to queue/i, + }); + expect(addToQueue).toBeEnabled(); + await captureDocument(document, { + name: "merge-queue-01-not-queued", + expectations: [ + 'The workspace header shows an outlined "Add to Queue" button with a merge (git-merge) icon.', + 'The header also still shows the "Merge..." button next to it.', + "The workspace row in the left sidebar has no coloured status dot next to its branch name.", + ], + }); + + // Enqueue through the real button. The edge function is stubbed, so flip the + // state the RPC reports before the post-mutation invalidation refetches. + queueState.current = entry("queued", { position: 1 }); + await user.click(addToQueue); + await screen.findByText("Added to merge queue"); + await screen.findByRole("button", { name: "Queued" }); + await captureDocument(document, { + name: "merge-queue-02-queued", + expectations: [ + 'The queue button now reads "Queued" -- no position number -- and is filled/secondary-styled rather than outlined.', + 'A green success toast reads "Added to merge queue".', + "The workspace row in the left sidebar shows a small yellow dot next to its branch name.", + ], + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "enqueue-workspace", + expect.objectContaining({ + body: expect.objectContaining({ + branch_name: BRANCH_NAME, + action: "enqueue", + }), + }), + ); +}, 120000); + +// The status queries poll on a 30s interval, so each downstream queue state +// gets its own render with the state already in place rather than being driven +// by a mid-test refetch that the user has no way to trigger. +it("captures the testing state (CI running in a lane)", async () => { + const user = userEvent.setup(); + stubHappyPath(); + queueState.current = entry("testing", { position: 1, lane_number: 2 }); + + const { repoPath } = await setupRepo(); + render(); + await createAndPushWorkspace(user, repoPath); + + await screen.findByRole("button", { name: "Queued" }); + expect( + screen.queryByRole("button", { name: /testing/i }), + ).not.toBeInTheDocument(); + await captureDocument(document, { + name: "merge-queue-03-testing", + expectations: [ + 'The queue button reads "Queued" -- the header deliberately shows neither a "Testing…" label nor a position number.', + "The sidebar dot for the workspace is blue (CI running) rather than yellow.", + ], + }); +}, 120000); + +it("captures the merging state, which the frontend has no case for", async () => { + const user = userEvent.setup(); + stubHappyPath(); + // 'merging' is a real value of the merge_queue_entry_status enum + // (supabase/migrations/003_merge_queue.sql) but is absent from the + // frontend's QueueEntryStatus union. + queueState.current = entry("merging", { position: 1 }); + + const { repoPath } = await setupRepo(); + render(); + await createAndPushWorkspace(user, repoPath); + + await screen.findByRole("button", { name: "Queued" }); + await captureDocument(document, { + name: "merge-queue-04-merging", + expectations: [ + "The entry is in the backend 'merging' state (the queue is landing it now).", + 'The queue button reads "Queued".', + "The sidebar dot for the workspace is green (passed CI, merging now) -- not the neutral grey it used to be before 'merging' had a case.", + ], + }); +}, 120000); + +it("captures the merged state", async () => { + const user = userEvent.setup(); + stubHappyPath(); + queueState.current = entry("merged", { + position: 0, + merged_at: new Date().toISOString(), + }); + + const { repoPath } = await setupRepo(); + render(); + await createAndPushWorkspace(user, repoPath); + + await screen.findByRole("button", { name: /add to queue/i }); + await captureDocument(document, { + name: "merge-queue-05-merged", + expectations: [ + 'Once the entry merges, the button reverts to the outlined "Add to Queue" label.', + "The sidebar dot for the workspace is dark green (merged via queue).", + ], + }); +}, 120000); + +it("captures the error toast when the branch has no open PR", async () => { + const user = userEvent.setup(); + stubHappyPath(); + // gh positively reports the branch's PR is closed -- the hook should refuse + // to enqueue and surface the reason rather than calling the edge function. + mockGetPrInfoViaGh.mockResolvedValue({ + number: 7, + title: "Closed PR", + state: "CLOSED", + url: "https://github.com/treq-dev/treq/pull/7", + head_ref_name: BRANCH_NAME, + base_ref_name: "main", + merge_state_status: "DIRTY", + }); + queueState.current = null; + + const { repoPath } = await setupRepo(); + render(); + await createAndPushWorkspace(user, repoPath); + + await user.click( + await screen.findByRole("button", { name: /add to queue/i }), + ); + await screen.findByText("Queue error"); + // Assert the actual reason, not just that *some* error surfaced -- a missing + // remote would otherwise produce the same "Queue error" title. + await screen.findByText(/gh reports: CLOSED/); + await captureDocument(document, { + name: "merge-queue-06-closed-pr-error", + expectations: [ + 'A red/destructive error toast reads "Queue error" with a description naming the branch and that gh reports CLOSED.', + 'The queue button is still the outlined "Add to Queue" -- nothing was enqueued.', + ], + }); + + expect(mockInvoke).not.toHaveBeenCalled(); +}, 120000); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 75be1283..526a87a7 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,33 +1,22 @@ /* eslint-disable max-lines */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { listen } from "@tauri-apps/api/event"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { ask } from "@tauri-apps/plugin-dialog"; -import { - UnifiedWorkspaceDialog, - type WorkspaceDialogDefaults, -} from "./UnifiedWorkspaceDialog"; -import { CommandPalette } from "./CommandPalette"; -import { WorkspacePicker } from "./WorkspacePicker"; -import { WorkspaceSidebar } from "./WorkspaceSidebar"; -import { ErrorBoundary } from "./ErrorBoundary"; -import { ShowWorkspace } from "./ShowWorkspace"; -import { - WorkspaceTerminalPane, - type WorkspaceTerminalPaneHandle, -} from "./WorkspaceTerminalPane"; -import type { ClaudeSessionData } from "./terminal/types"; - -import { SettingsPage } from "./SettingsPage"; -import { MergePreviewPage } from "./MergePreviewPage"; -import { GitHubPanel } from "./GitHubPanel"; -import { useToast } from "./ui/toast"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useKeyboardShortcut } from "../hooks/useKeyboard"; import { useWorkspaceHierarchy } from "../hooks/useWorkspaceHierarchy"; import { - Workspace, + type AgentDeepLinkRequest, + findWorkspaceByBranch, + isProcessedAgentRequest, + markProcessedAgentRequest, + parseAgentDeepLinks, + popPendingAgentRequests, + processAgentDeepLinkRequests, +} from "../lib/agentDeepLink"; +import { checkAndRebaseWorkspaces, createSession, deleteWorkspace, @@ -45,23 +34,33 @@ import { setSetting, setWindowRepoPath, updateSessionAccess, + type Workspace, } from "../lib/api"; +import { getFullWorkspacePath } from "../lib/utils"; import { buildWorkspaceTree, flattenWorkspaceTree, } from "../lib/workspace-tree"; -import { getFullWorkspacePath } from "../lib/utils"; -import { - findWorkspaceByBranch, - isProcessedAgentRequest, - markProcessedAgentRequest, - parseAgentDeepLinks, - popPendingAgentRequests, - processAgentDeepLinkRequests, - type AgentDeepLinkRequest, -} from "../lib/agentDeepLink"; +import { CommandPalette } from "./CommandPalette"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { GitHubPanel } from "./GitHubPanel"; +import { MergePreviewPage } from "./MergePreviewPage"; import { Onboarding } from "./Onboarding"; +import { SettingsPage } from "./SettingsPage"; +import { ShowWorkspace } from "./ShowWorkspace"; import type { BranchListItem } from "./TargetBranchSelector"; +import type { ClaudeSessionData } from "./terminal/types"; +import { + UnifiedWorkspaceDialog, + type WorkspaceDialogDefaults, +} from "./UnifiedWorkspaceDialog"; +import { useToast } from "./ui/toast"; +import { WorkspacePicker } from "./WorkspacePicker"; +import { WorkspaceSidebar } from "./WorkspaceSidebar"; +import { + WorkspaceTerminalPane, + type WorkspaceTerminalPaneHandle, +} from "./WorkspaceTerminalPane"; type ViewMode = | "session" @@ -1205,6 +1204,7 @@ export const Dashboard: React.FC = ({ repoPath={repoPath} initialPrNumber={githubInitialPrNumber} onInitialPrConsumed={clearGithubInitialPr} + onOpenSettings={openSettings} /> )} diff --git a/src/components/GitHubIntegrationSettings.tsx b/src/components/GitHubIntegrationSettings.tsx index a3ed60dc..5d4b131e 100644 --- a/src/components/GitHubIntegrationSettings.tsx +++ b/src/components/GitHubIntegrationSettings.tsx @@ -1,9 +1,16 @@ -import { useEffect, useState } from "react"; -import { ExternalLink, Github, Loader2 } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ExternalLink, Github, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; import { useAuth } from "../hooks/useAuth"; +import { + useGitRemoteInfo, + useMergeQueueEnabled, + useSetMergeQueueEnabled, +} from "../hooks/useMergeQueueStatus"; +import { FEATURES } from "../lib/features"; import { supabase, WEB_URL } from "../lib/supabase"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; export interface GitHubRepository { id: number; @@ -13,7 +20,123 @@ export interface GitHubRepository { installation_id: number; } -export const GitHubIntegrationSettings: React.FC = () => { +interface GitHubIntegrationSettingsProps { + /** Repo whose integration settings this page controls. */ + repoPath?: string; +} + +/** One row of an integration's settings: label, explanation, and control. */ +const SettingRow: React.FC<{ + title: string; + description: string; + children?: React.ReactNode; +}> = ({ title, description, children }) => ( +
+
+

{title}

+

{description}

+
+
{children}
+
+); + +/** + * Per-repo merge queue opt-in, stored in Postgres. A repo with no config row + * has never opted in, so this reads as off. Turning it on requires a Pro plan + * and a repo the GitHub App is installed on, so an ineligible repo gets the + * reason rather than a control that could only fail. + */ +const MergeQueueSetting: React.FC<{ + repoPath?: string; + isPro: boolean; + repositories: GitHubRepository[]; + repositoriesLoading: boolean; +}> = ({ repoPath, isPro, repositories, repositoriesLoading }) => { + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: enabled, isLoading } = useMergeQueueEnabled(repoPath); + const setEnabled = useSetMergeQueueEnabled(repoPath); + const [error, setError] = useState(null); + + const isLinked = + !!remoteInfo && + repositories.some((repo) => repo.full_name === remoteInfo.full_name); + const isEligible = isPro && isLinked; + + async function apply(next: boolean) { + setError(null); + try { + await setEnabled.mutateAsync(next); + } catch (err) { + setError((err as Error).message); + } + } + + function ineligibleReason(): string { + if (!remoteInfo) return "This repository has no GitHub remote."; + if (!isPro) return "Upgrade to Pro to use the merge queue."; + return `Install the Treq GitHub App on ${remoteInfo.full_name} to use the merge queue.`; + } + + return ( +
+ + {repositoriesLoading || isLoading ? ( + + ) : enabled ? ( + <> + {setEnabled.isPending && ( + + )} + void apply(next)} + /> + + ) : isEligible ? ( + + ) : !isPro ? ( + + ) : null} + + {error &&

{error}

} +
+ ); +}; + +export const GitHubIntegrationSettings: React.FC< + GitHubIntegrationSettingsProps +> = ({ repoPath }) => { const { user, session, @@ -27,8 +150,11 @@ export const GitHubIntegrationSettings: React.FC = () => { const isPro = subscription?.plan === "pro" && subscription.status === "active"; + const userId = user?.id; + const isSignedIn = !!user && !!session; + useEffect(() => { - if (!user || !session) return; + if (!isSignedIn) return; let active = true; setLoading(true); setFailed(false); @@ -44,7 +170,8 @@ export const GitHubIntegrationSettings: React.FC = () => { return () => { active = false; }; - }, [user, session]); + // Keyed on user identity, not the auth objects, so an unstable useAuth can't loop this. + }, [userId, isSignedIn]); if (authLoading) { return ( @@ -57,7 +184,7 @@ export const GitHubIntegrationSettings: React.FC = () => { ); } - if (!user || !session) { + if (!isSignedIn) { return (
@@ -82,55 +209,67 @@ export const GitHubIntegrationSettings: React.FC = () => { : repositories.filter((repo) => !repo.private); return ( -
-
-
- -
-

GitHub

-

- {isPro ? "All repositories" : "Public repositories"} -

-
+
+ {/* One header per integration; settings sit directly beneath it. */} +
+
+ +

GitHub

+ + {isPro ? "All repositories" : "Public repositories"} +
- {loading ? ( -
- - Loading GitHub repositories… -
- ) : failed ? ( -

- Could not load GitHub repositories. Try again later. -

- ) : visibleRepositories.length === 0 ? ( -

- No enabled GitHub repositories. -

- ) : ( -
    - {visibleRepositories.map((repo) => ( -
  • - {repo.full_name} - - {repo.private ? "Private" : "Public"} - -
  • - ))} -
- )} - -
+ +
+ {FEATURES.mergeQueue && ( + + )} + + + + + + {!loading && !failed && visibleRepositories.length > 0 && ( +
    + {visibleRepositories.map((repo) => ( +
  • + {repo.full_name} + + {repo.private ? "Private" : "Public"} + +
  • + ))} +
+ )} +
+
); }; diff --git a/src/components/GitHubPanel.tsx b/src/components/GitHubPanel.tsx index 5ad4f641..ca03178c 100644 --- a/src/components/GitHubPanel.tsx +++ b/src/components/GitHubPanel.tsx @@ -1,27 +1,30 @@ -import { useEffect, useState } from "react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { AlertCircle, CircleDot, Github, - GitMerge, GitPullRequest, Loader2, Plus, RefreshCw, - Rocket, } from "lucide-react"; -import { Button } from "./ui/button"; -import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { useEffect, useState } from "react"; import { useAuth } from "../hooks/useAuth"; -import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { + useDequeueBranches, + useGitRemoteInfo, + useMergeQueueEnabled, +} from "../hooks/useMergeQueueStatus"; import { GH_LIST_PAGE_SIZE, ghListIssues, ghListPrs } from "../lib/api"; -import type { QueueEntryStatus } from "../lib/api-types"; -import { WEB_URL, supabase } from "../lib/supabase"; +import { FEATURES } from "../lib/features"; +import type { QueueEntry } from "../lib/merge-queue-stacks"; +import { supabase } from "../lib/supabase"; +import { MergeQueueTab } from "./github-panel/MergeQueueTab"; import { CreateIssueForm, IssueDetailPanel } from "./github-panel/IssueDetail"; import { CreatePrForm, PrDetailPanel } from "./github-panel/PrDetail"; import { EmptyState, IssueListItem, PrListItem } from "./github-panel/shared"; +import { Button } from "./ui/button"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; type TabValue = "issues" | "prs" | "merge-queue"; type StateFilter = "open" | "closed" | "all"; @@ -31,13 +34,8 @@ interface GitHubPanelProps { /** When set, opens the PRs tab and selects this PR. */ initialPrNumber?: number | null; onInitialPrConsumed?: () => void; -} - -interface QueueBranchEntry { - branch_name: string; - status: QueueEntryStatus; - position: number; - target_branch: string; + /** Opens the settings page, where the merge queue opt-in lives. */ + onOpenSettings?: (tab?: string) => void; } const FILTERS: { label: string; value: StateFilter }[] = [ @@ -46,87 +44,19 @@ const FILTERS: { label: string; value: StateFilter }[] = [ { label: "All", value: "all" }, ]; -function queueStatusLabel(status: QueueEntryStatus): string { - switch (status) { - case "queued": - return "Queued"; - case "testing": - return "Testing"; - case "passed": - return "Passed"; - case "merged": - return "Merged"; - case "failed": - return "Failed"; - case "dequeued": - return "Dequeued"; - default: - return status; - } -} - -function QueueStatusChip({ status }: { status: QueueEntryStatus }) { - const color = - status === "testing" - ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" - : status === "failed" - ? "bg-red-500/20 text-red-600 dark:text-red-400" - : status === "merged" || status === "passed" - ? "bg-green-500/20 text-green-600 dark:text-green-400" - : "bg-muted text-muted-foreground"; - - return ( - - {queueStatusLabel(status)} - - ); -} - -function MergeQueueUpsell() { - return ( -
-
-
- -
-
-
- PRO -
-

- Unlock Merge Queue -

-

- Queue stacked PRs, run CI in parallel lanes, and merge with - confidence. Upgrade to Pro to manage your repository's merge - queue from Treq. -

-
- -
-
- ); -} - export const GitHubPanel: React.FC = ({ repoPath, initialPrNumber = null, onInitialPrConsumed, + onOpenSettings, }) => { const { subscription } = useAuth(); const isPro = subscription?.plan === "pro" && subscription.status === "active"; const { data: remoteInfo, isLoading: remoteLoading } = useGitRemoteInfo(repoPath); + const { data: queueEnabled } = useMergeQueueEnabled(repoPath); + const dequeueBranches = useDequeueBranches(repoPath); const [activeTab, setActiveTab] = useState("issues"); const [issueFilter, setIssueFilter] = useState("open"); const [prFilter, setPrFilter] = useState("open"); @@ -196,12 +126,17 @@ export const GitHubPanel: React.FC = ({ { p_repo_full_name: repoFullName }, ); if (error) throw error; - return ((data ?? []) as QueueBranchEntry[]).slice().sort((a, b) => { + return ((data ?? []) as QueueEntry[]).slice().sort((a, b) => { if (a.position !== b.position) return a.position - b.position; return a.branch_name.localeCompare(b.branch_name); }); }, - enabled: !!repoFullName && activeTab === "merge-queue" && isPro, + enabled: + FEATURES.mergeQueue && + queueEnabled === true && + !!repoFullName && + activeTab === "merge-queue" && + isPro, refetchInterval: 30_000, }); @@ -277,17 +212,19 @@ export const GitHubPanel: React.FC = ({ Issues Pull Requests - - Merge Queue - {!isPro && ( - - PRO - - )} - + {FEATURES.mergeQueue && ( + + Merge Queue + {!isPro && ( + + PRO + + )} + + )}
@@ -444,38 +381,16 @@ export const GitHubPanel: React.FC = ({ )} - {activeTab === "merge-queue" && !isPro && } - - {remoteInfo && activeTab === "merge-queue" && isPro && ( - <> - {queueLoading ? ( -
- -
- ) : queueEntries.length === 0 ? ( - - ) : ( - queueEntries.map((entry) => ( -
-
- - - #{entry.position} - -
-

- {entry.branch_name} -

-

- → {entry.target_branch} -

-
- )) - )} - + {activeTab === "merge-queue" && ( + )}
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index c2bd563e..5f57a34e 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -1,16 +1,16 @@ +import { FolderGit2, GitBranch, Plug, Settings, User } from "lucide-react"; import { useEffect, useState } from "react"; +import { useTerminalSettings } from "../hooks/useTerminalSettings"; +import { useTheme } from "../hooks/useTheme"; +import { getSetting, setSetting } from "../lib/api"; +import { AccountSettings } from "./AccountSettings"; +import { GitHubIntegrationSettings } from "./GitHubIntegrationSettings"; +import { RepositorySettingsContent } from "./RepositorySettingsContent"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; -import { RepositorySettingsContent } from "./RepositorySettingsContent"; -import { useTheme } from "../hooks/useTheme"; -import { useTerminalSettings } from "../hooks/useTerminalSettings"; import { useToast } from "./ui/toast"; -import { getSetting, setSetting } from "../lib/api"; -import { FolderGit2, GitBranch, Plug, Settings, User } from "lucide-react"; -import { AccountSettings } from "./AccountSettings"; -import { GitHubIntegrationSettings } from "./GitHubIntegrationSettings"; type TabValue = "application" | "repository" | "account" | "integrations"; @@ -284,7 +284,7 @@ export const SettingsPage: React.FC = ({ - + diff --git a/src/components/ShowWorkspace.merge-queue.test.tsx b/src/components/ShowWorkspace.merge-queue.test.tsx index c925239b..3681d24e 100644 --- a/src/components/ShowWorkspace.merge-queue.test.tsx +++ b/src/components/ShowWorkspace.merge-queue.test.tsx @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "../../test/test-utils"; -import { ShowWorkspace } from "./ShowWorkspace"; import type { Workspace } from "../lib/api"; import * as api from "../lib/api"; +import { ShowWorkspace } from "./ShowWorkspace"; const { mockFeatures } = vi.hoisted(() => ({ mockFeatures: { @@ -67,8 +67,14 @@ vi.mock("../lib/api", async () => { }; }); +const { mockQueueEnabled } = vi.hoisted(() => ({ + mockQueueEnabled: { current: true }, +})); + vi.mock("../hooks/useMergeQueueStatus", () => ({ useMergeQueueStatus: () => ({ data: null }), + useMergeQueueEnabled: () => ({ data: mockQueueEnabled.current }), + useSetMergeQueueEnabled: () => ({ mutateAsync: vi.fn(), isPending: false }), useEnqueueWorkspace: () => ({ enqueue: { mutateAsync: vi.fn(), isPending: false }, dequeue: { mutateAsync: vi.fn(), isPending: false }, @@ -92,6 +98,7 @@ describe("ShowWorkspace - Add to Queue feature flag", () => { beforeEach(() => { vi.clearAllMocks(); mockFeatures.mergeQueue = false; + mockQueueEnabled.current = true; vi.mocked(api.getWorkspaceStatus).mockResolvedValue({ current: workspace, has_conflicts: false, @@ -142,4 +149,26 @@ describe("ShowWorkspace - Add to Queue feature flag", () => { await screen.findByRole("button", { name: /add to queue/i }), ).toBeInTheDocument(); }); + + it("hides Add to Queue when the repo has not enabled the merge queue", async () => { + mockFeatures.mergeQueue = true; + mockQueueEnabled.current = false; + + render( + , + ); + + expect( + await screen.findByRole("button", { name: /merge/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /add to queue/i }), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ShowWorkspace.tsx b/src/components/ShowWorkspace.tsx index e1af342f..017560dd 100644 --- a/src/components/ShowWorkspace.tsx +++ b/src/components/ShowWorkspace.tsx @@ -1,45 +1,80 @@ /* eslint-disable max-lines, max-params */ +import { + type QueryClient, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + ChevronLeft, + Code2, + Eye, + EyeOff, + File, + FileDiff, + Folder, + GitBranch, + GitCommitHorizontal, + GitCompareArrows, + GitMerge, + Layers2, + Loader2, + MoreVertical, + RefreshCw, + Search, + Trash2, + Upload, +} from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { - DirectoryEntry, - dryRunHomeRepoRebase, - type HomeRebaseDryRunResult, - type JjLogResult, - type SingleRebaseResult, - Workspace, - type WorkspaceBookmarkConflict, + useEnqueueWorkspace, + useMergeQueueEnabled, + useMergeQueueStatus, +} from "../hooks/useMergeQueueStatus"; +import { useTerminalSettings } from "../hooks/useTerminalSettings"; +import { checkAndRebaseWorkspaces, createSession, + type DirectoryEntry, discardWorkspaceChanges, + dryRunHomeRepoRebase, getWorkspaceReadme, getWorkspaceStatus, + type HomeRebaseDryRunResult, + type JjLogResult, listCommits, lsWorkspace, pullWorkspaceFromRemote, pushWorkspaceToRemote, rebaseHomeRepoBranch, resolveBookmarkConflict, + type SingleRebaseResult, updateWorkspace, + type Workspace, + type WorkspaceBookmarkConflict, } from "../lib/api"; +import { FEATURES } from "../lib/features"; import { getStatusBgColor } from "../lib/git-status-colors"; -import { type ParsedFileChange } from "../lib/git-utils"; +import type { ParsedFileChange } from "../lib/git-utils"; import { cn, getFullWorkspacePath, resolveReadmeImageSrc } from "../lib/utils"; - +import type { SessionCreationInfo } from "../types/sessions"; import { ChangesDiffViewer, type ChangesDiffViewerHandle, } from "./ChangesDiffViewer"; +import { CommitDiffViewer } from "./CommitDiffViewer"; +import { CreatePrButtonGroup } from "./CreatePrButtonGroup"; import { FileBrowser } from "./FileBrowser"; import { LinearCommitHistory } from "./LinearCommitHistory"; -import { CommitDiffViewer } from "./CommitDiffViewer"; -import { WorkspaceBookmarkConflictModal } from "./WorkspaceBookmarkConflictModal"; -import { WorkspaceStackPanel } from "./WorkspaceStackPanel"; -import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { MarkdownContent } from "./MarkdownContent"; +import { + type BranchListItem, + TargetBranchSelector, +} from "./TargetBranchSelector"; +import { TaskInput } from "./TaskInput"; import { Button } from "./ui/button"; -import { Kbd, KbdGroup } from "./ui/kbd"; -import { useToast } from "./ui/toast"; import { DropdownMenu, DropdownMenuContent, @@ -47,50 +82,19 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { useToast } from "./ui/toast"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./ui/tooltip"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { - AlertTriangle, - ArrowLeft, - ChevronLeft, - Code2, - Eye, - EyeOff, - File, - FileDiff, - Folder, - GitBranch, - GitCommitHorizontal, - GitCompareArrows, - GitMerge, - Layers2, - Loader2, - MoreVertical, - RefreshCw, - Search, - Trash2, - Upload, -} from "lucide-react"; -import { - TargetBranchSelector, - type BranchListItem, -} from "./TargetBranchSelector"; -import { TaskInput } from "./TaskInput"; -import { MarkdownContent } from "./MarkdownContent"; -import { useTerminalSettings } from "../hooks/useTerminalSettings"; -import { - useEnqueueWorkspace, - useMergeQueueStatus, -} from "../hooks/useMergeQueueStatus"; -import { FEATURES } from "../lib/features"; -import type { SessionCreationInfo } from "../types/sessions"; -import { CreatePrButtonGroup } from "./CreatePrButtonGroup"; import { ViewPrButton } from "./ViewPrButton"; +import { WorkspaceBookmarkConflictModal } from "./WorkspaceBookmarkConflictModal"; +import { WorkspaceStackPanel } from "./WorkspaceStackPanel"; interface ShowWorkspaceProps { repositoryPath?: string; @@ -158,6 +162,9 @@ export const ShowWorkspace = memo( const { addToast } = useToast(); const { fontSize } = useTerminalSettings(); + const { data: queueEnabled } = useMergeQueueEnabled( + effectiveRepoPath || undefined, + ); const { data: queueStatus } = useMergeQueueStatus( effectiveRepoPath || undefined, workspace?.branch_name, @@ -1542,8 +1549,10 @@ export const ShowWorkspace = memo( )} - {/* Merge queue button */} + {/* Merge queue button. Hidden entirely until the repo has + opted into the merge queue in the GitHub panel. */} {FEATURES.mergeQueue && + queueEnabled === true && workspace && workspace.branch_name !== defaultBranch && !workspace.not_on_remote && ( @@ -1593,9 +1602,7 @@ export const ShowWorkspace = memo( !["merged", "failed", "dequeued"].includes( queueStatus.status, ) - ? queueStatus.status === "testing" - ? "Testing…" - : `Queue #${queueStatus.position}` + ? "Queued" : "Add to Queue"} @@ -1603,11 +1610,9 @@ export const ShowWorkspace = memo( {queueStatus ? queueStatus.status === "merged" ? "Merged via queue" - : queueStatus.status === "testing" - ? `CI running in lane ${queueStatus.lane_number ?? "?"}` - : queueStatus.status === "failed" - ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` - : `In merge queue at position ${queueStatus.position}` + : queueStatus.status === "failed" + ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` + : `In merge queue at position ${queueStatus.position}` : "Add this branch to the merge queue"} diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index 9fee8e6e..7020ae6a 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -1,6 +1,5 @@ +import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd"; import { useQuery } from "@tanstack/react-query"; -import { memo, useCallback, useMemo, useState } from "react"; -import { DragDropContext, type DropResult, Droppable } from "@hello-pangea/dnd"; import { GitBranch, Github, @@ -9,17 +8,22 @@ import { Settings, Trash2, } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { + useGitRemoteInfo, + useMergeQueueEnabled, +} from "../hooks/useMergeQueueStatus"; import { - type Workspace, getWorkspaceStatus, getWorkspaces, listWorkspaceStatuses, + type Workspace, } from "../lib/api"; import type { - WorkspaceSidebarStatus, QueueEntryStatus, + WorkspaceSidebarStatus, } from "../lib/api-types"; -import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { FEATURES } from "../lib/features"; import { supabase } from "../lib/supabase"; import { buildWorkspaceTree, @@ -128,6 +132,7 @@ export const WorkspaceSidebar: React.FC = memo( }); const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: queueEnabled } = useMergeQueueEnabled(repoPath); const { data: branchQueueStatuses } = useQuery({ queryKey: ["repo-branch-queue-statuses", remoteInfo?.full_name], queryFn: async () => { @@ -143,7 +148,7 @@ export const WorkspaceSidebar: React.FC = memo( } return map; }, - enabled: !!remoteInfo, + enabled: FEATURES.mergeQueue && queueEnabled === true && !!remoteInfo, refetchInterval: 30_000, }); diff --git a/src/components/WorkspaceSidebarItem.tsx b/src/components/WorkspaceSidebarItem.tsx index 775cfc95..8eb6ed62 100644 --- a/src/components/WorkspaceSidebarItem.tsx +++ b/src/components/WorkspaceSidebarItem.tsx @@ -1,21 +1,21 @@ import { Draggable } from "@hello-pangea/dnd"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { + AlertTriangle, Bot, Copy, FolderOpen, GitBranch, - AlertTriangle, Layers2, Pencil, Terminal, Trash2, } from "lucide-react"; -import type { Workspace, QueueEntryStatus } from "../lib/api"; -import type { FlattenedWorkspaceNode } from "../lib/workspace-tree"; +import { useEditorApps } from "../hooks/useEditorApps"; +import type { QueueEntryStatus, Workspace } from "../lib/api"; import { cn, getFullWorkspacePath } from "../lib/utils"; +import type { FlattenedWorkspaceNode } from "../lib/workspace-tree"; import { getWorkspaceTitle as getWorkspaceTitleFromUtils } from "../lib/workspace-utils"; -import { useEditorApps } from "../hooks/useEditorApps"; import { Button } from "./ui/button"; import { ContextMenu, @@ -139,8 +139,11 @@ function queueStatusDot(status: QueueEntryStatus): { color: "bg-blue-400 animate-pulse", label: "CI running in merge queue", }; - case "passed": - return { color: "bg-green-400", label: "Passed CI, awaiting merge" }; + case "merging": + return { + color: "bg-green-400 animate-pulse", + label: "Passed CI, merging now", + }; case "merged": return { color: "bg-green-600", label: "Merged via queue" }; case "failed": diff --git a/src/components/github-panel/MergeQueueTab.tsx b/src/components/github-panel/MergeQueueTab.tsx new file mode 100644 index 00000000..50de878f --- /dev/null +++ b/src/components/github-panel/MergeQueueTab.tsx @@ -0,0 +1,301 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ArrowDown, GitMerge, Layers2, Loader2, Rocket, X } from "lucide-react"; +import type { UseMutationResult } from "@tanstack/react-query"; +import type { QueueEntryStatus } from "../../lib/api-types"; +import { + buildQueueStacks, + type QueueEntry, +} from "../../lib/merge-queue-stacks"; +import { WEB_URL } from "../../lib/supabase"; +import { Button } from "../ui/button"; +import { EmptyState } from "./shared"; + +function queueStatusLabel(status: QueueEntryStatus): string { + switch (status) { + case "queued": + return "Queued"; + case "testing": + return "Testing"; + case "merging": + return "Merging"; + case "merged": + return "Merged"; + case "failed": + return "Failed"; + case "dequeued": + return "Dequeued"; + default: + return status; + } +} + +function queueNodeColor(status: QueueEntryStatus): string { + switch (status) { + case "testing": + return "bg-amber-500"; + case "merging": + return "bg-green-500 animate-pulse"; + case "merged": + return "bg-green-600"; + case "failed": + return "bg-red-500"; + default: + return "bg-muted-foreground"; + } +} + +function QueueStatusChip({ status }: { status: QueueEntryStatus }) { + const color = + status === "testing" + ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" + : status === "failed" + ? "bg-red-500/20 text-red-600 dark:text-red-400" + : status === "merged" || status === "merging" + ? "bg-green-500/20 text-green-600 dark:text-green-400" + : "bg-muted text-muted-foreground"; + + return ( + + {queueStatusLabel(status)} + + ); +} + +export function MergeQueueUpsell() { + return ( +
+
+
+ +
+
+
+ PRO +
+

+ Unlock Merge Queue +

+

+ Queue stacked PRs, run CI in parallel lanes, and merge with + confidence. Upgrade to Pro to manage your repository's merge + queue from Treq. +

+
+ +
+
+ ); +} + +interface MergeQueueDisabledProps { + onOpenSettings?: (tab?: string) => void; +} + +function MergeQueueDisabled({ onOpenSettings }: MergeQueueDisabledProps) { + return ( +
+ +

+ The merge queue is off for this repository. +

+ +
+ ); +} + +interface QueueStackBlockProps { + stack: ReturnType[number]; + dequeueBranches: UseMutationResult; +} + +function QueueStackBlock({ stack, dequeueBranches }: QueueStackBlockProps) { + const isStack = stack.entries.length > 1; + const stackKey = stack.entries[0].branch_name; + + return ( +
+ {isStack && ( +
+ + + Stack of {stack.entries.length} + + + merges bottom-up into {stack.targetBranch} + +
+ +
+ )} + {stack.entries.map((entry, indexInStack) => ( +
+
+
+
+
+
+ + #{entry.position} + + + {entry.pr_number != null ? `PR #${entry.pr_number}` : "No PR"} + + +
+

+ {entry.branch_name} → {entry.target_branch} +

+
+ +
+ ))} +
+ ); +} + +interface MergeQueueListProps { + queueLoading: boolean; + queueEntries: QueueEntry[]; + dequeueBranches: UseMutationResult; +} + +function MergeQueueList({ + queueLoading, + queueEntries, + dequeueBranches, +}: MergeQueueListProps) { + if (queueLoading) { + return ( +
+ +
+ ); + } + if (queueEntries.length === 0) { + return ; + } + + const queueStacks = buildQueueStacks(queueEntries); + + return ( + // One continuous rail: stacks are groupings within a single merge sequence. +
+ + ); +} + +export interface MergeQueueTabProps { + isPro: boolean; + hasRemote: boolean; + queueEnabled: boolean | undefined; + queueLoading: boolean; + queueEntries: QueueEntry[]; + dequeueBranches: UseMutationResult; + onOpenSettings?: (tab?: string) => void; +} + +export function MergeQueueTab({ + isPro, + hasRemote, + queueEnabled, + queueLoading, + queueEntries, + dequeueBranches, + onOpenSettings, +}: MergeQueueTabProps) { + if (!isPro) return ; + if (!hasRemote) return null; + if (!queueEnabled) + return ; + + return ( + + ); +} diff --git a/src/hooks/useMergeQueueStatus.ts b/src/hooks/useMergeQueueStatus.ts index 6ef047ed..04f5acd1 100644 --- a/src/hooks/useMergeQueueStatus.ts +++ b/src/hooks/useMergeQueueStatus.ts @@ -1,8 +1,15 @@ -import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { getGitRemoteUrl, getPrInfoViaGh } from "../lib/api"; -import { supabase } from "../lib/supabase"; import type { PrInfo, WorkspaceQueueStatus } from "../lib/api-types"; +import { FEATURES } from "../lib/features"; +import { supabase } from "../lib/supabase"; + +/** Query key for the per-repo merge queue opt-in. */ +export const mergeQueueEnabledKey = (repoFullName: string | undefined) => [ + "merge-queue-enabled", + repoFullName, +]; export function useGitRemoteInfo(repoPath: string | undefined) { return useQuery({ @@ -27,11 +34,101 @@ export function usePrInfoViaGh( }); } +/** + * Whether the merge queue is switched on for this repo, as stored in Postgres. + * A repo with no config row has never opted in, so this resolves to false -- + * the user has to turn the queue on before anything can be enqueued. + */ +export function useMergeQueueEnabled(repoPath: string | undefined) { + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + + return useQuery({ + queryKey: mergeQueueEnabledKey(remoteInfo?.full_name), + queryFn: async () => { + const { data, error } = await supabase.rpc("get_merge_queue_enabled", { + p_repo_full_name: remoteInfo!.full_name, + }); + if (error) throw error; + return data === true; + }, + enabled: FEATURES.mergeQueue && !!remoteInfo, + staleTime: 60_000, + }); +} + +export function useSetMergeQueueEnabled(repoPath: string | undefined) { + const queryClient = useQueryClient(); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + + return useMutation({ + mutationFn: async (enabled: boolean) => { + if (!remoteInfo) throw new Error("No GitHub remote detected"); + const { error } = await supabase.rpc("set_merge_queue_enabled", { + p_repo_full_name: remoteInfo.full_name, + p_enabled: enabled, + }); + if (error) throw error; + return enabled; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: mergeQueueEnabledKey(remoteInfo?.full_name), + }); + }, + }); +} + +/** + * Remove one or more branches from the queue, used by the GitHub panel's queue + * list. Stacks are removed top-down so a branch is never left queued on top of + * a parent that has already gone. + */ +export function useDequeueBranches(repoPath: string | undefined) { + const queryClient = useQueryClient(); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + + return useMutation({ + mutationFn: async (branchNames: string[]) => { + if (!remoteInfo) throw new Error("No GitHub remote detected"); + const fullName = remoteInfo.full_name; + // Sequential top-down: never dequeue a branch before what's stacked above it. + await [...branchNames].reverse().reduce( + (prev, branchName) => + prev.then(async () => { + const { error } = await supabase.functions.invoke( + "enqueue-workspace", + { + body: { + repo_full_name: fullName, + branch_name: branchName, + action: "dequeue", + }, + }, + ); + if (error) throw error; + }), + Promise.resolve(), + ); + return branchNames; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["repo-branch-queue-statuses-panel", remoteInfo?.full_name], + }); + void queryClient.invalidateQueries({ + queryKey: ["repo-branch-queue-statuses", remoteInfo?.full_name], + }); + void queryClient.invalidateQueries({ queryKey: ["merge-queue-status"] }); + }, + }); +} + export function useMergeQueueStatus( repoPath: string | undefined, branchName: string | undefined, ) { const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: queueEnabled } = useMergeQueueEnabled(repoPath); return useQuery({ queryKey: ["merge-queue-status", remoteInfo?.full_name, branchName], @@ -44,7 +141,12 @@ export function useMergeQueueStatus( if (error) throw error; return (data as WorkspaceQueueStatus[] | null)?.[0] ?? null; }, - enabled: !!remoteInfo && !!branchName, + // Never poll when off, whether by the build flag or the per-repo opt-in. + enabled: + FEATURES.mergeQueue && + queueEnabled === true && + !!remoteInfo && + !!branchName, refetchInterval: 30_000, }); } @@ -59,11 +161,16 @@ export function useEnqueueWorkspace( repoPath, branchName, ); + const { data: queueEnabled } = useMergeQueueEnabled(repoPath); const mutate = useCallback( async (action: "enqueue" | "dequeue") => { if (!remoteInfo || !branchName) throw new Error("Repository or branch not detected"); + if (action === "enqueue" && !queueEnabled) + throw new Error( + "The merge queue is not enabled for this repository. Turn it on in the GitHub panel's Merge Queue tab.", + ); if (action === "enqueue" && prInfoGhError) throw prInfoGhError; if (prInfoGh !== undefined && prInfoGh !== null) { @@ -83,11 +190,27 @@ export function useEnqueueWorkspace( }); if (error) throw error; - await queryClient.invalidateQueries({ - queryKey: ["merge-queue-status", remoteInfo.full_name, branchName], - }); + // Refresh every view of the queue, not just this workspace's button. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["merge-queue-status", remoteInfo.full_name, branchName], + }), + queryClient.invalidateQueries({ + queryKey: ["repo-branch-queue-statuses", remoteInfo.full_name], + }), + queryClient.invalidateQueries({ + queryKey: ["repo-branch-queue-statuses-panel", remoteInfo.full_name], + }), + ]); }, - [remoteInfo, branchName, prInfoGh, prInfoGhError, queryClient], + [ + remoteInfo, + branchName, + prInfoGh, + prInfoGhError, + queueEnabled, + queryClient, + ], ); const enqueue = useMutation({ mutationFn: () => mutate("enqueue") }); diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 335e6adc..65d10b05 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -391,10 +391,11 @@ export interface PrInfo { is_draft?: boolean; } +/** Mirrors the merge_queue_entry_status enum in 003_merge_queue.sql. */ export type QueueEntryStatus = | "queued" | "testing" - | "passed" + | "merging" | "merged" | "failed" | "dequeued"; diff --git a/src/lib/merge-queue-stacks.test.ts b/src/lib/merge-queue-stacks.test.ts new file mode 100644 index 00000000..2f946849 --- /dev/null +++ b/src/lib/merge-queue-stacks.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { buildQueueStacks, type QueueEntry } from "./merge-queue-stacks"; + +interface EntryOptions { + branch: string; + target: string; + position: number; + overrides?: Partial; +} + +function entry({ + branch, + target, + position, + overrides = {}, +}: EntryOptions): QueueEntry { + return { + branch_name: branch, + pr_number: position, + status: "queued", + position, + target_branch: target, + ...overrides, + }; +} + +function branchNames(entries: QueueEntry[]): string[] { + return entries.map((e) => e.branch_name); +} + +function stackBranches( + stacks: ReturnType, +): string[][] { + return stacks.map((s) => branchNames(s.entries)); +} + +describe("buildQueueStacks", () => { + it("returns one single-entry stack per independent branch", () => { + const stacks = buildQueueStacks([ + entry({ branch: "feat/a", target: "main", position: 1 }), + entry({ branch: "feat/b", target: "main", position: 2 }), + ]); + + expect(stacks).toHaveLength(2); + expect(stackBranches(stacks)).toEqual([["feat/a"], ["feat/b"]]); + expect(stacks.every((s) => s.targetBranch === "main")).toBe(true); + }); + + it("chains branches stacked on each other bottom-up", () => { + const stacks = buildQueueStacks([ + entry({ branch: "feat/top", target: "feat/mid", position: 3 }), + entry({ branch: "feat/base", target: "main", position: 1 }), + entry({ branch: "feat/mid", target: "feat/base", position: 2 }), + ]); + + expect(stacks).toHaveLength(1); + expect(stacks[0].targetBranch).toBe("main"); + expect(branchNames(stacks[0].entries)).toEqual([ + "feat/base", + "feat/mid", + "feat/top", + ]); + }); + + it("keeps stacks and singletons in merge order", () => { + const stacks = buildQueueStacks([ + entry({ branch: "solo", target: "main", position: 4 }), + entry({ branch: "feat/base", target: "main", position: 1 }), + entry({ branch: "feat/top", target: "feat/base", position: 2 }), + ]); + + expect(stackBranches(stacks)).toEqual([ + ["feat/base", "feat/top"], + ["solo"], + ]); + }); + + it("never drops an entry that sits in a cycle", () => { + const stacks = buildQueueStacks([ + entry({ branch: "a", target: "b", position: 1 }), + entry({ branch: "b", target: "a", position: 2 }), + ]); + + const allBranches = stackBranches(stacks).flat(); + expect(allBranches.sort()).toEqual(["a", "b"]); + }); + + it("returns nothing for an empty queue", () => { + expect(buildQueueStacks([])).toEqual([]); + }); +}); diff --git a/src/lib/merge-queue-stacks.ts b/src/lib/merge-queue-stacks.ts new file mode 100644 index 00000000..7c7c6fe4 --- /dev/null +++ b/src/lib/merge-queue-stacks.ts @@ -0,0 +1,70 @@ +import type { QueueEntryStatus } from "./api-types"; + +export interface QueueEntry { + branch_name: string; + pr_number: number | null; + status: QueueEntryStatus; + position: number; + target_branch: string; +} + +export interface QueueStack { + /** Branch the whole stack ultimately lands on (never itself queued). */ + targetBranch: string; + /** Entries in the order the queue will merge them, bottom of stack first. */ + entries: QueueEntry[]; +} + +/** + * Group flat queue entries into stacks. + * + * A branch whose target is another queued branch is stacked on top of it, and + * the queue must land them bottom-up. Everything else is a stack of one. Within + * a stack entries are ordered by the chain itself, and stacks are ordered by + * their lowest queue position so the block that merges first renders first. + */ +export function buildQueueStacks(entries: readonly QueueEntry[]): QueueStack[] { + const byBranch = new Map(entries.map((entry) => [entry.branch_name, entry])); + const childrenOf = new Map(); + const roots: QueueEntry[] = []; + + for (const entry of entries) { + if (byBranch.has(entry.target_branch)) { + const siblings = childrenOf.get(entry.target_branch) ?? []; + siblings.push(entry); + childrenOf.set(entry.target_branch, siblings); + } else { + roots.push(entry); + } + } + + const claimed = new Set(); + const stacks: QueueStack[] = []; + + for (const root of roots.sort((a, b) => a.position - b.position)) { + const chain: QueueEntry[] = []; + let current: QueueEntry | undefined = root; + while (current && !claimed.has(current.branch_name)) { + claimed.add(current.branch_name); + chain.push(current); + // Lowest position wins if a branch is unexpectedly forked. + [current] = (childrenOf.get(current.branch_name) ?? []).sort( + (a, b) => a.position - b.position, + ); + } + stacks.push({ targetBranch: root.target_branch, entries: chain }); + } + + // A leftover sits in a cycle with no root; surface it rather than drop it. + for (const entry of entries) { + if (claimed.has(entry.branch_name)) continue; + claimed.add(entry.branch_name); + stacks.push({ targetBranch: entry.target_branch, entries: [entry] }); + } + + return stacks.sort( + (a, b) => + Math.min(...a.entries.map((e) => e.position)) - + Math.min(...b.entries.map((e) => e.position)), + ); +} diff --git a/supabase/migrations/007_merge_queue_enabled.sql b/supabase/migrations/007_merge_queue_enabled.sql new file mode 100644 index 00000000..b7d4b22d --- /dev/null +++ b/supabase/migrations/007_merge_queue_enabled.sql @@ -0,0 +1,113 @@ +-- Per-repo merge queue opt-in, read and written by the desktop app. +-- +-- merge_queue_configs already carries an `enabled` flag, but a repo that has +-- never been configured has no row at all. The desktop app treats "no row" as +-- OFF: the merge queue has to be explicitly turned on for a repo before any +-- branch can be enqueued. The column default stays true so that a config row +-- created by other tooling (webhooks, dashboard) keeps its previous meaning -- +-- these RPCs always write `enabled` explicitly. + +-- Is the merge queue turned on for this repo? +-- Returns false when the repo has no config row, is unknown to us, or is not +-- accessible to the calling user. +create or replace function public.get_merge_queue_enabled( + p_repo_full_name text +) +returns boolean +language sql +security definer +as $$ + select coalesce( + ( + select c.enabled + from public.merge_queue_configs c + join public.github_repositories r + on r.id = c.repo_id + join public.github_app_installations i + on i.id = r.installation_id + where r.full_name = p_repo_full_name + and c.target_branch = r.default_branch + and i.linked_user_id = auth.uid() + limit 1 + ), + false + ); +$$; + +-- Turn the merge queue on or off for a repo, creating the config row on first +-- enable. Raises when the repo is not one the caller can administer, so the +-- desktop app can surface "install the GitHub App first" rather than silently +-- no-op. +create or replace function public.set_merge_queue_enabled( + p_repo_full_name text, + p_enabled boolean +) +returns boolean +language plpgsql +security definer +as $$ +declare + v_repo_id bigint; + v_default_branch text; +begin + select r.id, coalesce(r.default_branch, 'main') + into v_repo_id, v_default_branch + from public.github_repositories r + join public.github_app_installations i + on i.id = r.installation_id + where r.full_name = p_repo_full_name + and i.linked_user_id = auth.uid() + limit 1; + + if v_repo_id is null then + raise exception 'Repository % is not linked to a GitHub App installation for this account', p_repo_full_name + using errcode = 'no_data_found'; + end if; + + insert into public.merge_queue_configs (repo_id, target_branch, enabled) + values (v_repo_id, v_default_branch, p_enabled) + on conflict (repo_id, target_branch) + do update set enabled = excluded.enabled, updated_at = now(); + + return p_enabled; +end; +$$; + +grant execute on function public.get_merge_queue_enabled(text) to authenticated; +grant execute on function public.set_merge_queue_enabled(text, boolean) to authenticated; + +-- The merge queue tab lists entries as pull requests, so the per-repo listing +-- RPC has to return the PR number alongside the branch. +drop function if exists public.get_repo_branch_queue_statuses(text); + +create function public.get_repo_branch_queue_statuses( + p_repo_full_name text +) +returns table ( + branch_name text, + pr_number int, + status text, + "position" int, + target_branch text +) +language sql +security definer +as $$ + select + e.branch_name, + e.pr_number, + e.status::text, + e.position, + q.target_branch + from public.merge_queue_entries e + join public.merge_queues q + on q.id = e.queue_id + join public.github_repositories r + on r.id = q.repo_id + join public.github_app_installations i + on i.id = r.installation_id + where r.full_name = p_repo_full_name + and e.status::text not in ('failed', 'dequeued') + and e.branch_name is not null + and i.linked_user_id = auth.uid(); +$$; diff --git a/test/integration/github-panel.test.tsx b/test/integration/github-panel.test.tsx index 20762362..287cc242 100644 --- a/test/integration/github-panel.test.tsx +++ b/test/integration/github-panel.test.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; -import { render, screen, waitFor, within } from "../test-utils"; import userEvent from "@testing-library/user-event"; +import * as React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubPanel } from "../../src/components/GitHubPanel"; import { IssueDetailPanel } from "../../src/components/github-panel/IssueDetail"; import { PrDetailPanel } from "../../src/components/github-panel/PrDetail"; +import { render, screen, waitFor, within } from "../test-utils"; const auth = vi.hoisted(() => ({ user: { id: "user-1" } as object | null, @@ -34,9 +34,27 @@ const api = vi.hoisted(() => ({ const supabaseRpc = vi.hoisted(() => vi.fn()); +const queueEnabled = vi.hoisted(() => ({ + current: true, + setEnabled: vi.fn(), + dequeue: vi.fn(), +})); + vi.mock("../../src/hooks/useAuth", () => ({ useAuth: () => auth })); vi.mock("../../src/hooks/useMergeQueueStatus", () => ({ useGitRemoteInfo: () => remoteInfo, + useMergeQueueEnabled: () => ({ + data: queueEnabled.current, + isLoading: false, + }), + useSetMergeQueueEnabled: () => ({ + mutateAsync: queueEnabled.setEnabled, + isPending: false, + }), + useDequeueBranches: () => ({ + mutate: queueEnabled.dequeue, + isPending: false, + }), })); vi.mock("../../src/lib/api", async (importOriginal) => { const original = await importOriginal(); @@ -98,6 +116,9 @@ describe("GitHubPanel", () => { beforeEach(() => { auth.subscription = null; + queueEnabled.current = true; + queueEnabled.setEnabled.mockReset(); + queueEnabled.dequeue.mockReset(); remoteInfo.data = { full_name: "acme/treq", owner: "acme", @@ -107,6 +128,7 @@ describe("GitHubPanel", () => { api.ghListIssues.mockResolvedValue({ items: [], hasMore: false }); api.ghListPrs.mockResolvedValue({ items: [], hasMore: false }); api.ghCreateIssueComment.mockReset(); + supabaseRpc.mockReset(); supabaseRpc.mockResolvedValue({ data: [], error: null }); user = userEvent.setup(); }); @@ -145,12 +167,14 @@ describe("GitHubPanel", () => { data: [ { branch_name: "feat/alpha", + pr_number: 11, status: "queued", position: 1, target_branch: "main", }, { branch_name: "feat/beta", + pr_number: 12, status: "testing", position: 2, target_branch: "main", @@ -166,12 +190,110 @@ describe("GitHubPanel", () => { expect(within(mergeQueueTab).queryByText("PRO")).not.toBeInTheDocument(); await user.click(mergeQueueTab); - expect(await screen.findByText("feat/alpha")).toBeVisible(); - expect(screen.getByText("feat/beta")).toBeVisible(); + expect(await screen.findByText("PR #11")).toBeVisible(); + expect(screen.getByText("PR #12")).toBeVisible(); + expect(screen.getByText("feat/alpha → main")).toBeVisible(); + expect(screen.getByText("feat/beta → main")).toBeVisible(); expect(screen.getByText(/queued/i)).toBeVisible(); expect(screen.getByText(/testing/i)).toBeVisible(); }); + it("hides the queue and points at Settings when the repo has it disabled", async () => { + auth.subscription = { plan: "pro", status: "active" }; + queueEnabled.current = false; + + render(); + await user.click(screen.getByRole("tab", { name: /merge queue/i })); + + expect( + await screen.findByText(/merge queue is off for this repository/i), + ).toBeVisible(); + expect( + screen.queryByRole("switch", { name: /enable merge queue/i }), + ).not.toBeInTheDocument(); + expect(supabaseRpc).not.toHaveBeenCalled(); + }); + + it("opens the integrations settings tab from the disabled queue state", async () => { + auth.subscription = { plan: "pro", status: "active" }; + queueEnabled.current = false; + const onOpenSettings = vi.fn(); + + render( + , + ); + await user.click(screen.getByRole("tab", { name: /merge queue/i })); + await user.click( + await screen.findByRole("button", { name: /enable it in settings/i }), + ); + + expect(onOpenSettings).toHaveBeenCalledWith("integrations"); + }); + + it("groups stacked branches into a block and removes them together", async () => { + auth.subscription = { plan: "pro", status: "active" }; + supabaseRpc.mockResolvedValue({ + data: [ + { + branch_name: "feat/base", + pr_number: 11, + status: "queued", + position: 1, + target_branch: "main", + }, + { + branch_name: "feat/top", + pr_number: 12, + status: "queued", + position: 2, + target_branch: "feat/base", + }, + ], + error: null, + }); + + render(); + await user.click(screen.getByRole("tab", { name: /merge queue/i })); + + expect(await screen.findByText("Stack of 2")).toBeVisible(); + expect(screen.getByText(/merges bottom-up into main/i)).toBeVisible(); + + await user.click( + screen.getByRole("button", { name: "Remove stack of 2 from queue" }), + ); + expect(queueEnabled.dequeue).toHaveBeenCalledWith([ + "feat/base", + "feat/top", + ]); + }); + + it("removes only the branch itself when it has nothing stacked on it", async () => { + auth.subscription = { plan: "pro", status: "active" }; + supabaseRpc.mockResolvedValue({ + data: [ + { + branch_name: "fix/solo", + pr_number: 11, + status: "queued", + position: 1, + target_branch: "main", + }, + ], + error: null, + }); + + render(); + await user.click(screen.getByRole("tab", { name: /merge queue/i })); + + await screen.findByText("PR #11"); + expect(screen.queryByText(/^Stack of/)).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Remove fix/solo from queue" }), + ); + expect(queueEnabled.dequeue).toHaveBeenCalledWith(["fix/solo"]); + }); + it("shows Load more when more issues are available", async () => { api.ghListIssues.mockResolvedValue({ items: [makeIssue(1), makeIssue(2)], diff --git a/test/integration/useMergeQueueStatus.test.ts b/test/integration/useMergeQueueStatus.test.ts index 1d326c2a..c2290c2f 100644 --- a/test/integration/useMergeQueueStatus.test.ts +++ b/test/integration/useMergeQueueStatus.test.ts @@ -1,22 +1,33 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; import fs from "node:fs"; import path from "node:path"; -import React from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import * as api from "../../src/lib/api"; +import { renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + useEnqueueWorkspace, useGitRemoteInfo, usePrInfoViaGh, - useEnqueueWorkspace, } from "../../src/hooks/useMergeQueueStatus"; +import * as api from "../../src/lib/api"; import type { PrInfo } from "../../src/lib/api-types"; import { createTestRepo } from "../utils"; -const { mockEdgeFn } = vi.hoisted(() => ({ mockEdgeFn: vi.fn() })); +const { mockEdgeFn, mockRpc, queueEnabled } = vi.hoisted(() => { + const queueEnabled = { current: true }; + return { + queueEnabled, + mockEdgeFn: vi.fn(), + mockRpc: vi.fn(async (fn: string) => + fn === "get_merge_queue_enabled" + ? { data: queueEnabled.current, error: null } + : { data: [], error: null }, + ), + }; +}); vi.mock("../../src/lib/supabase", () => ({ supabase: { - rpc: vi.fn().mockResolvedValue({ data: [], error: null }), + rpc: mockRpc, functions: { invoke: mockEdgeFn }, }, })); @@ -137,6 +148,7 @@ describe("useEnqueueWorkspace", () => { beforeEach(() => { mockEdgeFn.mockReset(); + queueEnabled.current = true; ghSpy = vi.spyOn(api, "getPrInfoViaGh"); }); @@ -252,4 +264,22 @@ describe("useEnqueueWorkspace", () => { ); expect(mockEdgeFn).not.toHaveBeenCalled(); }); + + it("refuses to enqueue when the repo has not enabled the merge queue", async () => { + const { repoPath } = createTestRepo(false); + addGitHubRemote(repoPath, "git@github.com:ziinc/treq.git"); + queueEnabled.current = false; + ghSpy.mockResolvedValue(OPEN_PR); + mockEdgeFn.mockResolvedValue({ error: null }); + + const { result } = renderHook(() => useEnqueueWorkspace(repoPath, "feat"), { + wrapper: makeWrapper(), + }); + + await waitFor(() => expect(result.current.remoteInfo).toBeTruthy()); + await expect(result.current.enqueue.mutateAsync()).rejects.toThrow( + /not enabled for this repository/i, + ); + expect(mockEdgeFn).not.toHaveBeenCalled(); + }); });