fix(merger): prevent tasks landing in Done with no commit on main
Two root-cause fixes for the "fake done" patterns surfaced while debugging FN-5475's stuck preflight (it depended on FN-5233, which the board reported as Done but whose squash had stranded on a sibling fusion/fn-* branch). 1. resolveTaskMergeTarget rejects fusion/fn-* sibling branches as a merge destination — when a task's baseBranch was inherited from a sibling/dependent dispatch, the merger detached onto and squashed against that branch instead of advancing main. New audit event surfaces the steering miss so the underlying baseBranch-propagation bug stays observable. 2. self-healing findLandedTaskCommit verifies ownership against each grep candidate's body before attribution. The previous code blindly accepted the first hit of `git log --grep=FN-XXXX` (which matches the entire commit message); FN-5441 and FN-5446 were both marked done against an unrelated FN-5483 commit whose body merely mentioned them in prose. commitOwnedByTask is also tightened: trailers must be line-anchored and the subject fallback must match conventional-commit form, not a bare substring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,55 @@ describe("resolveTaskMergeTarget", () => {
|
||||
source: "legacy-main",
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for FN-5233/FN-5530: when a sibling-dispatched task inherits
|
||||
// `baseBranch = fusion/fn-<id>`, the merger must NOT use that as the squash
|
||||
// destination — otherwise the commit lands on the sibling branch and is
|
||||
// lost from main. Falls through to projectDefault, and reports the rejection.
|
||||
it("rejects task baseBranch when it points at a sibling fusion/fn-* branch", () => {
|
||||
const result = resolveTaskMergeTarget(
|
||||
{ baseBranch: "fusion/fn-5339", branchContext: undefined },
|
||||
{ projectDefaultBranch: "main" },
|
||||
);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.source).toBe("project-default");
|
||||
expect(result.rejected).toEqual({
|
||||
branch: "fusion/fn-5339",
|
||||
source: "task-base-branch",
|
||||
reason: "fusion-sibling-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects inherited branch context that points at a sibling fusion/fn-* branch", () => {
|
||||
const result = resolveTaskMergeTarget(
|
||||
{
|
||||
baseBranch: undefined,
|
||||
branchContext: {
|
||||
groupId: "G-1",
|
||||
source: "planning",
|
||||
assignmentMode: "shared",
|
||||
inheritedBaseBranch: "FUSION/FN-1234",
|
||||
},
|
||||
},
|
||||
{ projectDefaultBranch: "main" },
|
||||
);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.source).toBe("project-default");
|
||||
expect(result.rejected).toEqual({
|
||||
branch: "FUSION/FN-1234",
|
||||
source: "task-branch-context",
|
||||
reason: "fusion-sibling-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reject non-fusion branches that happen to share a prefix", () => {
|
||||
// `fusion/release-1.0` is a legitimate human-chosen base; only the
|
||||
// canonical `fusion/fn-<id>` pattern is a sibling-task marker.
|
||||
expect(resolveTaskMergeTarget({ baseBranch: "fusion/release-1.0", branchContext: undefined })).toEqual({
|
||||
branch: "fusion/release-1.0",
|
||||
source: "task-base-branch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTaskMergeBlocker", () => {
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { Task, WorkflowStepResult } from "./types.js";
|
||||
export interface MergeTargetResolution {
|
||||
branch: string;
|
||||
source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
|
||||
/**
|
||||
* When the resolver rejects a candidate (e.g. baseBranch points at a sibling
|
||||
* `fusion/fn-*` branch), this records the rejected value and the reason. The
|
||||
* merger uses this to emit an audit event so the steering bug is observable
|
||||
* in the run-audit timeline rather than failing silently.
|
||||
*/
|
||||
rejected?: { branch: string; source: "task-base-branch" | "task-branch-context"; reason: "fusion-sibling-branch" };
|
||||
}
|
||||
|
||||
export interface MergeTargetResolverOptions {
|
||||
@@ -10,27 +17,49 @@ export interface MergeTargetResolverOptions {
|
||||
legacyFallbackBranch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling task branches (`fusion/fn-<id>`) MUST NOT be used as merge targets.
|
||||
* They are start-point/rebase anchors, not destinations: landing a squash onto
|
||||
* a sibling branch strands the commit on a feature ref instead of advancing
|
||||
* the project integration branch (root cause of FN-5233/FN-5530 lost-on-main).
|
||||
*/
|
||||
const FUSION_SIBLING_BRANCH_RE = /^fusion\/fn-/i;
|
||||
|
||||
function isFusionSiblingBranch(branch: string): boolean {
|
||||
return FUSION_SIBLING_BRANCH_RE.test(branch);
|
||||
}
|
||||
|
||||
export function resolveTaskMergeTarget(
|
||||
task: Pick<Task, "baseBranch" | "branchContext">,
|
||||
options: MergeTargetResolverOptions = {},
|
||||
): MergeTargetResolution {
|
||||
let rejected: MergeTargetResolution["rejected"];
|
||||
|
||||
const configuredBase = task.baseBranch?.trim();
|
||||
if (configuredBase) {
|
||||
return { branch: configuredBase, source: "task-base-branch" };
|
||||
if (isFusionSiblingBranch(configuredBase)) {
|
||||
rejected = { branch: configuredBase, source: "task-base-branch", reason: "fusion-sibling-branch" };
|
||||
} else {
|
||||
return { branch: configuredBase, source: "task-base-branch" };
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedBase = task.branchContext?.inheritedBaseBranch?.trim();
|
||||
if (inheritedBase) {
|
||||
return { branch: inheritedBase, source: "task-branch-context" };
|
||||
if (isFusionSiblingBranch(inheritedBase)) {
|
||||
rejected = rejected ?? { branch: inheritedBase, source: "task-branch-context", reason: "fusion-sibling-branch" };
|
||||
} else {
|
||||
return { branch: inheritedBase, source: "task-branch-context", rejected };
|
||||
}
|
||||
}
|
||||
|
||||
const projectDefault = options.projectDefaultBranch?.trim();
|
||||
if (projectDefault) {
|
||||
return { branch: projectDefault, source: "project-default" };
|
||||
return { branch: projectDefault, source: "project-default", rejected };
|
||||
}
|
||||
|
||||
const legacyFallback = options.legacyFallbackBranch?.trim() || "main";
|
||||
return { branch: legacyFallback, source: "legacy-main" };
|
||||
return { branch: legacyFallback, source: "legacy-main", rejected };
|
||||
}
|
||||
|
||||
export const HARD_BLOCKING_TASK_STATUSES = new Set([
|
||||
|
||||
Reference in New Issue
Block a user