FN-5830: re-land shared branch promotion gate and API
Reintroduce shared-branch-group completion gating and promotion endpoints with reliability coverage. - add GroupMergeCoordinator promotion gate behavior and shared branch group promotion logic updates - expose promotion flow wiring through engine index and project engine APIs - expand coordinator tests and add reliability interaction coverage for branch-group promotion - document the new reliability backstop and add a changeset for @runfusion/fusion Files changed: .changeset/fn-5830-branch-group-promotion.md | 5 + AGENTS.md | 1 + docs/architecture.md | 3 +- .../src/__tests__/group-merge-coordinator.test.ts | 170 ++++++++++++++++++- .../branch-group-promotion.test.ts | 166 +++++++++++++++++++ packages/engine/src/group-merge-coordinator.ts | 183 ++++++++++++++++++++- packages/engine/src/index.ts | 4 + packages/engine/src/project-engine.ts | 37 +++++ 8 files changed, 566 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-5830 Fusion-Task-Lineage: e9823f51-df0e-4da8-8f51-58dfefdc293d
This commit is contained in:
5
.changeset/fn-5830-branch-group-promotion.md
Normal file
5
.changeset/fn-5830-branch-group-promotion.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add shared branch-group completion-gate promotion machinery so grouped shared branches promote to the default branch exactly once after all members land. This includes idempotent promotion re-evaluation, finalized branch-group status/PR tracking persistence, and lifecycle wiring that keeps member integration and shared→default promotion as separate phases.
|
||||||
@@ -170,6 +170,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
|||||||
- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes.
|
- FN-5755 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission validation coverage so bounded periodic maintenance replays `recoverActiveMissions` for stranded `implementing` features and remains idempotent on repeated passes.
|
||||||
- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons.
|
- FN-5783 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` guards grouped merge precedence so per-task `autoMerge` remains member→integration only, group `autoMerge` gates promotion eligibility, and promotion-gate audit events capture pause/automerge override reasons.
|
||||||
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards merger-side promotion-gate telemetry on shared member landings, including pause/settings/group autoMerge reason mapping and no default-branch auto-promotion side effects.
|
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards merger-side promotion-gate telemetry on shared member landings, including pause/settings/group autoMerge reason mapping and no default-branch auto-promotion side effects.
|
||||||
|
- FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so completion detection drives exactly one shared→default promotion, re-calls stay idempotent, and gated paths emit promotion-gated telemetry without promoting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1575,7 +1575,7 @@ The GitHub tracking state listener now attaches to every registered project stor
|
|||||||
### Merge strategies
|
### Merge strategies
|
||||||
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
- Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`)
|
||||||
- `aiMergeTask()` in `merger.ts` performs merge flow
|
- `aiMergeTask()` in `merger.ts` performs merge flow
|
||||||
- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry at member-landing time; actual group-branch→default ref-advance / PR-merge promotion machinery remains downstream in FN-5784/FN-5785.
|
- FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry; FN-5830 lands the completion gate + promotion machinery via `evaluateBranchGroupCompletion` and idempotent `promoteBranchGroup` (single shared→default merge/PR with finalized status and PR tracking persistence).
|
||||||
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`.
|
||||||
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
|
- `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior)
|
||||||
- Supports workflow-step execution after merge (post-merge phase)
|
- Supports workflow-step execution after merge (post-merge phase)
|
||||||
@@ -1774,6 +1774,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
|
|||||||
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features.
|
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features.
|
||||||
- FN-5782 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` guards branch-group merge routing so `shared` members land on `branch_groups.branchName`, grouped multi-member merges converge on the same integration branch, ungrouped/`per-task-derived` tasks stay on direct default-branch merge flow, and routed merges emit `merge:branch-group-routed` audit metadata.
|
- FN-5782 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` guards branch-group merge routing so `shared` members land on `branch_groups.branchName`, grouped multi-member merges converge on the same integration branch, ungrouped/`per-task-derived` tasks stay on direct default-branch merge flow, and routed merges emit `merge:branch-group-routed` audit metadata.
|
||||||
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards the promotion eligibility hook/audit seam so member landings emit `merge:branch-group-promotion-gated` with deterministic reason metadata (`eligible`, `group-automerge-disabled`, `settings-automerge-disabled`, `global-pause`, `engine-paused`) while group branches remain open and do not auto-promote to the default branch.
|
- FN-5788 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` guards the promotion eligibility hook/audit seam so member landings emit `merge:branch-group-promotion-gated` with deterministic reason metadata (`eligible`, `group-automerge-disabled`, `settings-automerge-disabled`, `global-pause`, `engine-paused`) while group branches remain open and do not auto-promote to the default branch.
|
||||||
|
- FN-5830 backstop: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` guards branch-group completion-gate + promotion lifecycle so promotion happens exactly once after all members land, re-calls are idempotent, and gated paths emit `merge:branch-group-promotion-gated` without default-branch promotion.
|
||||||
- FN-5819 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` guards the scoped autoMerge-off exception so shared members still integrate their per-task branches into the single group branch, do not land on main, and are not moved backward by self-healing maintenance.
|
- FN-5819 backstop: `packages/engine/src/__tests__/reliability-interactions/shared-group-member-integration.test.ts` guards the scoped autoMerge-off exception so shared members still integrate their per-task branches into the single group branch, do not land on main, and are not moved backward by self-healing maintenance.
|
||||||
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
|
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends coverage so zero-assertion auto-pass deterministically advances `loopState` to `passed`, sets `lastValidatorStatus="passed"`, emits `validation_auto_passed_no_assertions`, and does not re-fire on repeated recovery passes.
|
||||||
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
|
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 merge-request contract shadow writes: flag OFF is a no-op, flag ON writes marker/record strictly after legacy handoff, and `autoMerge:false` remains `manual-required` without shadow running transitions.
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { describe, expect, it, afterEach } from "vitest";
|
import { describe, expect, it, afterEach } from "vitest";
|
||||||
import { evaluateBranchGroupPromotion, resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
|
import {
|
||||||
|
evaluateBranchGroupCompletion,
|
||||||
|
evaluateBranchGroupPromotion,
|
||||||
|
promoteBranchGroup,
|
||||||
|
resolveBranchGroupMergeRouting,
|
||||||
|
} from "../group-merge-coordinator.js";
|
||||||
|
|
||||||
const dirs: string[] = [];
|
const dirs: string[] = [];
|
||||||
|
|
||||||
@@ -24,6 +29,59 @@ afterEach(async () => {
|
|||||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("evaluateBranchGroupCompletion", () => {
|
||||||
|
it("returns complete when all members are landed", () => {
|
||||||
|
const result = evaluateBranchGroupCompletion({
|
||||||
|
members: [
|
||||||
|
{ id: "FN-A", column: "done" as const },
|
||||||
|
{ id: "FN-B", column: "in-review" as const, mergeDetails: { mergeTargetSource: "branch-group-integration" } as any },
|
||||||
|
] as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
complete: true,
|
||||||
|
totalMembers: 2,
|
||||||
|
landedMemberIds: ["FN-A", "FN-B"],
|
||||||
|
pendingMemberIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns pending ids when one member is not landed", () => {
|
||||||
|
const result = evaluateBranchGroupCompletion({
|
||||||
|
members: [
|
||||||
|
{ id: "FN-A", column: "done" as const },
|
||||||
|
{ id: "FN-B", column: "todo" as const },
|
||||||
|
] as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.complete).toBe(false);
|
||||||
|
expect(result.landedMemberIds).toEqual(["FN-A"]);
|
||||||
|
expect(result.pendingMemberIds).toEqual(["FN-B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty groups as incomplete", () => {
|
||||||
|
const result = evaluateBranchGroupCompletion({ members: [] });
|
||||||
|
expect(result).toEqual({
|
||||||
|
complete: false,
|
||||||
|
totalMembers: 0,
|
||||||
|
landedMemberIds: [],
|
||||||
|
pendingMemberIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts mixed done + landed in-review members as complete", () => {
|
||||||
|
const result = evaluateBranchGroupCompletion({
|
||||||
|
members: [
|
||||||
|
{ id: "FN-A", column: "done" as const },
|
||||||
|
{ id: "FN-B", column: "in-review" as const, mergeDetails: { mergeTargetSource: "branch-group-integration" } as any },
|
||||||
|
] as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
expect(result.pendingMemberIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("evaluateBranchGroupPromotion", () => {
|
describe("evaluateBranchGroupPromotion", () => {
|
||||||
const baseGroup = {
|
const baseGroup = {
|
||||||
id: "BG-1",
|
id: "BG-1",
|
||||||
@@ -117,6 +175,116 @@ describe("evaluateBranchGroupPromotion", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("promoteBranchGroup", () => {
|
||||||
|
function makeGroup(overrides?: Partial<any>) {
|
||||||
|
return {
|
||||||
|
id: "BG-1",
|
||||||
|
sourceType: "planning",
|
||||||
|
sourceId: "planning:x",
|
||||||
|
branchName: "fusion/groups/planning-x",
|
||||||
|
autoMerge: true,
|
||||||
|
prState: "none",
|
||||||
|
status: "open",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns incomplete without merging when members are pending", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const group = makeGroup();
|
||||||
|
const result = await promoteBranchGroup({
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
|
||||||
|
store: {
|
||||||
|
getBranchGroup: () => group,
|
||||||
|
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "todo" }],
|
||||||
|
updateBranchGroup: () => {
|
||||||
|
throw new Error("should not update");
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reason).toBe("incomplete");
|
||||||
|
expect(() => execSync("git show main:group.txt", { cwd: rootDir })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns gated and emits audit when promotion gates are disabled", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
const group = makeGroup({ autoMerge: false });
|
||||||
|
const audits: Array<Record<string, unknown>> = [];
|
||||||
|
const result = await promoteBranchGroup({
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
|
||||||
|
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
|
||||||
|
store: {
|
||||||
|
getBranchGroup: () => group,
|
||||||
|
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
|
||||||
|
updateBranchGroup: () => {
|
||||||
|
throw new Error("should not update");
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reason).toBe("gated");
|
||||||
|
expect(audits).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ mutationType: "merge:branch-group-promotion-gated" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges group branch once and finalizes group when complete and eligible", async () => {
|
||||||
|
const rootDir = makeRepo();
|
||||||
|
execSync("git checkout -b fusion/groups/planning-x", { cwd: rootDir });
|
||||||
|
execSync("echo promoted > group.txt", { cwd: rootDir, shell: "/bin/bash" });
|
||||||
|
execSync("git add group.txt && git commit -m group", { cwd: rootDir, shell: "/bin/bash" });
|
||||||
|
execSync("git checkout main", { cwd: rootDir });
|
||||||
|
|
||||||
|
let group = makeGroup();
|
||||||
|
const audits: Array<Record<string, unknown>> = [];
|
||||||
|
const first = await promoteBranchGroup({
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
|
||||||
|
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
|
||||||
|
store: {
|
||||||
|
getBranchGroup: () => group,
|
||||||
|
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
|
||||||
|
updateBranchGroup: (_id: string, patch: Partial<typeof group>) => {
|
||||||
|
group = { ...group, ...patch };
|
||||||
|
return group;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.promoted).toBe(true);
|
||||||
|
expect(first.reason).toBe("promoted");
|
||||||
|
expect(group.status).toBe("finalized");
|
||||||
|
expect(group.prState).toBe("merged");
|
||||||
|
expect(execSync("git show main:group.txt", { cwd: rootDir, encoding: "utf8" })).toContain("promoted");
|
||||||
|
|
||||||
|
const second = await promoteBranchGroup({
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" },
|
||||||
|
recordAudit: async (event) => { audits.push(event as Record<string, unknown>); },
|
||||||
|
store: {
|
||||||
|
getBranchGroup: () => group,
|
||||||
|
listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }],
|
||||||
|
updateBranchGroup: (_id: string, patch: Partial<typeof group>) => {
|
||||||
|
group = { ...group, ...patch };
|
||||||
|
return group;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.reason).toBe("already-finalized");
|
||||||
|
expect(audits.filter((event) => event.mutationType === "merge:branch-group-promoted")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveBranchGroupMergeRouting", () => {
|
describe("resolveBranchGroupMergeRouting", () => {
|
||||||
it("returns null for non-shared tasks", async () => {
|
it("returns null for non-shared tasks", async () => {
|
||||||
const routing = await resolveBranchGroupMergeRouting({
|
const routing = await resolveBranchGroupMergeRouting({
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { type TaskStore } from "@fusion/core";
|
||||||
|
import { aiMergeTask } from "../../merger.js";
|
||||||
|
import { promoteBranchGroup } from "../../group-merge-coordinator.js";
|
||||||
|
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||||
|
|
||||||
|
async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise<void> {
|
||||||
|
const task = await store.getTask(taskId);
|
||||||
|
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||||
|
const worktreePath = join(`${rootDir}-worktrees`, taskId.toLowerCase());
|
||||||
|
await store.updateTask(taskId, {
|
||||||
|
baseBranch: "",
|
||||||
|
branch,
|
||||||
|
column: "in-review",
|
||||||
|
worktree: worktreePath,
|
||||||
|
steps: (task?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
|
||||||
|
currentStep: (task?.steps ?? []).length ?? 0,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
git(rootDir, `git checkout -b ${branch}`);
|
||||||
|
await mkdir(join(rootDir, "packages/engine/src"), { recursive: true });
|
||||||
|
git(rootDir, `sh -c 'printf ${JSON.stringify(`export const ${fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}'`);
|
||||||
|
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}`);
|
||||||
|
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${fileName}`)}`);
|
||||||
|
git(rootDir, "git checkout main");
|
||||||
|
store.enqueueMergeQueue(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FN-5830 reliability interactions: branch group promotion", () => {
|
||||||
|
it.skipIf(!hasGit)("promotes exactly once after all members land", async () => {
|
||||||
|
const fixture = await makeReliabilityFixture({ taskId: "FN-5830-RI-A", settings: { testMode: true, autoMerge: true } as any });
|
||||||
|
try {
|
||||||
|
const { rootDir, store, task } = fixture;
|
||||||
|
const second = await store.createTask({
|
||||||
|
id: "FN-5830-RI-B",
|
||||||
|
title: "FN-5830-RI-B",
|
||||||
|
description: "second member",
|
||||||
|
column: "in-review",
|
||||||
|
baseBranch: "main",
|
||||||
|
branch: "fusion/fn-5830-ri-b",
|
||||||
|
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||||
|
steps: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await stageMergeBranch(store, rootDir, task.id, "fn5830MemberA");
|
||||||
|
await stageMergeBranch(store, rootDir, second.id, "fn5830MemberB");
|
||||||
|
|
||||||
|
const group = store.createBranchGroup({
|
||||||
|
sourceType: "planning",
|
||||||
|
sourceId: "PS-FN5830-A",
|
||||||
|
branchName: "fusion/groups/fn-5830-a",
|
||||||
|
autoMerge: true,
|
||||||
|
});
|
||||||
|
await store.setTaskBranchGroup(task.id, group.id);
|
||||||
|
await store.setTaskBranchGroup(second.id, group.id);
|
||||||
|
await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
|
||||||
|
await store.updateTask(second.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
|
||||||
|
|
||||||
|
const firstMerge = await aiMergeTask(store, rootDir, task.id);
|
||||||
|
expect(firstMerge.merged).toBe(true);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
column: "done",
|
||||||
|
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
|
||||||
|
} as any);
|
||||||
|
expect(() => git(rootDir, "git show main:packages/engine/src/fn5830MemberA.ts")).toThrow();
|
||||||
|
|
||||||
|
const audits: Array<any> = [];
|
||||||
|
const promoteWithMembers = async (memberIds: string[]) => promoteBranchGroup({
|
||||||
|
store: {
|
||||||
|
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
|
||||||
|
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
|
||||||
|
listTasksByBranchGroup: async () => Promise.all(memberIds.map(async (id) => await store.getTask(id))).then((tasks) => tasks.filter(Boolean) as any),
|
||||||
|
} as any,
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" } as any,
|
||||||
|
recordAudit: (e) => { audits.push(e); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomplete = await promoteWithMembers([task.id, second.id]);
|
||||||
|
expect(incomplete.reason).toBe("incomplete");
|
||||||
|
|
||||||
|
const secondMerge = await aiMergeTask(store, rootDir, second.id);
|
||||||
|
expect(secondMerge.merged).toBe(true);
|
||||||
|
await store.updateTask(second.id, {
|
||||||
|
column: "done",
|
||||||
|
mergeDetails: { ...(await store.getTask(second.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
|
||||||
|
} as any);
|
||||||
|
const promoted = await promoteWithMembers([task.id, second.id]);
|
||||||
|
expect(promoted.reason).toBe("promoted");
|
||||||
|
expect(git(rootDir, "git show main:packages/engine/src/fn5830MemberA.ts")).toContain("fn5830MemberA");
|
||||||
|
expect(git(rootDir, "git show main:packages/engine/src/fn5830MemberB.ts")).toContain("fn5830MemberB");
|
||||||
|
expect(store.getBranchGroup(group.id)?.status).toBe("finalized");
|
||||||
|
expect(store.getBranchGroup(group.id)?.prState).toBe("merged");
|
||||||
|
|
||||||
|
const again = await promoteWithMembers([task.id, second.id]);
|
||||||
|
expect(again.reason).toBe("already-finalized");
|
||||||
|
const promoteEvents = audits.filter((event) => event.mutationType === "merge:branch-group-promoted" && (event.metadata as any)?.groupId === group.id);
|
||||||
|
expect(promoteEvents).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup();
|
||||||
|
}
|
||||||
|
}, 45_000);
|
||||||
|
|
||||||
|
it.skipIf(!hasGit)("respects disabled gate and does not promote", async () => {
|
||||||
|
const scenarios: Array<{ name: string; settings: any; groupAutoMerge?: boolean; fileName: string }> = [
|
||||||
|
{
|
||||||
|
name: "settings auto-merge disabled",
|
||||||
|
settings: { testMode: true, autoMerge: false },
|
||||||
|
groupAutoMerge: true,
|
||||||
|
fileName: "fn5830GateSettings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group auto-merge disabled",
|
||||||
|
settings: { testMode: true, autoMerge: true },
|
||||||
|
groupAutoMerge: false,
|
||||||
|
fileName: "fn5830GateGroup",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const fixture = await makeReliabilityFixture({ taskId: `FN-5830-RI-GATE-${scenario.fileName}`, settings: scenario.settings });
|
||||||
|
try {
|
||||||
|
const { rootDir, store, task } = fixture;
|
||||||
|
await stageMergeBranch(store, rootDir, task.id, scenario.fileName);
|
||||||
|
const group = store.createBranchGroup({
|
||||||
|
sourceType: "planning",
|
||||||
|
sourceId: `PS-FN5830-GATE-${scenario.fileName}`,
|
||||||
|
branchName: `fusion/groups/fn-5830-gate-${scenario.fileName}`,
|
||||||
|
autoMerge: scenario.groupAutoMerge,
|
||||||
|
});
|
||||||
|
await store.setTaskBranchGroup(task.id, group.id);
|
||||||
|
await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any);
|
||||||
|
|
||||||
|
const mergeResult = await aiMergeTask(store, rootDir, task.id);
|
||||||
|
expect(mergeResult.merged).toBe(true);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
column: "done",
|
||||||
|
mergeDetails: { ...(await store.getTask(task.id))?.mergeDetails, mergeTargetSource: "branch-group-integration" },
|
||||||
|
} as any);
|
||||||
|
const audits: Array<any> = [];
|
||||||
|
const gated = await promoteBranchGroup({
|
||||||
|
store: {
|
||||||
|
getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args),
|
||||||
|
updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args),
|
||||||
|
listTasksByBranchGroup: async () => [await store.getTask(task.id)].filter(Boolean) as any,
|
||||||
|
} as any,
|
||||||
|
rootDir,
|
||||||
|
groupId: group.id,
|
||||||
|
settings: await store.getSettings() as any,
|
||||||
|
recordAudit: (e) => { audits.push(e); },
|
||||||
|
});
|
||||||
|
expect(gated.reason, scenario.name).toBe("gated");
|
||||||
|
expect(() => git(rootDir, `git show main:packages/engine/src/${scenario.fileName}.ts`), scenario.name).toThrow();
|
||||||
|
expect(store.getBranchGroup(group.id)?.status, scenario.name).toBe("open");
|
||||||
|
expect(store.getBranchGroup(group.id)?.prState, scenario.name).toBe("none");
|
||||||
|
expect(audits.find((event) => event.mutationType === "merge:branch-group-promotion-gated" && (event.metadata as any)?.groupId === group.id), scenario.name).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 45_000);
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import type { BranchGroup, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
|
import type { BranchGroup, BranchGroupPrState, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core";
|
||||||
import { resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core";
|
import { resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core";
|
||||||
|
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -11,6 +12,24 @@ export interface BranchGroupMergeRouting {
|
|||||||
mergeTarget: MergeTargetResolution;
|
mergeTarget: MergeTargetResolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BranchGroupCompletionStatus {
|
||||||
|
complete: boolean;
|
||||||
|
totalMembers: number;
|
||||||
|
landedMemberIds: string[];
|
||||||
|
pendingMemberIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchGroupPromotionResult {
|
||||||
|
groupId: string;
|
||||||
|
promoted: boolean;
|
||||||
|
alreadyFinalized: boolean;
|
||||||
|
reason: "promoted" | "incomplete" | "gated" | "already-finalized" | "group-not-found";
|
||||||
|
status: BranchGroup["status"];
|
||||||
|
prState: BranchGroupPrState;
|
||||||
|
prNumber?: number;
|
||||||
|
prUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BranchGroupPromotionDecision {
|
export interface BranchGroupPromotionDecision {
|
||||||
eligible: boolean;
|
eligible: boolean;
|
||||||
groupAutoMerge: boolean;
|
groupAutoMerge: boolean;
|
||||||
@@ -22,6 +41,31 @@ export interface BranchGroupPromotionDecision {
|
|||||||
| "eligible";
|
| "eligible";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function evaluateBranchGroupCompletion(input: {
|
||||||
|
members: Pick<Task, "id" | "column" | "branchContext" | "mergeDetails">[];
|
||||||
|
}): BranchGroupCompletionStatus {
|
||||||
|
const landedMemberIds: string[] = [];
|
||||||
|
const pendingMemberIds: string[] = [];
|
||||||
|
|
||||||
|
for (const member of input.members) {
|
||||||
|
const landed = member.column === "done"
|
||||||
|
|| (member.column === "in-review" && member.mergeDetails?.mergeTargetSource === "branch-group-integration");
|
||||||
|
if (landed) {
|
||||||
|
landedMemberIds.push(member.id);
|
||||||
|
} else {
|
||||||
|
pendingMemberIds.push(member.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMembers = input.members.length;
|
||||||
|
return {
|
||||||
|
complete: totalMembers > 0 && pendingMemberIds.length === 0,
|
||||||
|
totalMembers,
|
||||||
|
landedMemberIds,
|
||||||
|
pendingMemberIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates branch-group → default-branch PROMOTION eligibility only.
|
* Evaluates branch-group → default-branch PROMOTION eligibility only.
|
||||||
* This does not perform promotion and does not govern member → group-integration
|
* This does not perform promotion and does not govern member → group-integration
|
||||||
@@ -60,6 +104,143 @@ async function ensureGroupBranchExists(rootDir: string, branchName: string, star
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only entrypoint allowed to perform shared-branch-group → default-branch promotion.
|
||||||
|
* Promotion is intentionally idempotent and must never run inline in aiMergeTask.
|
||||||
|
*/
|
||||||
|
export async function promoteBranchGroup(input: {
|
||||||
|
store: Pick<TaskStore, "getBranchGroup" | "listTasksByBranchGroup" | "updateBranchGroup">;
|
||||||
|
rootDir: string;
|
||||||
|
groupId: string;
|
||||||
|
settings: Pick<Settings, "autoMerge" | "globalPause" | "enginePaused"> & Partial<Pick<Settings, "mergeStrategy" | "integrationBranch" | "baseBranch">>;
|
||||||
|
recordAudit?: (event: {
|
||||||
|
domain: string;
|
||||||
|
mutationType: string;
|
||||||
|
target: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
}): Promise<BranchGroupPromotionResult> {
|
||||||
|
const group = input.store.getBranchGroup(input.groupId);
|
||||||
|
if (!group) {
|
||||||
|
return {
|
||||||
|
groupId: input.groupId,
|
||||||
|
promoted: false,
|
||||||
|
alreadyFinalized: false,
|
||||||
|
reason: "group-not-found",
|
||||||
|
status: "abandoned",
|
||||||
|
prState: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.status === "finalized" || group.prState === "merged") {
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
promoted: false,
|
||||||
|
alreadyFinalized: true,
|
||||||
|
reason: "already-finalized",
|
||||||
|
status: group.status,
|
||||||
|
prState: group.prState,
|
||||||
|
prNumber: group.prNumber,
|
||||||
|
prUrl: group.prUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.prState === "open") {
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
promoted: false,
|
||||||
|
alreadyFinalized: true,
|
||||||
|
reason: "already-finalized",
|
||||||
|
status: group.status,
|
||||||
|
prState: group.prState,
|
||||||
|
prNumber: group.prNumber,
|
||||||
|
prUrl: group.prUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await input.store.listTasksByBranchGroup(group.id);
|
||||||
|
const completion = evaluateBranchGroupCompletion({ members });
|
||||||
|
if (!completion.complete) {
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
promoted: false,
|
||||||
|
alreadyFinalized: false,
|
||||||
|
reason: "incomplete",
|
||||||
|
status: group.status,
|
||||||
|
prState: group.prState,
|
||||||
|
prNumber: group.prNumber,
|
||||||
|
prUrl: group.prUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibility = evaluateBranchGroupPromotion({ group, settings: input.settings });
|
||||||
|
if (!eligibility.eligible) {
|
||||||
|
await input.recordAudit?.({
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "merge:branch-group-promotion-gated",
|
||||||
|
target: group.id,
|
||||||
|
metadata: {
|
||||||
|
groupId: group.id,
|
||||||
|
branchName: group.branchName,
|
||||||
|
groupAutoMerge: eligibility.groupAutoMerge,
|
||||||
|
effectiveEligible: false,
|
||||||
|
reason: eligibility.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
promoted: false,
|
||||||
|
alreadyFinalized: false,
|
||||||
|
reason: "gated",
|
||||||
|
status: group.status,
|
||||||
|
prState: group.prState,
|
||||||
|
prNumber: group.prNumber,
|
||||||
|
prUrl: group.prUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const integrationBranch = await resolveIntegrationBranch(input.rootDir, input.settings);
|
||||||
|
await ensureGroupBranchExists(input.rootDir, group.branchName, integrationBranch);
|
||||||
|
const currentBranch = (await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: input.rootDir })).stdout.trim();
|
||||||
|
try {
|
||||||
|
await execAsync(`git checkout ${JSON.stringify(integrationBranch)}`, { cwd: input.rootDir });
|
||||||
|
await execAsync(`git merge --no-ff --no-edit ${JSON.stringify(group.branchName)}`, { cwd: input.rootDir });
|
||||||
|
} finally {
|
||||||
|
await execAsync(`git checkout ${JSON.stringify(currentBranch)}`, { cwd: input.rootDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrMode = input.settings.mergeStrategy === "pull-request";
|
||||||
|
const updatedGroup = input.store.updateBranchGroup(group.id, {
|
||||||
|
status: "finalized",
|
||||||
|
prState: isPrMode ? "open" : "merged",
|
||||||
|
});
|
||||||
|
|
||||||
|
await input.recordAudit?.({
|
||||||
|
domain: "git",
|
||||||
|
mutationType: "merge:branch-group-promoted",
|
||||||
|
target: group.id,
|
||||||
|
metadata: {
|
||||||
|
groupId: group.id,
|
||||||
|
branchName: group.branchName,
|
||||||
|
integrationBranch,
|
||||||
|
memberIds: completion.landedMemberIds,
|
||||||
|
...(updatedGroup.prNumber ? { prNumber: updatedGroup.prNumber } : {}),
|
||||||
|
...(updatedGroup.prUrl ? { prUrl: updatedGroup.prUrl } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
promoted: true,
|
||||||
|
alreadyFinalized: false,
|
||||||
|
reason: "promoted",
|
||||||
|
status: updatedGroup.status,
|
||||||
|
prState: updatedGroup.prState,
|
||||||
|
prNumber: updatedGroup.prNumber,
|
||||||
|
prUrl: updatedGroup.prUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveBranchGroupMergeRouting(input: {
|
export async function resolveBranchGroupMergeRouting(input: {
|
||||||
task: Pick<Task, "branchContext" | "baseBranch">;
|
task: Pick<Task, "branchContext" | "baseBranch">;
|
||||||
store: Pick<TaskStore, "getBranchGroup">;
|
store: Pick<TaskStore, "getBranchGroup">;
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ export {
|
|||||||
export {
|
export {
|
||||||
resolveBranchGroupMergeRouting,
|
resolveBranchGroupMergeRouting,
|
||||||
evaluateBranchGroupPromotion,
|
evaluateBranchGroupPromotion,
|
||||||
|
evaluateBranchGroupCompletion,
|
||||||
|
promoteBranchGroup,
|
||||||
type BranchGroupMergeRouting,
|
type BranchGroupMergeRouting,
|
||||||
type BranchGroupPromotionDecision,
|
type BranchGroupPromotionDecision,
|
||||||
|
type BranchGroupCompletionStatus,
|
||||||
|
type BranchGroupPromotionResult,
|
||||||
} from "./group-merge-coordinator.js";
|
} from "./group-merge-coordinator.js";
|
||||||
export {
|
export {
|
||||||
resolveMergeIntegrationRoot,
|
resolveMergeIntegrationRoot,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
|
|||||||
import type { RoutineRunner } from "./routine-runner.js";
|
import type { RoutineRunner } from "./routine-runner.js";
|
||||||
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
|
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
|
||||||
import { runAiMerge } from "./merger-ai.js";
|
import { runAiMerge } from "./merger-ai.js";
|
||||||
|
import { promoteBranchGroup } from "./group-merge-coordinator.js";
|
||||||
import { PRIORITY_MERGE } from "./concurrency.js";
|
import { PRIORITY_MERGE } from "./concurrency.js";
|
||||||
import { runtimeLog } from "./logger.js";
|
import { runtimeLog } from "./logger.js";
|
||||||
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
|
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
|
||||||
@@ -1782,6 +1783,39 @@ export class ProjectEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
|
const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct";
|
||||||
|
const promotionSettings = {
|
||||||
|
autoMerge: settings.autoMerge,
|
||||||
|
globalPause: settings.globalPause,
|
||||||
|
enginePaused: settings.enginePaused,
|
||||||
|
mergeStrategy: settings.mergeStrategy,
|
||||||
|
integrationBranch: settings.integrationBranch,
|
||||||
|
baseBranch: settings.baseBranch,
|
||||||
|
};
|
||||||
|
const attemptBranchGroupPromotion = async (taskForPromotion: Task | null): Promise<void> => {
|
||||||
|
if (!taskForPromotion || !isSharedBranchGroupMemberIntegration(taskForPromotion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await promoteBranchGroup({
|
||||||
|
store,
|
||||||
|
rootDir: cwd,
|
||||||
|
groupId: taskForPromotion.branchContext!.groupId,
|
||||||
|
settings: promotionSettings,
|
||||||
|
recordAudit: async (event) => {
|
||||||
|
await store.recordRunAuditEvent({
|
||||||
|
domain: event.domain as any,
|
||||||
|
mutationType: event.mutationType,
|
||||||
|
target: event.target,
|
||||||
|
metadata: event.metadata,
|
||||||
|
} as any);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (promotionError) {
|
||||||
|
runtimeLog.warn(
|
||||||
|
`Branch-group promotion evaluation failed for ${taskId}: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
|
if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) {
|
||||||
this.activeMergeTaskId = taskId;
|
this.activeMergeTaskId = taskId;
|
||||||
@@ -1807,6 +1841,7 @@ export class ProjectEngine {
|
|||||||
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
|
mergeTargetBranch: mergedTask.mergeDetails?.mergeTargetBranch,
|
||||||
} as MergeResult);
|
} as MergeResult);
|
||||||
}
|
}
|
||||||
|
await attemptBranchGroupPromotion(mergedTask);
|
||||||
} else if (result === "waiting") {
|
} else if (result === "waiting") {
|
||||||
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR waiting: ${taskId}`);
|
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge PR waiting: ${taskId}`);
|
||||||
}
|
}
|
||||||
@@ -1882,6 +1917,8 @@ export class ProjectEngine {
|
|||||||
if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) {
|
if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) {
|
||||||
await store.updateTask(taskId, { mergeRetries: 0 });
|
await store.updateTask(taskId, { mergeRetries: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await attemptBranchGroupPromotion(latestTask);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
this.activeMergeSession = null;
|
this.activeMergeSession = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user