feat(FN-3878): add restore-merge-sha script for commitSha restoration

Adds a new merge commitSha restoration script (`scripts/restore-merge-sha-fn-3878.mjs`) with test coverage to handle a specific FN-3878 merge case, along with a changeset for publishing and a minor plugin vitest config governance alignment across three plugins.

Fusion-Task-Id: FN-3878
This commit is contained in:
Fusion
2026-05-09 12:47:01 -07:00
committed by gsxdsm
parent 2278ceff31
commit 39f036c451
6 changed files with 353 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Restore canonical mergeDetails.commitSha for tasks FN-3794, FN-3814, FN-3829 whose attribution had been overwritten by self-healing reconciliation prior to the FN-3862 fix. Adds an idempotent restoration script (`scripts/restore-merge-sha-fn-3878.mjs`) for operators to re-verify or repair similar drift.

View File

@@ -0,0 +1,23 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const maxWorkers = computeMaxWorkers();
export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},
},
test: {
environment: "node",
include: ["src/__tests__/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
},
});

View File

@@ -1,14 +1,23 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const maxWorkers = computeMaxWorkers();
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
}, },
}, },
test: { test: {
environment: "node", environment: "node",
include: ["src/__tests__/**/*.test.ts"], include: ["src/__tests__/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
}, },
}); });

View File

