fix(FN-5090): subject-prefix fallback in trailer attribution

The contamination detector relied solely on the Fusion-Task-Id commit
trailer to attribute commits to a task. Agent-driven commits do not
currently get the trailer written (no commit-msg hook installs it), so
filterFilesToOwnTaskCommits saw 0 of N commits attributed even when
every commit subject was clearly feat(FN-XXXX): / fix(FN-XXXX): work
for that task. This drove attributedFileCount=0 reports, false-positive
task:worktree-contamination-detected events, and indefinite
foreign-only-contamination-skipped + completion-handoff-limbo loops
that stranded FN-5053, FN-5060, FN-5083 (manually merged) for hours.

Hotfix:
- extractAttributedTaskId now accepts both Fusion-Task-Id and a
  generic Task-Id trailer.
- New extractTaskIdFromSubject recognizes conventional-commit
  (feat(<id>):), bracketed ([<id>]) and legacy colon (<id>:) styles
  for any uppercase task prefix (FN, KB, RF, PROJ, JIRA, ...).
- filterFilesToOwnTaskCommits falls back to subject-derived
  attribution when the trailer is missing, with case-insensitive
  match against opts.taskId.

Project-agnostic by design: the subject regex matches any
[A-Z]+-\d+ pattern, so projects with non-FN taskPrefix are
covered automatically.

Two new tests cover the FN-5083 production repro and the
KB-/lowercase/legacy variants.

Fusion-Task-Id: FN-5090
This commit is contained in:
gsxdsm
2026-05-18 21:05:51 -07:00
parent 68b5694cba
commit 92be8225e4
2 changed files with 96 additions and 3 deletions

View File

@@ -92,6 +92,65 @@ describe("FN-5039 branch-attribution", () => {
).rejects.toBeInstanceOf(BranchAttributionError);
});
it("FN-5083 hotfix: falls back to conventional-commit subject when trailer is missing", async () => {
// Commit subjects use feat(FN-5039):, fix(FN-5039):, test(FN-5039): with EMPTY trailers —
// the exact pattern that stranded FN-5060/FN-5083/FN-5053 in production.
const log = [
"sha-a\x00feat(FN-5039): step 2\x00body without trailer\x1e",
"sha-b\x00fix(FN-5039): step 3 fixup\x00body without trailer\x1e",
"sha-c\x00test(FN-5039): step 4 coverage\x00body without trailer\x1e",
"sha-d\x00chore(FN-1111): foreign commit\x00body without trailer\x1e",
].join("");
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "a.ts\nb.ts\nc.ts\nd.ts\n" })
.mockResolvedValueOnce({ stdout: log })
.mockResolvedValueOnce({ stdout: "a.ts\n" })
.mockResolvedValueOnce({ stdout: "b.ts\n" })
.mockResolvedValueOnce({ stdout: "c.ts\n" });
const result = await filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
});
// Three own commits attributed by subject; one foreign attributed by subject.
expect(result.ownCommitCount).toBe(3);
expect(result.files).toEqual(["a.ts", "b.ts", "c.ts"]);
expect(result.foreignCommits).toEqual([
{ sha: "sha-d", subject: "chore(FN-1111): foreign commit", attributedTaskId: "FN-1111" },
]);
});
it("FN-5083 hotfix: accepts bracketed and colon-prefix legacy subject styles", async () => {
const log = [
"sha-a\x00[KB-42] legacy bracket style\x00body\x1e",
"sha-b\x00KB-42: legacy colon style\x00body\x1e",
"sha-c\x00feat(kb-42): lowercase convention\x00body\x1e",
].join("");
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "a.ts\nb.ts\nc.ts\n" })
.mockResolvedValueOnce({ stdout: log })
.mockResolvedValueOnce({ stdout: "a.ts\n" })
.mockResolvedValueOnce({ stdout: "b.ts\n" })
.mockResolvedValueOnce({ stdout: "c.ts\n" });
const result = await filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "KB-42",
execAsyncImpl: execMock as never,
});
expect(result.ownCommitCount).toBe(3);
expect(result.foreignCommits).toEqual([]);
});
it("throws BranchAttributionError when git command fails", async () => {
const execMock = vi.fn().mockRejectedValueOnce(new Error("fatal: bad revision"));

View File

@@ -34,7 +34,7 @@ function quoteShellArg(value: string): string {
}
function extractAttributedTaskId(body: string): string | null {
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(\S+)\s*(?:\n|$)/gim;
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
@@ -45,6 +45,35 @@ function extractAttributedTaskId(body: string): string | null {
return last?.[1] ?? null;
}
/**
* FN-5083/FN-5060 hotfix: extract a task-id reference from a commit subject when the
* `Fusion-Task-Id` trailer is missing. Recognizes conventional commits
* (`feat(FN-123): ...`, `fix(FN-123): ...`, etc.), bracketed prefixes
* (`[FN-123] ...`) and legacy colon prefixes (`FN-123: ...`).
*
* Accepts any uppercase-letter task prefix (FN, KB, RF, PROJ, JIRA, ...) so this is
* project-agnostic. Returns the canonical `<PREFIX>-<digits>` string.
*/
function extractTaskIdFromSubject(subject: string): string | null {
if (!subject) return null;
// Conventional commit: feat(FN-123): ... or fix(FN-123)!: ... (case-insensitive)
const conventional =
/^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
if (conventional?.[1]) return conventional[1].toUpperCase();
// Bracketed: [FN-123] ...
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
if (bracketed?.[1]) return bracketed[1].toUpperCase();
// Legacy colon: FN-123: ...
const colon = /^\s*([A-Z]+-\d+):/i.exec(subject);
if (colon?.[1]) return colon[1].toUpperCase();
return null;
}
function taskIdsMatch(a: string | null, b: string): boolean {
if (!a) return false;
return a.toUpperCase() === b.toUpperCase();
}
export async function filterFilesToOwnTaskCommits(opts: BranchAttributionOptions): Promise<AttributionResult> {
const execImpl = opts.execAsyncImpl ?? execAsync;
const runGit = async (command: string): Promise<string> => {
@@ -90,8 +119,13 @@ export async function filterFilesToOwnTaskCommits(opts: BranchAttributionOptions
throw new BranchAttributionError("malformed git log output: missing commit sha");
}
const body = bodyParts.join("\x00");
const attributedTaskId = extractAttributedTaskId(body);
if (attributedTaskId === opts.taskId) {
const trailerAttributedTaskId = extractAttributedTaskId(body);
// FN-5083/FN-5060 hotfix: trailer is primary; fall back to subject parsing so
// commits without the `Fusion-Task-Id` trailer (the common case for agent-driven
// commits today) still attribute correctly by their conventional-commit subject.
const subjectAttributedTaskId = trailerAttributedTaskId ? null : extractTaskIdFromSubject(subject);
const attributedTaskId = trailerAttributedTaskId ?? subjectAttributedTaskId;
if (taskIdsMatch(attributedTaskId, opts.taskId)) {
ownCommitShas.push(sha);
continue;
}