FN-7532: stamp branch group merge attribution
Ensure shared branch group members record merge attribution so completion checklists reflect real landed state. - Route AI merges through branch-group merge routing before selecting the integration target. - Stamp mergeDetails merge target fields for both landed and no-op finalize paths. - Record shared-group member landing state and best-effort managed PR checklist sync after AI merges. - Cover dashboard, CLI lifecycle, and merger scenarios for accurate branch-group completion counts. Files changed: .changeset/fn-7532-branch-group-completion.md | 7 ++ docs/dashboard-guide.md | 2 + .../src/commands/__tests__/task-lifecycle.test.ts | 29 +++++++ .../src/__tests__/routes-branch-groups.test.ts | 45 +++++++++++ packages/engine/src/__tests__/merger-ai.test.ts | 68 +++++++++++++++- packages/engine/src/merger-ai.ts | 90 +++++++++++++++++++++- 6 files changed, 235 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7532 Fusion-Task-Lineage: cd65c18a-f1ad-4f8b-99b2-2e61e233f042 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7532-branch-group-completion.md
Normal file
7
.changeset/fn-7532-branch-group-completion.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix branch group completion checklists to show accurate landed/finished counts.
|
||||||
|
category: fix
|
||||||
|
dev: runAiMerge (the sole merge path since master-plan U0) never resolved branch-group routing or stamped mergeDetails.mergeTargetBranch/mergeTargetSource, so isBranchGroupMemberLanded permanently reported shared-group members as not landed. Routes through resolveBranchGroupMergeRouting (matching the legacy merger.ts pattern) and stamps the target fields on both the landed and no-op finalize paths; preserves merge-target-safety in isBranchGroupMemberLanded (a sibling/mismatched-branch member still never counts as landed).
|
||||||
@@ -1821,6 +1821,8 @@ UI surfaces:
|
|||||||
|
|
||||||
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable SQLite state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
The Group Task Modal shows shared branch name/status, member list (`taskId`, title, column, landed state), quick links to open each member task detail, completion progress (`X of Y members finished`), and tracked PR state when present. Branch groups are durable SQLite state keyed by real `BG-*` ids, so valid grouped tasks continue to list/show after a server restart. It live-refreshes from the same dashboard task-update stream and ignores stale cross-project events.
|
||||||
|
|
||||||
|
> **FN-7532:** a member only counts as "landed" once it merge-confirms onto its OWN group's branch via the branch-group-integration path (`mergeDetails.mergeTargetSource === "branch-group-integration"` and a matching `mergeTargetBranch`) — this is the same predicate the engine's promotion gate uses, so the checklist can never show "complete" when a real promotion would still be refused (or vice versa). The merge engine now stamps this attribution for every merge (previously only the legacy merge path did, so shared-group members merged through the current path were undercounted).
|
||||||
|
|
||||||
Both the modal and branch-group card are completion-gated: while members are still pending, they show progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior).
|
Both the modal and branch-group card are completion-gated: while members are still pending, they show progress only. PR / merge controls are only revealed after all members are landed into the shared branch. When auto-merge is off, promote/open-PR is explicit user action (no automatic push-to-origin behavior).
|
||||||
|
|
||||||
### CLI-onboarding backfill runbook
|
### CLI-onboarding backfill runbook
|
||||||
|
|||||||
@@ -1462,6 +1462,35 @@ describe("syncGroupPrCallback (U6)", () => {
|
|||||||
const sync = syncGroupPrCallback(github as never);
|
const sync = syncGroupPrCallback(github as never);
|
||||||
await expect(sync({ cwd: "/tmp/project", group: { ...group, prNumber: undefined } as never, members })).rejects.toThrow(/no persisted prNumber/);
|
await expect(sync({ cwd: "/tmp/project", group: { ...group, prNumber: undefined } as never, members })).rejects.toThrow(/no persisted prNumber/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532 surface-parity regression: the PR-body checklist must use the SAME
|
||||||
|
isBranchGroupMemberLanded predicate as the dashboard route and CLI serializer —
|
||||||
|
a landed member ticks [x] and counts toward "Completion: x/N landed", while a
|
||||||
|
member merge-confirmed against a sibling/mismatched branch (merge-target-safety)
|
||||||
|
must NOT tick or count, even though it is otherwise "merge confirmed".
|
||||||
|
*/
|
||||||
|
it("ticks landed members and counts only genuinely-landed ones toward the completion line", async () => {
|
||||||
|
const landedMembers = [
|
||||||
|
{ id: "FN-A", title: "Alpha", mergeDetails: { mergeConfirmed: true, mergeTargetSource: "branch-group-integration", mergeTargetBranch: group.branchName } },
|
||||||
|
{ id: "FN-B", title: "Beta", mergeDetails: { mergeConfirmed: true, mergeTargetSource: "branch-group-integration", mergeTargetBranch: "fusion/fn-sibling" } },
|
||||||
|
{ id: "FN-C", title: "Gamma" },
|
||||||
|
] as never[];
|
||||||
|
const github = {
|
||||||
|
getPrStatus: vi.fn(async () => ({ number: 42, url: "https://github.com/owner/repo/pull/42", status: "open", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })),
|
||||||
|
updatePr: vi.fn(async () => ({ number: 42, url: "https://github.com/owner/repo/pull/42", status: "open", title: "T2", headBranch: "h", baseBranch: "main", commentCount: 0 })),
|
||||||
|
};
|
||||||
|
const sync = syncGroupPrCallback(github as never);
|
||||||
|
await sync({ cwd: "/tmp/project", group: group as never, members: landedMembers });
|
||||||
|
const body = (github.updatePr.mock.calls[0][0] as { body: string }).body;
|
||||||
|
// Only FN-A landed (matching branch); FN-B (sibling-branch mismatch) and
|
||||||
|
// FN-C (no merge details) must NOT count, even though FN-B is mergeConfirmed.
|
||||||
|
expect(body).toContain("Completion: 1/3 landed");
|
||||||
|
expect(body).toContain("- [x] FN-A: Alpha");
|
||||||
|
expect(body).toContain("- [ ] FN-B: Beta");
|
||||||
|
expect(body).toContain("- [ ] FN-C: Gamma");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createGroupPrCallback", () => {
|
describe("createGroupPrCallback", () => {
|
||||||
|
|||||||
@@ -575,4 +575,49 @@ describe("branch group list N+1 elimination (Fix #6)", () => {
|
|||||||
expect(byId["BG-B"].completion).toEqual({ landed: 1, total: 1, complete: true });
|
expect(byId["BG-B"].completion).toEqual({ landed: 1, total: 1, complete: true });
|
||||||
expect(byId["BG-C"].completion).toEqual({ landed: 0, total: 0, complete: false });
|
expect(byId["BG-C"].completion).toEqual({ landed: 0, total: 0, complete: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532 archived-member semantics: the list route fetches membership via
|
||||||
|
`listTasks({ includeArchived: false, slim: true })`, so an archived member
|
||||||
|
drops out of BOTH `landed` and `total` uniformly — archiving a LANDED member
|
||||||
|
preserves the ratio (both counts drop by one, `complete` is unaffected when it
|
||||||
|
was already complete), matching every other completion-reading surface (CLI,
|
||||||
|
PR-body checklist, engine promotion gate) which all resolve membership through
|
||||||
|
the same includeArchived:false path. This is intentional, consistent behavior
|
||||||
|
(no cross-surface divergence), asserted here so a future change to the fetch
|
||||||
|
option on only one surface would be caught as a regression.
|
||||||
|
*/
|
||||||
|
it("drops an archived member from both landed and total uniformly (no ratio distortion when the archived member was landed)", async () => {
|
||||||
|
const groups = buildGroups();
|
||||||
|
const archivedLandedMember = memberTask("FN-A3", "BG-A", "feature/a", true);
|
||||||
|
// listTasks with includeArchived:false never returns this row — simulate
|
||||||
|
// the store contract precisely rather than assuming.
|
||||||
|
const nonArchivedTasks: Task[] = [
|
||||||
|
memberTask("FN-A1", "BG-A", "feature/a", true),
|
||||||
|
memberTask("FN-A2", "BG-A", "feature/a", true),
|
||||||
|
];
|
||||||
|
const allTasksIncludingArchived = [...nonArchivedTasks, { ...archivedLandedMember, column: "archived" as const }];
|
||||||
|
const listTasks = vi.fn(async (opts?: { includeArchived?: boolean }) =>
|
||||||
|
opts?.includeArchived ? allTasksIncludingArchived : nonArchivedTasks,
|
||||||
|
);
|
||||||
|
const store = {
|
||||||
|
getRootDir: vi.fn(() => "/tmp/project"),
|
||||||
|
listBranchGroups: vi.fn(() => [groups[0]]),
|
||||||
|
getBranchGroup: vi.fn((id: string) => groups.find((g) => g.id === id) ?? null),
|
||||||
|
listTasks,
|
||||||
|
listTasksByBranchGroup: vi.fn(),
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/branch-groups", createBranchGroupsRouter(store));
|
||||||
|
attachErrorHandler(app);
|
||||||
|
|
||||||
|
const res = await REQUEST(app, "GET", "/branch-groups");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Both members landed, both counted — the archived 3rd (also landed) member
|
||||||
|
// is invisible to the route entirely: 2/2 complete, not 2/3 incomplete.
|
||||||
|
expect(res.body.groups[0].completion).toEqual({ landed: 2, total: 2, complete: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ function initRepoWithBranch(opts: { branch: string; conflict?: boolean } = { bra
|
|||||||
return { dir };
|
return { dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStore(_dir: string, taskOverrides: Record<string, unknown> = {}, settingsOverrides: Record<string, unknown> = {}) {
|
function makeStore(
|
||||||
|
_dir: string,
|
||||||
|
taskOverrides: Record<string, unknown> = {},
|
||||||
|
settingsOverrides: Record<string, unknown> = {},
|
||||||
|
branchGroup?: any,
|
||||||
|
) {
|
||||||
const task: any = {
|
const task: any = {
|
||||||
id: "FN-1",
|
id: "FN-1",
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
@@ -86,6 +91,7 @@ function makeStore(_dir: string, taskOverrides: Record<string, unknown> = {}, se
|
|||||||
};
|
};
|
||||||
const emitted: Array<{ event: string; payload: unknown }> = [];
|
const emitted: Array<{ event: string; payload: unknown }> = [];
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
const group = branchGroup ? { ...branchGroup } : undefined;
|
||||||
const store: any = {
|
const store: any = {
|
||||||
getTask: vi.fn(async () => task),
|
getTask: vi.fn(async () => task),
|
||||||
getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 1 }, ...settingsOverrides })),
|
getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 1 }, ...settingsOverrides })),
|
||||||
@@ -94,8 +100,19 @@ function makeStore(_dir: string, taskOverrides: Record<string, unknown> = {}, se
|
|||||||
emit: vi.fn((event: string, payload: unknown) => { emitted.push({ event, payload }); }),
|
emit: vi.fn((event: string, payload: unknown) => { emitted.push({ event, payload }); }),
|
||||||
logEntry: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
|
logEntry: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
|
||||||
appendAgentLog: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
|
appendAgentLog: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
|
||||||
|
getBranchGroup: vi.fn((id: string) => (group && id === group.id ? group : null)),
|
||||||
|
recordBranchGroupMemberLanded: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||||
|
if (group && id === group.id) Object.assign(group, patch);
|
||||||
|
return group;
|
||||||
|
}),
|
||||||
|
updateBranchGroup: vi.fn((id: string, patch: Record<string, unknown>) => {
|
||||||
|
if (group && id === group.id) Object.assign(group, patch);
|
||||||
|
return group;
|
||||||
|
}),
|
||||||
|
listTasksByBranchGroup: vi.fn(async () => [task]),
|
||||||
|
recordRunAuditEvent: vi.fn(),
|
||||||
};
|
};
|
||||||
return { store, task, emitted, logs };
|
return { store, task, emitted, logs, group };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A merge agent that actually performs the squash merge with git.
|
// A merge agent that actually performs the squash merge with git.
|
||||||
@@ -573,6 +590,53 @@ describe("runAiMerge", () => {
|
|||||||
expect(git(dir, "rev-parse release")).not.toBe(releaseBefore);
|
expect(git(dir, "rev-parse release")).not.toBe(releaseBefore);
|
||||||
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532 regression: runAiMerge is the SOLE merge path, so a shared-branch-group
|
||||||
|
member landed through it must come out with mergeDetails.mergeTargetBranch/
|
||||||
|
mergeTargetSource stamped to the group's own branch via "branch-group-integration" —
|
||||||
|
exactly what isBranchGroupMemberLanded requires — not merged straight onto the
|
||||||
|
project default branch mislabeled (or unlabeled).
|
||||||
|
*/
|
||||||
|
it("routes a shared-branch-group member onto the group's branch and stamps mergeTargetSource: branch-group-integration", async () => {
|
||||||
|
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||||
|
const groupBranch = "fusion/groups/shared-x";
|
||||||
|
const branchGroup = { id: "BG-1", branchName: groupBranch, sourceType: "planning", sourceId: "PS-1", status: "open", prState: "none" };
|
||||||
|
const { store, task, group } = makeStore(
|
||||||
|
dir,
|
||||||
|
{ branchContext: { assignmentMode: "shared", groupId: "BG-1" } },
|
||||||
|
{},
|
||||||
|
branchGroup,
|
||||||
|
);
|
||||||
|
const mainBefore = git(dir, "rev-parse main");
|
||||||
|
|
||||||
|
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||||
|
mergeAgent: realMergeAgent("fusion/fn-1"),
|
||||||
|
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.merged).toBe(true);
|
||||||
|
// Landed onto the GROUP's branch, not the project default.
|
||||||
|
expect(git(dir, `rev-parse ${groupBranch}`)).not.toBe(git(dir, "rev-parse main"));
|
||||||
|
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
||||||
|
expect(task.mergeDetails).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
mergeConfirmed: true,
|
||||||
|
mergeTargetBranch: groupBranch,
|
||||||
|
mergeTargetSource: "branch-group-integration",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exact invariant the checklist/PR-body/dashboard/CLI serializers all
|
||||||
|
// read from — prove the shared predicate now agrees the member landed.
|
||||||
|
const { isBranchGroupMemberLanded } = await import("@fusion/core");
|
||||||
|
expect(isBranchGroupMemberLanded(task, { branchName: groupBranch })).toBe(true);
|
||||||
|
|
||||||
|
// Group-row landing bookkeeping (worktreePath/status) was updated best-effort.
|
||||||
|
expect(store.recordBranchGroupMemberLanded).toHaveBeenCalledWith("BG-1", expect.objectContaining({ status: "open" }));
|
||||||
|
expect(group?.status).toBe("open");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("landSquash (advance + local-checkout sync)", () => {
|
describe("landSquash (advance + local-checkout sync)", () => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
resolveValidatorSettingsModel,
|
resolveValidatorSettingsModel,
|
||||||
type MergeDetails,
|
type MergeDetails,
|
||||||
type MergeResult,
|
type MergeResult,
|
||||||
|
type MergeTargetResolution,
|
||||||
type Settings,
|
type Settings,
|
||||||
type Task,
|
type Task,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
@@ -66,7 +67,8 @@ import { checkSessionError } from "./usage-limit-detector.js";
|
|||||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||||
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
|
import { captureSingleCommitLandedMetadata, syncGroupPrOnLanding, type MergerOptions } from "./merger.js";
|
||||||
|
import { resolveBranchGroupMergeRouting, type BranchGroupMergeRouting, type SyncGroupPrFn } from "./group-merge-coordinator.js";
|
||||||
import { DEFAULT_COMMIT_AUTHOR_EMAIL, DEFAULT_COMMIT_AUTHOR_NAME } from "./worktree-hooks.js";
|
import { DEFAULT_COMMIT_AUTHOR_EMAIL, DEFAULT_COMMIT_AUTHOR_NAME } from "./worktree-hooks.js";
|
||||||
import { installWorktreeDependencies } from "./merge-dependency-sync.js";
|
import { installWorktreeDependencies } from "./merge-dependency-sync.js";
|
||||||
import { activeSessionRegistry } from "./active-session-registry.js";
|
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||||
@@ -818,7 +820,26 @@ export async function runAiMerge(
|
|||||||
// integration branch. The local checkout is only synced if it is on this same
|
// integration branch. The local checkout is only synced if it is on this same
|
||||||
// target branch (see syncLocalCheckout).
|
// target branch (see syncLocalCheckout).
|
||||||
const projectDefaultBranch = await resolveIntegrationBranch(projectRootDir, settings);
|
const projectDefaultBranch = await resolveIntegrationBranch(projectRootDir, settings);
|
||||||
const mergeTarget = resolveTaskMergeTarget(task, { projectDefaultBranch });
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532: runAiMerge is the SOLE merge path (master-plan U0 FNXC:MergerUnification), but it never
|
||||||
|
consulted branch-group routing, so a shared-branch-group member's mergeDetails never got
|
||||||
|
mergeTargetBranch/mergeTargetSource stamped. isBranchGroupMemberLanded requires
|
||||||
|
mergeTargetSource === "branch-group-integration" AND a matching mergeTargetBranch (merge-target
|
||||||
|
safety, see branch-group-completion.ts) — with both fields permanently undefined, every shared
|
||||||
|
member landed via the production path was reported as NOT landed forever (the branch-group
|
||||||
|
checklist/PR body "x/N landed" never advanced and promotion never became eligible). Route through
|
||||||
|
the same resolveBranchGroupMergeRouting used by the legacy merger.ts executeMergeAttempt so a
|
||||||
|
shared member's actual merge target is the group's branch (never a sibling/mismatched branch) and
|
||||||
|
the persisted mergeDetails correctly attribute the landing.
|
||||||
|
*/
|
||||||
|
const groupRouting = await resolveBranchGroupMergeRouting({
|
||||||
|
task,
|
||||||
|
store,
|
||||||
|
projectDefaultBranch,
|
||||||
|
rootDir: projectRootDir,
|
||||||
|
});
|
||||||
|
const mergeTarget = groupRouting?.mergeTarget ?? resolveTaskMergeTarget(task, { projectDefaultBranch });
|
||||||
const integrationBranch = mergeTarget.branch;
|
const integrationBranch = mergeTarget.branch;
|
||||||
const audit = createRunAuditor(store, {
|
const audit = createRunAuditor(store, {
|
||||||
runId: generateSyntheticRunId("ai-merge", taskId),
|
runId: generateSyntheticRunId("ai-merge", taskId),
|
||||||
@@ -951,10 +972,10 @@ export async function runAiMerge(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
|
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
|
||||||
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true });
|
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.tipSha, audit, log, { empty: true }, mergeTarget, groupRouting, options.syncGroupPr);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false });
|
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, landResult.squashSha, audit, log, { empty: false }, mergeTarget, groupRouting, options.syncGroupPr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1511,7 +1532,20 @@ async function finalizeMerged(
|
|||||||
audit: RunAuditor,
|
audit: RunAuditor,
|
||||||
log: (message: string) => Promise<void>,
|
log: (message: string) => Promise<void>,
|
||||||
opts: { empty: boolean },
|
opts: { empty: boolean },
|
||||||
|
mergeTarget?: MergeTargetResolution,
|
||||||
|
groupRouting?: BranchGroupMergeRouting | null,
|
||||||
|
syncGroupPr?: SyncGroupPrFn,
|
||||||
): Promise<MergeResult> {
|
): Promise<MergeResult> {
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532: stamp mergeTargetBranch/mergeTargetSource on every finalize path (landed AND no-op),
|
||||||
|
not only the landed one — isBranchGroupMemberLanded needs both fields regardless of whether the
|
||||||
|
landing produced a real commit, otherwise a no-op-finalized shared-group member would also be
|
||||||
|
reported as not-landed forever.
|
||||||
|
*/
|
||||||
|
const mergeTargetPatch: Pick<MergeDetails, "mergeTargetBranch" | "mergeTargetSource"> | undefined = mergeTarget
|
||||||
|
? { mergeTargetBranch: mergeTarget.branch, mergeTargetSource: mergeTarget.source }
|
||||||
|
: undefined;
|
||||||
let mergeDetails: MergeDetails | undefined;
|
let mergeDetails: MergeDetails | undefined;
|
||||||
let modifiedFiles: string[] | undefined;
|
let modifiedFiles: string[] | undefined;
|
||||||
if (!opts.empty && landedSha) {
|
if (!opts.empty && landedSha) {
|
||||||
@@ -1531,6 +1565,7 @@ async function finalizeMerged(
|
|||||||
mergedAt,
|
mergedAt,
|
||||||
mergeConfirmed: true,
|
mergeConfirmed: true,
|
||||||
prNumber: getPrimaryPrInfo(task)?.number,
|
prNumber: getPrimaryPrInfo(task)?.number,
|
||||||
|
...mergeTargetPatch,
|
||||||
};
|
};
|
||||||
modifiedFiles = landedFiles.length > 0 ? landedFiles : undefined;
|
modifiedFiles = landedFiles.length > 0 ? landedFiles : undefined;
|
||||||
await store.updateTask(taskId, { mergeDetails, modifiedFiles });
|
await store.updateTask(taskId, { mergeDetails, modifiedFiles });
|
||||||
@@ -1549,6 +1584,10 @@ async function finalizeMerged(
|
|||||||
deletions,
|
deletions,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
} else if (mergeTargetPatch) {
|
||||||
|
mergeDetails = { ...(task.mergeDetails ?? {}), ...mergeTargetPatch };
|
||||||
|
await store.updateTask(taskId, { mergeDetails });
|
||||||
|
task.mergeDetails = mergeDetails;
|
||||||
}
|
}
|
||||||
let branchDeleted = false;
|
let branchDeleted = false;
|
||||||
// NEVER delete the integration branch itself — a task whose branch name
|
// NEVER delete the integration branch itself — a task whose branch name
|
||||||
@@ -1585,6 +1624,49 @@ async function finalizeMerged(
|
|||||||
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op), finalizing task row` : `AI merge: landed ${short(landedSha)}, finalizing task row`);
|
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op), finalizing task row` : `AI merge: landed ${short(landedSha)}, finalizing task row`);
|
||||||
const finalized = await finalizeTask(store, taskId, result, audit, log, projectRootDir);
|
const finalized = await finalizeTask(store, taskId, result, audit, log, projectRootDir);
|
||||||
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op) → done` : `AI merge: landed ${short(landedSha)}, task → done`);
|
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op) → done` : `AI merge: landed ${short(landedSha)}, task → done`);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:BranchGroupCompletion 2026-07-04-00:00:
|
||||||
|
FN-7532: mirror the legacy merger.ts executeMergeAttempt's shared-group landing bookkeeping so a
|
||||||
|
member merged via the (now sole) runAiMerge path also updates the group row (worktreePath/status)
|
||||||
|
and pushes the up-to-date checklist body onto any already-open managed group PR. Both are
|
||||||
|
best-effort — a failure here must never fail an otherwise-successful merge.
|
||||||
|
*/
|
||||||
|
if (groupRouting) {
|
||||||
|
try {
|
||||||
|
await Promise.resolve((store as { recordBranchGroupMemberLanded?: TaskStore["recordBranchGroupMemberLanded"] }).recordBranchGroupMemberLanded?.(groupRouting.branchGroup.id, {
|
||||||
|
worktreePath: task.worktree ?? null,
|
||||||
|
status: "open",
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// best-effort persistence
|
||||||
|
}
|
||||||
|
if (syncGroupPr) {
|
||||||
|
try {
|
||||||
|
await syncGroupPrOnLanding({
|
||||||
|
store,
|
||||||
|
groupId: groupRouting.branchGroup.id,
|
||||||
|
cwd: projectRootDir,
|
||||||
|
syncGroupPr,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
store.recordRunAuditEvent?.({
|
||||||
|
taskId,
|
||||||
|
agentId: "merger",
|
||||||
|
runId: `merge-${taskId}`,
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "merge:branch-group-pr-sync-failed",
|
||||||
|
target: groupRouting.branchGroup.id,
|
||||||
|
metadata: { groupId: groupRouting.branchGroup.id, error: err instanceof Error ? err.message : String(err) },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort audit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return finalized;
|
return finalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user