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:
@@ -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"));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user