@@ -1,14 +1,23 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
const maxWorkers = computeMaxWorkers();
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), "@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
}, },
}, },
test: { test: {
environment: "node", environment: "node",
include: ["src/__tests__/**/*.test.ts"], include: ["src/__tests__/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
}, },
}); });

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import { runRestoration } from "../restore-merge-sha-fn-3878.mjs";
function createStore(task) {
const state = { ...task, mergeDetails: { ...(task.mergeDetails ?? {}) } };
const calls = { updateTask: 0, logEntry: 0 };
return {
calls,
state,
async getTask(id) {
assert.equal(id, state.id);
return { ...state, mergeDetails: { ...state.mergeDetails } };
},
async updateTask(id, updates) {
assert.equal(id, state.id);
calls.updateTask += 1;
state.mergeDetails = { ...updates.mergeDetails };
return { ...state };
},
async logEntry(id, action, outcome) {
assert.equal(id, state.id);
assert.equal(action, "FN-3878 restore commitSha");
assert.match(outcome, /→/);
calls.logEntry += 1;
return { ...state };
},
};
}
function createGit({ ancestor = true, owned = true } = {}) {
return {
isAncestorOfMain() {
return ancestor;
},
getCommitSubject() {
return owned ? "fix(FN-3794): canonical" : "fix: unrelated";
},
getCommitBody() {
return owned ? "Body\n\nFusion-Task-Id: FN-3794" : "Body without trailer";
},
getCommitAuthorDateIso() {
return "2026-05-09T00:50:47-07:00";
},
getShortstat() {
return { filesChanged: 3, insertions: 20, deletions: 5 };
},
};
}
const restoration = [{ id: "FN-3794", canonicalSha: "7d20a348d82320bc57310169aaa2d3b3f0d5a946" }];
function baseTask() {
return {
id: "FN-3794",
column: "done",
mergeDetails: {
commitSha: "oldoldoldoldoldoldoldoldoldoldoldoldoldoldold1",
mergeConfirmed: true,
branch: "ignored",
resolutionStrategy: "ai",
resolutionMethod: "ai",
attemptsMade: 2,
autoResolvedCount: 4,
},
};
}
test("FN-3878: dry-run by default — no DB writes", async () => {
const store = createStore(baseTask());
const result = await runRestoration({
store,
git: createGit(),
restorations: restoration,
dryRun: true,
});
assert.equal(result.hadValidationErrors, false);
assert.equal(store.calls.updateTask, 0);
assert.equal(store.calls.logEntry, 0);
assert.equal(store.state.mergeDetails.commitSha, "oldoldoldoldoldoldoldoldoldoldoldoldoldoldold1");
assert.equal(result.results[0].action, "updated");
assert.equal(result.results[0].reason, "dry-run");
});
test("FN-3878: --apply rewrites commitSha and preserves mergeConfirmed + strategy metadata", async () => {
const store = createStore(baseTask());
await runRestoration({
store,
git: createGit(),
restorations: restoration,
dryRun: false,
});
assert.equal(store.calls.updateTask, 1);
assert.equal(store.calls.logEntry, 1);
assert.equal(store.state.mergeDetails.commitSha, restoration[0].canonicalSha);
assert.equal(store.state.mergeDetails.mergeCommitMessage, "fix(FN-3794): canonical");
assert.equal(store.state.mergeDetails.filesChanged, 3);
assert.equal(store.state.mergeDetails.insertions, 20);
assert.equal(store.state.mergeDetails.deletions, 5);
assert.equal(store.state.mergeDetails.mergedAt, "2026-05-09T00:50:47-07:00");
assert.equal(store.state.mergeDetails.mergeConfirmed, true);
assert.equal(store.state.mergeDetails.resolutionStrategy, "ai");
assert.equal(store.state.mergeDetails.resolutionMethod, "ai");
assert.equal(store.state.mergeDetails.attemptsMade, 2);
assert.equal(store.state.mergeDetails.autoResolvedCount, 4);
});
test("FN-3878: idempotent — re-running with --apply is a no-op", async () => {
const store = createStore(baseTask());
await runRestoration({ store, git: createGit(), restorations: restoration, dryRun: false });
const second = await runRestoration({ store, git: createGit(), restorations: restoration, dryRun: false });
assert.equal(store.calls.updateTask, 1);
assert.equal(second.results[0].action, "already-canonical");
});
test("FN-3878: refuses to update if canonical SHA is unreachable or untrailered", async () => {
const storeUnreachable = createStore(baseTask());
const unreachable = await runRestoration({
store: storeUnreachable,
git: createGit({ ancestor: false }),
restorations: restoration,
dryRun: false,
});
assert.equal(unreachable.hadValidationErrors, true);
assert.equal(storeUnreachable.calls.updateTask, 0);
const storeUnowned = createStore(baseTask());
const unowned = await runRestoration({
store: storeUnowned,
git: createGit({ owned: false }),
restorations: restoration,
dryRun: false,
});
assert.equal(unowned.hadValidationErrors, true);
assert.equal(storeUnowned.calls.updateTask, 0);
});
test("FN-3878: refuses non-done or mergeConfirmed!==true tasks", async () => {
const notDone = createStore({ ...baseTask(), column: "in-review" });
const notDoneResult = await runRestoration({ store: notDone, git: createGit(), restorations: restoration, dryRun: false });
assert.equal(notDone.calls.updateTask, 0);
assert.equal(notDoneResult.results[0].reason, "task-not-done");
const unconfirmed = createStore({ ...baseTask(), mergeDetails: { ...baseTask().mergeDetails, mergeConfirmed: false } });
const unconfirmedResult = await runRestoration({ store: unconfirmed, git: createGit(), restorations: restoration, dryRun: false });
assert.equal(unconfirmed.calls.updateTask, 0);
assert.equal(unconfirmedResult.results[0].reason, "merge-not-confirmed");
});

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import process from "node:process";
export const RESTORATIONS = [
{ id: "FN-3794", canonicalSha: "7d20a348d82320bc57310169aaa2d3b3f0d5a946" },
{ id: "FN-3814", canonicalSha: "8a7038c9e8c692b0cce89f26260604c6672283bd" },
{ id: "FN-3829", canonicalSha: "1abbb106073df866147e24b48ac05deeaf7224a6" },
];
function parseShortstat(output) {
const normalized = String(output ?? "").trim().replace(/\n/g, " ");
const filesMatch = normalized.match(/(\d+) files? changed/);
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
return {
filesChanged: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0,
};
}
export function createGitHelpers(cwd = process.cwd()) {
function run(args) {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
return {
ok: result.status === 0,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
status: result.status ?? 1,
};
}
return {
isAncestorOfMain(sha) {
return run(["merge-base", "--is-ancestor", sha, "main"]).ok;
},
getCommitSubject(sha) {
const res = run(["log", "-1", "--format=%s", sha]);
return res.ok ? res.stdout.trim() : null;
},
getCommitBody(sha) {
const res = run(["log", "-1", "--format=%B", sha]);
return res.ok ? res.stdout : null;
},
getCommitAuthorDateIso(sha) {
const res = run(["log", "-1", "--format=%aI", sha]);
return res.ok ? res.stdout.trim() : null;
},
getShortstat(sha) {
const res = run(["show", "--shortstat", "--format=", sha]);
return res.ok ? parseShortstat(res.stdout) : null;
},
};
}
function commitOwnedByTask(taskId, subject, body) {
if (String(body ?? "").includes(`Fusion-Task-Id: ${taskId}`)) return true;
return new RegExp(`\\(${taskId}\\)`).test(String(subject ?? ""));
}
export async function runRestoration({ store, git, restorations = RESTORATIONS, dryRun = true }) {
const results = [];
let hadValidationErrors = false;
for (const { id, canonicalSha } of restorations) {
const task = await store.getTask(id);
const mergeDetails = task.mergeDetails ?? {};
if (task.column !== "done") {
results.push({ taskId: id, action: "skipped", reason: "task-not-done" });
continue;
}
if (mergeDetails.mergeConfirmed !== true) {
results.push({ taskId: id, action: "skipped", reason: "merge-not-confirmed" });
continue;
}
if (mergeDetails.commitSha === canonicalSha) {
results.push({ taskId: id, action: "already-canonical", reason: "commit-matches" });
continue;
}
if (!git.isAncestorOfMain(canonicalSha)) {
hadValidationErrors = true;
results.push({ taskId: id, action: "skipped", reason: "canonical-sha-not-on-main" });
continue;
}
const subject = git.getCommitSubject(canonicalSha);
const body = git.getCommitBody(canonicalSha);
if (!subject || !body || !commitOwnedByTask(id, subject, body)) {
hadValidationErrors = true;
results.push({ taskId: id, action: "skipped", reason: "canonical-sha-not-owned-by-task" });
continue;
}
const mergedAt = git.getCommitAuthorDateIso(canonicalSha);
const shortstat = git.getShortstat(canonicalSha);
if (!mergedAt || !shortstat) {
hadValidationErrors = true;
results.push({ taskId: id, action: "skipped", reason: "failed-to-derive-commit-metadata" });
continue;
}
const nextMergeDetails = {
...mergeDetails,
commitSha: canonicalSha,
mergeCommitMessage: subject,
filesChanged: shortstat.filesChanged,
insertions: shortstat.insertions,
deletions: shortstat.deletions,
mergedAt,
mergeConfirmed: true,
};
if (!dryRun) {
await store.updateTask(id, { mergeDetails: nextMergeDetails });
await store.logEntry(
id,
"FN-3878 restore commitSha",
`${String(mergeDetails.commitSha ?? "unknown").slice(0, 8)}${canonicalSha.slice(0, 8)}`,
);
results.push({ taskId: id, action: "updated", reason: "restored-canonical-sha" });
} else {
results.push({ taskId: id, action: "updated", reason: "dry-run" });
}
}
return { results, hadValidationErrors };
}
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const git = deps.git ?? createGitHelpers(process.cwd());
let store = deps.store;
if (!store) {
const { TaskStore } = await import("../packages/core/dist/index.js");
store = new TaskStore(process.cwd());
await store.init();
}
const output = await runRestoration({ store, git, dryRun });
console.log(JSON.stringify(output.results, null, 2));
return output.hadValidationErrors ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().then((code) => {
process.exitCode = code;
}).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}