diff --git a/.changeset/fn-6333-legacy-automerge-cleanup.md b/.changeset/fn-6333-legacy-automerge-cleanup.md new file mode 100644 index 0000000000..edd4eda5e7 --- /dev/null +++ b/.changeset/fn-6333-legacy-automerge-cleanup.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d55bcb3ac..a13999123f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -591,6 +591,8 @@ Create a pull request for a task with `fn pr create `. Alias: `fn task pr-create ` +Maintenance: `fn pr automerge-cleanup` performs a dry run of legacy auto-merge stamps left by older `in-review` task behavior and prints affected task IDs/columns. Add `--apply` to clear those stamps after reviewing the list, and `--json` for machine-readable output. + Flags: - `--title `: Set the PR title. - `--base <branch>`: Target base branch (default from repo/CLI settings). @@ -605,6 +607,8 @@ Default behavior: PR title/body are AI-generated unless both `--title` and `--bo fn pr create FN-001 fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main fn task pr-create FN-001 --title "Fix login race" --body "Prevents duplicate session refresh." --base main +fn pr automerge-cleanup --json +fn pr automerge-cleanup --apply fn task import owner/repo --labels bug --limit 10 fn task import owner/repo --interactive ``` diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 16017e5cb3..5a761b3f84 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -710,6 +710,12 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - For shared `branch_groups` (tasks with `branchContext.groupId`), PR merge mode opens and tracks one group-level PR from the group integration branch to the project default branch; member tasks share that PR state. - In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs. +### Legacy auto-merge stamp cleanup + +Settings → Merge includes **Legacy auto-merge stamp cleanup** for operators auditing tasks that inherited historical in-review `autoMerge` stamps. The panel loads a dry-run candidate list, shows task IDs and current columns, and only reveals the destructive **Clear legacy stamps** action when candidates exist. Applying the cleanup requires the browser confirmation prompt, calls the maintenance apply endpoint, and then refreshes the dry-run list so cleared tasks disappear. + +Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows before relying on per-task auto-merge overrides. It only targets stamps tagged as legacy provenance; explicit user overrides remain intact. + ### Identifying high-impact blockers Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact: diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fb287dadc0..1bf5bc522c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -305,7 +305,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | -| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | +| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 0547525ce6..a5d9af16d9 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -47,6 +47,7 @@ const commandMocks = vi.hoisted(() => ({ runPrMerge: vi.fn(), runPrClose: vi.fn(), runPrAutomerge: vi.fn(), + runPrAutomergeCleanup: vi.fn(), runSettingsShow: vi.fn(), runSettingsSet: vi.fn(), @@ -194,6 +195,7 @@ vi.mock("../commands/pr.js", () => ({ runPrMerge: commandMocks.runPrMerge, runPrClose: commandMocks.runPrClose, runPrAutomerge: commandMocks.runPrAutomerge, + runPrAutomergeCleanup: commandMocks.runPrAutomergeCleanup, })); vi.mock("../commands/settings.js", () => ({ @@ -923,6 +925,14 @@ describe("bin command routing and fallbacks", () => { expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Try: fn pr create <task-id>")); }); + it("routes pr automerge-cleanup flags", async () => { + await runBin(["pr", "automerge-cleanup", "--apply", "--json", "--project", "ops"]); + expect(commandMocks.runPrAutomergeCleanup).toHaveBeenCalledWith( + { apply: true, json: true }, + "ops", + ); + }); + it("routes task delete with allow-resurrection flag", async () => { await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]); expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined); diff --git a/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts b/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts new file mode 100644 index 0000000000..e0277908f4 --- /dev/null +++ b/packages/cli/src/__tests__/pr-automerge-cleanup.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../project-context.js", () => ({ + resolveProject: vi.fn(), +})); + +vi.mock("@fusion/engine", () => ({ + releaseHeldTaskByEvent: vi.fn(), +})); + +vi.mock("@fusion/dashboard", () => ({ + GitHubClient: class {}, + generatePrMetadata: vi.fn(), +})); + +vi.mock("@fusion/core/gh-cli", () => ({ + classifyGhError: vi.fn(() => ({ message: "err" })), + getGhErrorMessage: vi.fn(() => "err"), + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + isGhAuthenticated: vi.fn(() => true), + isGhAvailable: vi.fn(() => true), +})); + +const { resolveProject } = await import("../project-context.js"); +const { runPrAutomergeCleanup } = await import("../commands/pr.js"); + +function mockStore(results: Array<{ taskId: string; column: string; cleared: boolean }>) { + const reconcileLegacyAutoMergeStamps = vi.fn().mockResolvedValue(results); + vi.mocked(resolveProject).mockResolvedValue({ + store: { reconcileLegacyAutoMergeStamps } as never, + projectPath: "/tmp/project", + projectName: "proj", + } as never); + return { reconcileLegacyAutoMergeStamps }; +} + +describe("fn pr automerge-cleanup", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("dry-runs by default and lists store-provided candidates", async () => { + const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]); + + await runPrAutomergeCleanup(); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("candidate")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("FN-101")); + }); + + it("passes apply only when --apply is requested", async () => { + const store = mockStore([{ taskId: "FN-101", column: "in-review", cleared: true }]); + + await runPrAutomergeCleanup({ apply: true }); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Cleared 1 legacy auto-merge stamp")); + }); + + it("prints well-formed JSON for non-empty dry-run results", async () => { + mockStore([{ taskId: "FN-101", column: "in-review", cleared: false }]); + + await runPrAutomergeCleanup({ json: true }); + + const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as { + mode: string; + count: number; + candidates: Array<{ taskId: string; column: string; cleared: boolean }>; + }; + expect(payload).toEqual({ + mode: "dry-run", + count: 1, + candidates: [{ taskId: "FN-101", column: "in-review", cleared: false }], + }); + }); + + it("prints well-formed JSON for empty apply results", async () => { + const store = mockStore([]); + + await runPrAutomergeCleanup({ apply: true, json: true }); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + const payload = JSON.parse(vi.mocked(console.log).mock.calls[0]?.[0] as string) as { + mode: string; + count: number; + cleared: unknown[]; + }; + expect(payload).toEqual({ mode: "apply", count: 0, cleared: [] }); + }); + + it("zero candidates is a successful no-op message", async () => { + const store = mockStore([]); + + await runPrAutomergeCleanup(); + + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("No legacy auto-merge stamps to clean up")); + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a110b8dd35..024bc1977a 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -120,7 +120,7 @@ async function loadCommandHandlers() { const { runDaemon } = await import("./commands/daemon.js"); const { runDesktop } = await import("./commands/desktop.js"); const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskDeps, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode } = await import("./commands/task.js"); - const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge } = await import("./commands/pr.js"); + const { runPrCreate, runPrShow, runPrList, runPrRespond, runPrApprove, runPrRetry, runPrMerge, runPrClose, runPrAutomerge, runPrAutomergeCleanup } = await import("./commands/pr.js"); const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); @@ -186,6 +186,7 @@ async function loadCommandHandlers() { runPrMerge, runPrClose, runPrAutomerge, + runPrAutomergeCleanup, runSettingsShow, runSettingsSet, runSettingsExport, @@ -331,6 +332,8 @@ PR: fn pr merge <pr-id> Force-merge the PR via its merge release fn pr close <pr-id> Close the PR terminally fn pr automerge <pr-id> [on|off] Toggle auto-merge for the PR + fn pr automerge-cleanup [--apply] [--json] + Dry-run or apply legacy auto-merge stamp cleanup fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json] Create and optionally wait for a cited-research run (search/fetch/synthesis) fn research list | ls [--status <status>] [--limit <n>] [--json] @@ -667,6 +670,7 @@ async function main() { runPrMerge, runPrClose, runPrAutomerge, + runPrAutomergeCleanup, runSettingsShow, runSettingsSet, runSettingsExport, @@ -901,9 +905,15 @@ async function main() { await runPrAutomerge(args[2], enabled, projectName); break; } + case "automerge-cleanup": + await runPrAutomergeCleanup({ + apply: args.includes("--apply"), + json: args.includes("--json"), + }, projectName); + break; default: console.error(`Unknown subcommand: pr ${subcommand || ""}`); - console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off]"); + console.error("Try: fn pr create <task-id> | list | show <id> | approve <id> | respond <id> | retry <id> | merge <id> | close <id> | automerge <id> [on|off] | automerge-cleanup [--apply] [--json]"); process.exit(1); } break; diff --git a/packages/cli/src/commands/pr.ts b/packages/cli/src/commands/pr.ts index f7c8aefa1f..e15be8cf37 100644 --- a/packages/cli/src/commands/pr.ts +++ b/packages/cli/src/commands/pr.ts @@ -374,3 +374,44 @@ export async function runPrAutomerge(id: string, enabled: boolean | undefined, p const updated = store.updatePrEntity(id, { autoMerge: next }); console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`); } + +export interface PrAutomergeCleanupOptions { + apply?: boolean; + json?: boolean; +} + +export async function runPrAutomergeCleanup(options: PrAutomergeCleanupOptions = {}, projectName?: string) { + const { store } = await getPrContext(projectName); + const results = options.apply + ? await store.reconcileLegacyAutoMergeStamps({ apply: true }) + : await store.reconcileLegacyAutoMergeStamps(); + + if (options.json) { + console.log(JSON.stringify({ + mode: options.apply ? "apply" : "dry-run", + count: results.length, + candidates: options.apply ? undefined : results, + cleared: options.apply ? results : undefined, + }, null, 2)); + return; + } + + if (results.length === 0) { + console.log("\n ✓ No legacy auto-merge stamps to clean up.\n"); + return; + } + + if (options.apply) { + console.log(`\n ✓ Cleared ${results.length} legacy auto-merge stamp${results.length === 1 ? "" : "s"}:`); + } else { + console.log(`\n Legacy auto-merge stamp candidate${results.length === 1 ? "" : "s"} (${results.length}):`); + } + for (const result of results) { + console.log(` - ${result.taskId} (${result.column})`); + } + if (!options.apply) { + console.log("\n Re-run with --apply to clear these legacy non-override stamps. Genuine per-task overrides are preserved.\n"); + } else { + console.log(""); + } +} diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx index 2372c474b4..58e06f0620 100644 --- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx +++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx @@ -11,11 +11,35 @@ * original inline JSX. */ import type { ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Settings } from "@fusion/core"; import { MovedSettingsStub } from "./MovedSettingsStub"; import type { SectionBaseProps } from "./context"; +interface LegacyAutoMergeStampCandidate { + taskId: string; + column: string; + cleared: boolean; +} + +interface LegacyAutoMergeStampListResponse { + candidates: LegacyAutoMergeStampCandidate[]; + count: number; +} + +interface LegacyAutoMergeStampApplyResponse { + cleared: LegacyAutoMergeStampCandidate[]; + count: number; +} + +async function readLegacyAutoMergeStampResponse(response: Response): Promise<LegacyAutoMergeStampListResponse> { + if (!response.ok) { + throw new Error(await response.text() || "Failed to load legacy auto-merge stamps"); + } + return response.json() as Promise<LegacyAutoMergeStampListResponse>; +} + export interface MergeSectionProps extends SectionBaseProps { scopeBanner: ReactNode; integrationBranchOptions: string[]; @@ -34,6 +58,54 @@ export function MergeSection({ onOpenWorkflowSettings, }: MergeSectionProps) { const { t } = useTranslation("app"); + const [legacyStampCandidates, setLegacyStampCandidates] = useState<LegacyAutoMergeStampCandidate[]>([]); + const [legacyStampLoading, setLegacyStampLoading] = useState(true); + const [legacyStampApplying, setLegacyStampApplying] = useState(false); + const [legacyStampError, setLegacyStampError] = useState<string | null>(null); + const [legacyStampSuccess, setLegacyStampSuccess] = useState<string | null>(null); + + const loadLegacyAutoMergeStamps = useCallback(async () => { + setLegacyStampLoading(true); + setLegacyStampError(null); + try { + const data = await readLegacyAutoMergeStampResponse( + await fetch("/api/maintenance/legacy-automerge-stamps"), + ); + setLegacyStampCandidates(Array.isArray(data.candidates) ? data.candidates : []); + } catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to load legacy auto-merge stamps"); + } finally { + setLegacyStampLoading(false); + } + }, []); + + useEffect(() => { + void loadLegacyAutoMergeStamps(); + }, [loadLegacyAutoMergeStamps]); + + const applyLegacyAutoMergeStampCleanup = async () => { + const confirmed = window.confirm( + "Apply cleanup for legacy auto-merge stamps? This clears only legacy non-override in-review stamps returned by the store and never touches genuine per-task overrides.", + ); + if (!confirmed) return; + setLegacyStampApplying(true); + setLegacyStampError(null); + setLegacyStampSuccess(null); + try { + const response = await fetch("/api/maintenance/legacy-automerge-stamps/apply", { method: "POST" }); + if (!response.ok) { + throw new Error(await response.text() || "Failed to apply legacy auto-merge stamp cleanup"); + } + const data = await response.json() as LegacyAutoMergeStampApplyResponse; + setLegacyStampSuccess(`Cleared ${data.count} legacy auto-merge stamp${data.count === 1 ? "" : "s"}.`); + await loadLegacyAutoMergeStamps(); + } catch (err) { + setLegacyStampError(err instanceof Error ? err.message : "Failed to apply legacy auto-merge stamp cleanup"); + } finally { + setLegacyStampApplying(false); + } + }; + return ( <> {scopeBanner} @@ -55,6 +127,43 @@ export function MergeSection({ <small>When enabled, tasks that pass review are automatically merged into the main branch</small> </details> </div> + <div className="form-group" data-testid="legacy-automerge-stamp-cleanup-panel"> + <h5 className="settings-section-heading">Legacy auto-merge stamp cleanup</h5> + <small> + Finds in-review tasks whose auto-merge value came from the legacy review-entry stamp. + Dry-run is automatic; applying delegates to the store cleanup and preserves genuine + per-task overrides. + </small> + {legacyStampLoading ? ( + <small aria-live="polite">Checking for legacy auto-merge stamps…</small> + ) : legacyStampCandidates.length === 0 ? ( + <small data-testid="legacy-automerge-stamp-empty-state"> + No legacy auto-merge stamps to clean up. + </small> + ) : ( + <> + <small>{legacyStampCandidates.length} legacy auto-merge stamp{legacyStampCandidates.length === 1 ? "" : "s"} ready to clean up.</small> + <ul> + {legacyStampCandidates.map((candidate) => ( + <li key={candidate.taskId} data-testid="legacy-automerge-stamp-candidate-row"> + <strong>{candidate.taskId}</strong> — {candidate.column} + </li> + ))} + </ul> + <button + type="button" + className="btn" + onClick={applyLegacyAutoMergeStampCleanup} + disabled={legacyStampApplying} + data-testid="legacy-automerge-stamp-apply-button" + > + {legacyStampApplying ? "Applying cleanup…" : "Apply cleanup"} + </button> + </> + )} + {legacyStampSuccess ? <small className="settings-success" aria-live="polite">{legacyStampSuccess}</small> : null} + {legacyStampError ? <small className="settings-error" role="alert">{legacyStampError}</small> : null} + </div> <div className="form-group"> <label htmlFor="mergerMode">AI merge</label> <select diff --git a/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx new file mode 100644 index 0000000000..25c8e71bc3 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/MergeSection.legacy-automerge-cleanup.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MergeSection } from "../MergeSection"; +import type { MergeSectionProps } from "../MergeSection"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }), +})); + +function jsonResponse(body: unknown, ok = true): Response { + return { + ok, + json: async () => body, + text: async () => typeof body === "string" ? body : JSON.stringify(body), + } as Response; +} + +function makeProps(): MergeSectionProps { + return { + scopeBanner: null, + form: { + autoMerge: true, + merger: { mode: "ai" }, + testMode: false, + mergeStrategy: "direct", + } as MergeSectionProps["form"], + setForm: vi.fn(), + integrationBranchOptions: ["main"], + integrationBranchCustomMode: false, + setIntegrationBranchCustomMode: vi.fn(), + }; +} + +describe("MergeSection legacy auto-merge stamp cleanup", () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.innerWidth = 1024; + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders the store-provided candidate list without client-side filtering", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + candidates: [ + { taskId: "FN-101", column: "in-review", cleared: false }, + { taskId: "FN-USER", column: "in-review", cleared: false }, + ], + count: 2, + })); + vi.stubGlobal("fetch", fetchMock); + + render(<MergeSection {...makeProps()} />); + + await waitFor(() => expect(screen.getByText("FN-101")).toBeInTheDocument()); + expect(screen.getByText("FN-USER")).toBeInTheDocument(); + expect(screen.getAllByTestId("legacy-automerge-stamp-candidate-row")).toHaveLength(2); + expect(screen.getByTestId("legacy-automerge-stamp-apply-button")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith("/api/maintenance/legacy-automerge-stamps"); + }); + + it("renders an explicit empty state and no apply shell when there are zero candidates", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ candidates: [], count: 0 }))); + + render(<MergeSection {...makeProps()} />); + + expect(await screen.findByTestId("legacy-automerge-stamp-empty-state")).toHaveTextContent( + "No legacy auto-merge stamps to clean up.", + ); + expect(screen.queryByTestId("legacy-automerge-stamp-apply-button")).not.toBeInTheDocument(); + }); + + it("requires confirmation, posts apply, and re-fetches to the empty state", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + candidates: [{ taskId: "FN-101", column: "in-review", cleared: false }], + count: 1, + })) + .mockResolvedValueOnce(jsonResponse({ + cleared: [{ taskId: "FN-101", column: "in-review", cleared: true }], + count: 1, + })) + .mockResolvedValueOnce(jsonResponse({ candidates: [], count: 0 })); + vi.stubGlobal("fetch", fetchMock); + + render(<MergeSection {...makeProps()} />); + + fireEvent.click(await screen.findByTestId("legacy-automerge-stamp-apply-button")); + + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("never touches genuine per-task overrides")); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + "/api/maintenance/legacy-automerge-stamps/apply", + { method: "POST" }, + )); + expect(await screen.findByTestId("legacy-automerge-stamp-empty-state")).toBeInTheDocument(); + }); + + it("is operable at a narrow mobile width", async () => { + window.innerWidth = 390; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ + candidates: [{ taskId: "FN-MOBILE", column: "in-review", cleared: false }], + count: 1, + }))); + + render(<MergeSection {...makeProps()} />); + + expect(await screen.findByText("FN-MOBILE")).toBeInTheDocument(); + const applyButton = screen.getByTestId("legacy-automerge-stamp-apply-button"); + expect(applyButton.tagName).toBe("BUTTON"); + expect(applyButton).toHaveTextContent("Apply cleanup"); + }); +}); diff --git a/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts b/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts new file mode 100644 index 0000000000..c09b5b61c8 --- /dev/null +++ b/packages/dashboard/src/__tests__/legacy-automerge-stamps-routes.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import type { TaskStore } from "@fusion/core"; +import { createServer } from "../server.js"; +import { request as performRequest } from "../test-request.js"; + +function createStore(results: Array<{ taskId: string; column: string; cleared: boolean }> = []): TaskStore { + return { + reconcileLegacyAutoMergeStamps: vi.fn().mockResolvedValue(results), + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({}), + getRootDir: vi.fn().mockReturnValue("/tmp/project"), + getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"), + listTasks: vi.fn().mockResolvedValue([]), + getAgentLogs: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + getDatabase: vi.fn().mockReturnValue({ + exec: vi.fn(), + prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }), + }), + getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }), + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore; +} + +describe("legacy auto-merge stamp maintenance routes", () => { + it("GET returns dry-run candidates without apply", async () => { + const candidates = [{ taskId: "FN-101", column: "in-review", cleared: false }]; + const store = createStore(candidates); + const app = createServer(store); + + const response = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ candidates, count: 1 }); + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith(); + }); + + it("POST delegates apply to the store API and returns cleared count", async () => { + const cleared = [{ taskId: "FN-101", column: "in-review", cleared: true }]; + const store = createStore(cleared); + const app = createServer(store); + + const response = await performRequest(app, "POST", "/api/maintenance/legacy-automerge-stamps/apply"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ cleared, count: 1 }); + expect(store.reconcileLegacyAutoMergeStamps).toHaveBeenCalledWith({ apply: true }); + }); + + it("handles zero-candidate dry-run and apply as clean no-ops", async () => { + const store = createStore([]); + const app = createServer(store); + + const dryRun = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + const applied = await performRequest(app, "POST", "/api/maintenance/legacy-automerge-stamps/apply"); + + expect(dryRun.status).toBe(200); + expect(dryRun.body).toEqual({ candidates: [], count: 0 }); + expect(applied.status).toBe(200); + expect(applied.body).toEqual({ cleared: [], count: 0 }); + }); + + it("maps store errors through the API error handler", async () => { + const store = createStore(); + vi.mocked(store.reconcileLegacyAutoMergeStamps).mockRejectedValue(new Error("store unavailable")); + const app = createServer(store); + + const response = await performRequest(app, "GET", "/api/maintenance/legacy-automerge-stamps"); + + expect(response.status).toBe(500); + expect(response.body.error).toContain("store unavailable"); + }); +}); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 361d12325e..f5e9ca011c 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -1638,6 +1638,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); + // ── Maintenance Routes ───────────────────────────────────────────── + + /** + * GET /api/maintenance/legacy-automerge-stamps + * Dry-run the legacy auto-merge stamp cleanup and list candidates. + */ + router.get("/maintenance/legacy-automerge-stamps", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const candidates = await scopedStore.reconcileLegacyAutoMergeStamps(); + res.json({ candidates, count: candidates.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to list legacy auto-merge stamps"); + } + }); + + /** + * POST /api/maintenance/legacy-automerge-stamps/apply + * Apply the legacy auto-merge stamp cleanup via the store-owned reconcile API. + */ + router.post("/maintenance/legacy-automerge-stamps/apply", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const cleared = await scopedStore.reconcileLegacyAutoMergeStamps({ apply: true }); + res.json({ cleared, count: cleared.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to apply legacy auto-merge stamp cleanup"); + } + }); + // ── Backup Routes ───────────────────────────────────────────────── /**