FN-6333: add legacy auto-merge cleanup surfaces

Expose operator controls for auditing and clearing legacy auto-merge stamps.

- Add CLI dry-run, apply, and JSON modes for legacy auto-merge stamp cleanup.
- Add dashboard maintenance endpoints and a Settings → Merge cleanup panel.
- Document the cleanup workflow and cover CLI, route, and UI behavior with tests.

Files changed:
 .changeset/fn-6333-legacy-automerge-cleanup.md     |   5 +
 docs/cli-reference.md                              |   4 +
 docs/dashboard-guide.md                            |   6 ++
 docs/settings-reference.md                         |   2 +-
 packages/cli/src/__tests__/bin.test.ts             |  10 ++
 .../cli/src/__tests__/pr-automerge-cleanup.test.ts | 107 ++++++++++++++++++++
 packages/cli/src/bin.ts                            |  14 ++-
 packages/cli/src/commands/pr.ts                    |  41 ++++++++
 .../components/settings/sections/MergeSection.tsx  | 109 ++++++++++++++++++++
 .../MergeSection.legacy-automerge-cleanup.test.tsx | 110 +++++++++++++++++++++
 .../legacy-automerge-stamps-routes.test.ts         |  76 ++++++++++++++
 packages/dashboard/src/routes.ts                   |  36 +++++++
 12 files changed, 517 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6333
Fusion-Task-Lineage: 55a3fa22-e0ba-4996-bfce-cad31c71177d
This commit is contained in:
gsxdsm
2026-06-13 06:12:09 -07:00
parent bffae81a98
commit 66591ec11c
12 changed files with 517 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup.

View File

@@ -591,6 +591,8 @@ Create a pull request for a task with `fn pr create <task-id>`.
Alias: `fn task pr-create <task-id>`
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 <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
```

View File

@@ -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:

View File

@@ -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"`. |

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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("");
}
}

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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 ─────────────────────────────────────────────────
/**