fix(FN-5633): guarantee the squash subject starts with the task id
ensureCommitTaskMetadata (was ensureTaskTrailersOnHead) now also prefixes the commit subject with `<taskId>: ` when includeTaskIdInCommit is on, in addition to ensuring the Fusion-Task-Id / lineage trailers — so the landed commit both starts with the task id and carries the board-association trailers even if the AI agent omits them. Single idempotent amend. Test: asserts the landed subject matches /^FN-1: / when the agent committed without a prefix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,9 +148,12 @@ describe("runAiMerge", () => {
|
||||
expect(mainAfter).not.toBe(mainBefore);
|
||||
// The squash landed the feature file.
|
||||
expect(existsSync(join(dir, "feature.txt"))).toBe(true);
|
||||
// The landed commit carries the board-association trailer even though the
|
||||
// (mock) merge agent committed without it — ensureTaskTrailersOnHead adds it.
|
||||
expect(git(dir, "log -1 --pretty=%B main")).toContain("Fusion-Task-Id: FN-1");
|
||||
// The landed commit carries the board-association trailer AND its subject
|
||||
// starts with the task id, even though the (mock) merge agent committed
|
||||
// "squash: feature" without either — ensureCommitTaskMetadata adds both.
|
||||
const landedMsg = git(dir, "log -1 --pretty=%B main");
|
||||
expect(landedMsg).toContain("Fusion-Task-Id: FN-1");
|
||||
expect(git(dir, "log -1 --pretty=%s main")).toMatch(/^FN-1: /);
|
||||
// Task moved to done + event emitted.
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
|
||||
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
|
||||
|
||||
@@ -92,16 +92,37 @@ function taskTrailers(taskId: string, lineageId?: string | null): string[] {
|
||||
return trailers;
|
||||
}
|
||||
|
||||
/** Idempotently guarantee the task trailers are on HEAD — a safety net so board
|
||||
* association holds even if the AI agent omitted them from its commit. */
|
||||
async function ensureTaskTrailersOnHead(mergeRoot: string, trailers: string[]): Promise<void> {
|
||||
const existing = await git(["log", "-1", "--pretty=%B"], mergeRoot).catch(() => "");
|
||||
const missing = trailers.filter((t) => !existing.includes(t));
|
||||
if (missing.length === 0) return;
|
||||
const args = ["-c", "trailer.ifExists=addIfDifferent", "commit", "--amend", "--no-edit"];
|
||||
for (const t of missing) args.push("--trailer", t);
|
||||
/** Idempotently guarantee the squash commit's task metadata — a safety net so
|
||||
* board association and the task-id prefix hold even if the AI agent omitted
|
||||
* them: the subject starts with `<taskId>:` (when includeTaskId) and the
|
||||
* association trailers are present. */
|
||||
async function ensureCommitTaskMetadata(
|
||||
mergeRoot: string,
|
||||
taskId: string,
|
||||
includeTaskId: boolean,
|
||||
trailers: string[],
|
||||
): Promise<void> {
|
||||
const fullMessage = await git(["log", "-1", "--pretty=%B"], mergeRoot).catch(() => "");
|
||||
if (!fullMessage) return;
|
||||
const subject = (fullMessage.split("\n")[0] ?? "").trim();
|
||||
const body = await git(["log", "-1", "--pretty=%b"], mergeRoot).catch(() => "");
|
||||
|
||||
const needsPrefix = includeTaskId && !subject.toLowerCase().startsWith(taskId.toLowerCase());
|
||||
const missingTrailers = trailers.filter((t) => !fullMessage.includes(t));
|
||||
if (!needsPrefix && missingTrailers.length === 0) return;
|
||||
|
||||
const args = ["-c", "trailer.ifExists=addIfDifferent", "commit", "--amend"];
|
||||
if (needsPrefix) {
|
||||
// Rewrite the message with the task-id-prefixed subject (body, which already
|
||||
// carries any existing trailers, is preserved verbatim).
|
||||
args.push("-m", `${taskId}: ${subject}`);
|
||||
if (body.trim()) args.push("-m", body);
|
||||
} else {
|
||||
args.push("--no-edit");
|
||||
}
|
||||
for (const t of missingTrailers) args.push("--trailer", t);
|
||||
await git(args, mergeRoot).catch((err: unknown) => {
|
||||
aiMergeLog.warn(`failed to amend task trailers onto squash (${err instanceof Error ? err.message : String(err)})`);
|
||||
aiMergeLog.warn(`failed to amend task metadata onto squash (${err instanceof Error ? err.message : String(err)})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,9 +814,10 @@ async function mergeAndReview(input: {
|
||||
let head = await git(["rev-parse", "HEAD"], mergeRoot);
|
||||
if (head === tipSha) return null; // empty merge — nothing landed
|
||||
|
||||
// Guarantee the board-association trailers are on the squash even if the
|
||||
// agent omitted them — this amends HEAD, so re-read the sha afterwards.
|
||||
await ensureTaskTrailersOnHead(mergeRoot, trailers);
|
||||
// Guarantee the squash's task metadata (task-id subject prefix + board
|
||||
// association trailers) even if the agent omitted it — this amends HEAD, so
|
||||
// re-read the sha afterwards.
|
||||
await ensureCommitTaskMetadata(mergeRoot, taskId, includeTaskId, trailers);
|
||||
head = await git(["rev-parse", "HEAD"], mergeRoot);
|
||||
|
||||
await setStatus("reviewing");
|
||||
|
||||
Reference in New Issue
Block a user