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:
5
.changeset/fn-3878-restore-commitsha.md
Normal file
5
.changeset/fn-3878-restore-commitsha.md
Normal 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.
|
||||||
23
plugins/fusion-plugin-even-cards/vitest.config.ts
Normal file
23
plugins/fusion-plugin-even-cards/vitest.config.ts
Normal 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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
152
scripts/__tests__/restore-merge-sha-fn-3878.test.mjs
Normal file
152
scripts/__tests__/restore-merge-sha-fn-3878.test.mjs
Normal 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");
|
||||||
|
});
|
||||||
155
scripts/restore-merge-sha-fn-3878.mjs
Normal file
155
scripts/restore-merge-sha-fn-3878.mjs
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user