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:
5
.changeset/fn-6333-legacy-automerge-cleanup.md
Normal file
5
.changeset/fn-6333-legacy-automerge-cleanup.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add dashboard and CLI operator surfaces to inspect and apply legacy auto-merge stamp cleanup.
|
||||||
@@ -591,6 +591,8 @@ Create a pull request for a task with `fn pr create <task-id>`.
|
|||||||
|
|
||||||
Alias: `fn task 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:
|
Flags:
|
||||||
- `--title <title>`: Set the PR title.
|
- `--title <title>`: Set the PR title.
|
||||||
- `--base <branch>`: Target base branch (default from repo/CLI settings).
|
- `--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
|
||||||
fn pr create FN-001 --draft --reviewer octocat --reviewer hubot --base main
|
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 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 --labels bug --limit 10
|
||||||
fn task import owner/repo --interactive
|
fn task import owner/repo --interactive
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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
|
### 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:
|
Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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). |
|
| `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"`. |
|
| `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"`. |
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const commandMocks = vi.hoisted(() => ({
|
|||||||
runPrMerge: vi.fn(),
|
runPrMerge: vi.fn(),
|
||||||
runPrClose: vi.fn(),
|
runPrClose: vi.fn(),
|
||||||
runPrAutomerge: vi.fn(),
|
runPrAutomerge: vi.fn(),
|
||||||
|
runPrAutomergeCleanup: vi.fn(),
|
||||||
|
|
||||||
runSettingsShow: vi.fn(),
|
runSettingsShow: vi.fn(),
|
||||||
runSettingsSet: vi.fn(),
|
runSettingsSet: vi.fn(),
|
||||||
@@ -194,6 +195,7 @@ vi.mock("../commands/pr.js", () => ({
|
|||||||
runPrMerge: commandMocks.runPrMerge,
|
runPrMerge: commandMocks.runPrMerge,
|
||||||
runPrClose: commandMocks.runPrClose,
|
runPrClose: commandMocks.runPrClose,
|
||||||
runPrAutomerge: commandMocks.runPrAutomerge,
|
runPrAutomerge: commandMocks.runPrAutomerge,
|
||||||
|
runPrAutomergeCleanup: commandMocks.runPrAutomergeCleanup,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/settings.js", () => ({
|
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>"));
|
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 () => {
|
it("routes task delete with allow-resurrection flag", async () => {
|
||||||
await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]);
|
await runBin(["task", "delete", "FN-1", "--force", "--allow-resurrection"]);
|
||||||
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined);
|
expect(commandMocks.runTaskDelete).toHaveBeenCalledWith("FN-1", true, true, undefined);
|
||||||
|
|||||||
107
packages/cli/src/__tests__/pr-automerge-cleanup.test.ts
Normal file
107
packages/cli/src/__tests__/pr-automerge-cleanup.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,7 +120,7 @@ async function loadCommandHandlers() {
|
|||||||
const { runDaemon } = await import("./commands/daemon.js");
|
const { runDaemon } = await import("./commands/daemon.js");
|
||||||
const { runDesktop } = await import("./commands/desktop.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 { 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 { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||||
const { runSettingsImport } = await import("./commands/settings-import.js");
|
const { runSettingsImport } = await import("./commands/settings-import.js");
|
||||||
@@ -186,6 +186,7 @@ async function loadCommandHandlers() {
|
|||||||
runPrMerge,
|
runPrMerge,
|
||||||
runPrClose,
|
runPrClose,
|
||||||
runPrAutomerge,
|
runPrAutomerge,
|
||||||
|
runPrAutomergeCleanup,
|
||||||
runSettingsShow,
|
runSettingsShow,
|
||||||
runSettingsSet,
|
runSettingsSet,
|
||||||
runSettingsExport,
|
runSettingsExport,
|
||||||
@@ -331,6 +332,8 @@ PR:
|
|||||||
fn pr merge <pr-id> Force-merge the PR via its merge release
|
fn pr merge <pr-id> Force-merge the PR via its merge release
|
||||||
fn pr close <pr-id> Close the PR terminally
|
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 <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]
|
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
|
||||||
Create and optionally wait for a cited-research run (search/fetch/synthesis)
|
Create and optionally wait for a cited-research run (search/fetch/synthesis)
|
||||||
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
||||||
@@ -667,6 +670,7 @@ async function main() {
|
|||||||
runPrMerge,
|
runPrMerge,
|
||||||
runPrClose,
|
runPrClose,
|
||||||
runPrAutomerge,
|
runPrAutomerge,
|
||||||
|
runPrAutomergeCleanup,
|
||||||
runSettingsShow,
|
runSettingsShow,
|
||||||
runSettingsSet,
|
runSettingsSet,
|
||||||
runSettingsExport,
|
runSettingsExport,
|
||||||
@@ -901,9 +905,15 @@ async function main() {
|
|||||||
await runPrAutomerge(args[2], enabled, projectName);
|
await runPrAutomerge(args[2], enabled, projectName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "automerge-cleanup":
|
||||||
|
await runPrAutomergeCleanup({
|
||||||
|
apply: args.includes("--apply"),
|
||||||
|
json: args.includes("--json"),
|
||||||
|
}, projectName);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.error(`Unknown subcommand: pr ${subcommand || ""}`);
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -374,3 +374,44 @@ export async function runPrAutomerge(id: string, enabled: boolean | undefined, p
|
|||||||
const updated = store.updatePrEntity(id, { autoMerge: next });
|
const updated = store.updatePrEntity(id, { autoMerge: next });
|
||||||
console.log(`\n ✓ Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`);
|
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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,35 @@
|
|||||||
* original inline JSX.
|
* original inline JSX.
|
||||||
*/
|
*/
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Settings } from "@fusion/core";
|
import type { Settings } from "@fusion/core";
|
||||||
import { MovedSettingsStub } from "./MovedSettingsStub";
|
import { MovedSettingsStub } from "./MovedSettingsStub";
|
||||||
import type { SectionBaseProps } from "./context";
|
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 {
|
export interface MergeSectionProps extends SectionBaseProps {
|
||||||
scopeBanner: ReactNode;
|
scopeBanner: ReactNode;
|
||||||
integrationBranchOptions: string[];
|
integrationBranchOptions: string[];
|
||||||
@@ -34,6 +58,54 @@ export function MergeSection({
|
|||||||
onOpenWorkflowSettings,
|
onOpenWorkflowSettings,
|
||||||
}: MergeSectionProps) {
|
}: MergeSectionProps) {
|
||||||
const { t } = useTranslation("app");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{scopeBanner}
|
{scopeBanner}
|
||||||
@@ -55,6 +127,43 @@ export function MergeSection({
|
|||||||
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
|
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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">
|
<div className="form-group">
|
||||||
<label htmlFor="mergerMode">AI merge</label>
|
<label htmlFor="mergerMode">AI merge</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ─────────────────────────────────────────────────
|
// ── Backup Routes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user