From 36218876a3ef57d3ae582b048a744851ef3b3287 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:58:06 -0700 Subject: [PATCH 01/21] docs(plan): branch-group single managed PR flow plan --- ...1-feat-branch-group-single-pr-flow-plan.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md diff --git a/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md b/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md new file mode 100644 index 0000000000..770571a4fb --- /dev/null +++ b/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md @@ -0,0 +1,363 @@ +--- +title: "feat: End-to-end branch-group single managed PR flow (planning + missions)" +type: feat +status: active +date: 2026-06-03 +depth: deep +--- + +# feat: End-to-end branch-group single managed PR flow (planning + missions) + +## Summary + +When a user runs **planning** or a **mission**, every task in the resulting group should land on one shared group branch, and that group branch should produce **a single PR that is created and kept in sync** as members land — through to a terminal merge/close. + +The plumbing for most of this already exists (the FN-5782 → FN-5788 → FN-5819 → FN-5830 → FN-5846 chain). This plan is an **audit-and-complete** pass: it fixes the breaks that stop the flow from working end-to-end and adds the one capability that was never built — creating and syncing a real GitHub PR for the group. + +Confirmed scope decisions (from planning dialogue): +- **PR lifecycle:** create the single group PR, **keep it in sync** as members land (body/checklist/completion), and reconcile terminal merge/close. +- **Entry points:** both **planning** and **missions** must work end-to-end. + +Out of scope: redesigning the task/mission data model, the merger seam (FN-5719 is ratified; we align with it, we don't re-open it), or PR-monitor external-integration semantics. + +--- + +## Problem Frame + +The branch-group system was built incrementally and has four classes of defect that, together, prevent the "single managed PR" outcome: + +1. **The PR is never real.** `promoteBranchGroup` (in `packages/engine/src/group-merge-coordinator.ts`) does git plumbing only — it merges the group branch into the integration branch and flips `BranchGroup.prState` to `"open"` for PR-mode, but it never calls the GitHub client, so `prNumber`/`prUrl` stay empty. There is no "PR" beyond a status string. + +2. **The manual promote route is dead.** `POST /branch-groups/:id/promote` (`packages/dashboard/src/routes/register-branch-groups-routes.ts`) invokes `engine.promoteBranchGroup(groupId)` as a method on the engine object, but only a standalone function exists. The dashboard test mocks the method, so the gap is invisible in CI. + +3. **Members can't be enumerated by group.** Planning and missions stamp `task.branchContext.groupId` with a synthetic string (`planning:` / `mission:`), while the stored `BranchGroup.id` is a generated `BG-…`. `listTasksByBranchGroup(group.id)` filters on exact `groupId` equality, so auto-created groups won't list their members — which breaks completion gating and PR rollup. + +4. **Inconsistent "landed" semantics.** The route's `isMemberLanded` and the coordinator's `evaluateBranchGroupCompletion` define "landed/complete" differently, so the gate that reveals PR controls can disagree with the gate the engine uses to promote. + +Underlying all of this is a **data-loss risk** (the 2026-05-23 lost-work incident): shared members share branch lineage, so any path that resolves a shared member's merge target to a sibling `fusion/fn-*` branch or to `main` instead of `branch_groups.branchName` can strand or mis-attribute work. This plan must preserve and extend the existing guards, especially across self-healing finalize paths (FN-5846). + +--- + +## Requirements + +- **R1.** A shared group created by planning or by a mission can enumerate its member tasks via the store (`listTasksByBranchGroup`) using the group's real id. *(fixes defect 3)* +- **R2.** "Landed" and "group complete" have a single canonical definition shared by the dashboard route and the engine coordinator. *(fixes defect 4)* +- **R3.** Every merge path — normal merge **and** all self-healing/deterministic finalize paths — resolves a shared member to `branch_groups.branchName`, never to a sibling `fusion/fn-*` branch or to the project default. *(preserves the lost-work guards)* +- **R4.** The dashboard `promote` route reaches a real, callable promotion entry point on the engine. *(fixes defect 2)* +- **R5.** Promotion of a completed group creates **exactly one** GitHub PR (group integration branch → default), persists `prNumber`/`prUrl`/`prState`, and is **idempotent** (re-running never opens a second PR). *(fixes defect 1)* +- **R6.** Once the group PR exists, it is kept in sync as additional members land — body reflects member list and completion (x/total) — reusing the existing idempotent PR-refresh path. +- **R7.** When a group reaches terminal state, `prState` reconciles to `"merged"` (group merged) or `"closed"` (group abandoned), and the GitHub PR is closed/merged accordingly. +- **R8.** PR/promote controls remain completion-gated; under `autoMerge: false`, promotion and PR creation are explicit user actions with no automatic push-to-origin. +- **R9.** The flow works end-to-end from both the **planning** entry point and the **mission** entry point, verified by integration tests. +- **R10.** Agent-native parity: any group promote/PR action a user can take in the dashboard is reachable from the CLI / agent surface. + +--- + +## High-Level Technical Design + +*Authoritative shape of the flow; per-unit fields below are the source of truth for files.* + +### Flow: member task → shared branch → single managed PR + +```mermaid +sequenceDiagram + participant EP as Entry point
(planning / mission) + participant Store as core: TaskStore + participant Merger as engine: merger + participant Coord as engine: group-merge-coordinator + participant GH as dashboard: github client + participant BG as branch_groups row + + EP->>Store: ensureBranchGroupForSource(...) → group (BG-id) + EP->>Store: createTask(branchContext.groupId = group.id) %% U1: real id, not synthetic + Note over Merger: each member completes + Merger->>Merger: resolveTaskMergeTarget → branch-group-integration
(never sibling / main — U3) + Merger->>Store: recordBranchGroupMemberLanded + Merger->>Coord: attempt promotion (completion-gated — U2) + alt all members landed AND PR mode + Coord->>Coord: merge group branch → integration branch (idempotent) + Coord->>GH: create OR reuse single PR (U5, idempotent) + GH-->>Coord: prNumber / prUrl + Coord->>BG: persist prNumber/prUrl, prState=open + end + Note over Coord,GH: subsequent members land → sync PR body (U6) + Coord->>GH: refreshPrInBackground keyed on (source, externalId) +``` + +### BranchGroup.prState lifecycle + +```mermaid +stateDiagram-v2 + [*] --> none + none --> open: promote (completion-gated)
creates 1 real PR — U5 + open --> open: member lands → sync body — U6 + open --> merged: group PR merged — U7 + open --> closed: group abandoned — U7 + none --> closed: group abandoned before PR + merged --> [*] + closed --> [*] +``` + +Two definitions must be unified (U2): the route's `isMemberLanded` and the coordinator's `evaluateBranchGroupCompletion`. The diagram's gates assume the unified predicate. + +--- + +## Key Technical Decisions + +- **KTD1 — Stamp the real `BG-` id into `branchContext.groupId`.** Rather than teach `listTasksByBranchGroup` to also match synthetic keys, have the entry points use the id returned by `ensureBranchGroupForSource`. This is the smallest change that makes membership queries correct everywhere and avoids a dual-key convention that would rot. Migration concern for already-created groups is addressed in U1. +- **KTD2 — One canonical landed/completion predicate in `@fusion/core`.** Extract a single function (e.g. in `packages/core/src/task-merge.ts` or a small `branch-group-completion.ts`) consumed by both the route and the coordinator, so the gate can never diverge again. +- **KTD3 — Build a new group-PR sync helper; do NOT reuse `refreshPrInBackground`.** Feasibility review confirmed `refreshPrInBackground` (`packages/dashboard/src/routes/register-git-github.ts:2220`) is task-hardwired and runs the *wrong direction* — it pulls review/merge status *from* GitHub onto a task's PR array; U6 needs to *push* an updated PR **body** for a group PR stored on the `branch_groups` row. `github.ts` has `mergePr` but no `updatePr`/`closePr` helper, so those are net-new. The `(source, externalId)` idempotency key belongs to the comment-import path (`register-git-github.ts:2073`), is unrelated to group-PR sync, and must not be cited as the dedup mechanism here. +- **KTD4 — Promotion/sync idempotency via persisted `prNumber` + `getBranchGroupByBranchName`** (`packages/core/src/store.ts:4373`). Before creating a PR, check for an existing one on the group; create only when absent. Re-running promotion is a no-op on the PR. This — not KTD3 — is the load-bearing idempotency guarantee for R5/R6. +- **KTD5 — A single engine bridge method** (`engine.promoteBranchGroup(groupId)`) wraps the standalone coordinator function so the route wiring in `register-integrated-routers.ts` works and the dashboard test stops mocking a non-existent method. +- **KTD7 — Inject the GitHub client via the existing `processPullRequestMerge` option seam, not `setCreateFnAgent`.** The engine already receives GitHub capability as a constructor-option callback that closes over a dashboard-built `GitHubClient` (`packages/engine/src/project-engine.ts:207`, invoked at `:1877`; constructed in the CLI layer at `daemon.ts:335`, `dashboard.ts:1560`, `serve.ts:361`). Add a sibling option (e.g. `promoteBranchGroupPr`/`createGroupPr`) alongside it and thread it through **all three** CLI construction sites. `setCreateFnAgent` is a weaker module-load global and the wrong model here. +- **KTD6 — Additive schema only.** `branch_groups` already carries `prState`/`prUrl`/`prNumber` (per `storage.md`). If a member-checklist cache is needed it's an additive, forward-only, version-gated `IF NOT EXISTS` column — no destructive backfill, `fusion-central.db` untouched. Default assumption: **no migration needed**; confirm during U5. + +--- + +## Scope Boundaries + +### In scope +- Membership identity fix, unified completion predicate, merge-target safety hardening, the engine bridge method, real single-PR creation, PR sync as members land, terminal merge/close reconciliation, dashboard + CLI surfacing, and end-to-end tests for both entry points. + +### Deferred to Follow-Up Work +- Multi-node promotion arbitration. Promotion idempotency is currently task-row/group-local with no central claim/lease (FN-4820 gap). If two nodes can trigger promotion concurrently, a lease is needed. Out of this PR; flagged in U5 as an assumption (single-promoter). +- Richer PR templating / labels / reviewers beyond a member checklist + completion summary. + +### Outside this product's identity +- Changing PR-monitor external-integration semantics (explicit non-goal per FN-5719). +- Re-opening the executor/merger decoupling seam. + +--- + +## Implementation Units + +### U1. Unify branch-group membership identity + +**Goal:** Make `listTasksByBranchGroup(group.id)` reliably return members for groups created by planning and missions, and stop `setTaskBranchGroup` from hardcoding the assignment mode. *(R1)* + +**Dependencies:** none (foundational). + +**Files:** +- `packages/core/src/store.ts` — `setTaskBranchGroup` (~4434), `listTasksByBranchGroup` (~4467), `ensureBranchGroupForSource` (~4378). +- `packages/dashboard/src/routes/register-planning-subtask-routes.ts` — branch-context construction (~213–246 and the parallel ~1273–1310 block). +- `packages/core/src/mission-store.ts` — branch-context construction (~3814–3848). +- Tests: `packages/core/src/__tests__/branch-group-store.test.ts`, `packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts`. + +**Approach:** Root cause (feasibility-confirmed): both entry points call `ensureBranchGroupForSource(...)` but **discard its return value** (`register-planning-subtask-routes.ts:228`, `mission-store.ts:3823`), then stamp `branchContext.groupId` with the synthetic string (`register-planning-subtask-routes.ts:214,1271`; `mission-store.ts:3844`). The synthetic value never resolves against `getBranchGroup` (a plain PK lookup, `store.ts:4363`), so it is broken for *every* consumer today, not just enumeration. Fix: capture the returned `group.id` and stamp it. `setTaskBranchGroup` should carry the group's actual assignment intent instead of literal `"shared"`. A read-side fallback matching the legacy synthetic key is harmless and preserves enumeration of already-broken old groups — but note it is not preventing a regression (legacy groups were already non-functional for promotion/gating), so keep it minimal and removable. + +**Patterns to follow:** `ensureBranchGroupForSource` idempotency; existing `branchContext` shape in `types.ts` (~1690). + +**Test scenarios:** +- Covers F (planning shared group). Planning creates a shared group; `listTasksByBranchGroup(group.id)` returns all created subtasks. +- Covers F (mission shared group). Mission triage creates a shared group; members enumerate by real id. +- `setTaskBranchGroup` on a `per-task-derived` group does not overwrite mode to `"shared"`. +- Legacy row with synthetic `groupId` still enumerates via read-side fallback. +- Empty group returns `[]`, not an error. + +**Verification:** Both entry points produce groups whose members are returned by id; no path writes the shared branch as `task.branch`. + +--- + +### U2. Canonical landed / completion predicate + +**Goal:** One shared definition of "member landed" and "group complete," consumed by both the dashboard route and the engine coordinator. *(R2)* + +**Dependencies:** U1. + +**Files:** +- `packages/core/src/task-merge.ts` (or a new `packages/core/src/branch-group-completion.ts`) — exported predicate(s). +- `packages/core/src/index.ts` — export. +- `packages/dashboard/src/routes/register-branch-groups-routes.ts` — replace local `isMemberLanded` (~14–18). +- `packages/engine/src/group-merge-coordinator.ts` — replace/wrap `evaluateBranchGroupCompletion` (~44–67). +- Tests: `packages/core/src/__tests__/` new test for the predicate; update `routes-branch-groups.test.ts`, `group-merge-coordinator.test.ts`. + +**Approach:** Define `isBranchGroupMemberLanded(task, group)` and `isBranchGroupComplete(members, group)` in core. The two existing semantics genuinely disagree (feasibility-confirmed): the route requires `mergeConfirmed === true && mergeTargetSource === "branch-group-integration" && mergeTargetBranch === group.branchName` (`register-branch-groups-routes.ts:13`), while the coordinator accepts `column === "done"` OR `(column === "in-review" && mergeTargetSource === "branch-group-integration")` and **never checks `mergeTargetBranch`** (`group-merge-coordinator.ts:50`). **Decision:** the stricter route semantics win — landing requires `mergeConfirmed` **and** `mergeTargetBranch === group.branchName`. This is load-bearing for U3's merge-target safety guarantee (a member `done` against a sibling/mismatched branch must NOT count as landed). Route and coordinator both import the core predicate. + +**Patterns to follow:** existing exports from `@fusion/core`; serialize-group completion shape in the route (~20–38). + +**Test scenarios:** +- Member with `mergeConfirmed` + matching target → landed; mismatched `mergeTargetBranch` → not landed. +- Group with all members landed → complete; one unlanded → incomplete. +- Route serialization and coordinator agree on the same fixture (no divergence). +- Empty membership → not complete. + +**Verification:** Route gate and engine gate return identical results for identical group states. + +--- + +### U3. Merge-target safety for shared members across all paths + +**Goal:** Guarantee every merge and self-healing finalize path routes a shared member to `branch_groups.branchName`, never to a sibling `fusion/fn-*` branch or the default. *(R3)* + +**Dependencies:** U1 (correct group resolution). + +**Execution note:** Characterization-first — add coverage that pins current correct routing on the normal path before touching the recovery paths, given the data-loss history. + +**Files:** +- `packages/core/src/task-merge.ts` — `resolveTaskMergeTarget` (~71), `isSharedBranchGroupMemberIntegration` (~64). +- `packages/engine/src/merger.ts` — `resolveBranchGroupMergeRouting` (~7466), `recordBranchGroupMemberLanding` (~7475), finalize-success paths (~8139, 8206, 8411, deferred-confirm ~9983–10127). +- `packages/engine/src/self-healing.ts`, `packages/engine/src/already-merged-detector.ts` — recovery/finalize re-routing (FN-5846). +- Tests: `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts`, `shared-group-member-integration.test.ts`, plus new recovery-path cases. + +**Approach:** Audit each finalize/recovery path; ensure shared-member re-routing to the group branch happens **before** reachability checks, stamps `mergeTargetSource`/`mergeTargetBranch`, calls `recordBranchGroupMemberLanded`, and emits a defensive audit event if any path would evaluate a shared member against the default branch (mirror `merge:merge-target-rejected-fusion-sibling`). Keep "already landed" detection commit-ownership-anchored, not grep-prose-matched. + +**Patterns to follow:** existing rejection guard `merge:merge-target-rejected-fusion-sibling`; FN-5819 in-review-terminal exception for member→group integration. + +**Test scenarios:** +- Shared member with inherited sibling `baseBranch` → merge target resolves to group branch, not the sibling; emits routed audit. +- Self-healing finalize of a shared member re-routes to the group branch and never evaluates against `main`. +- `already-merged-detector` attributes a landed commit by ownership trailer, not first `git log --grep` hit. +- `autoMerge: false`: member→group integration proceeds (FN-5819 exception) but does **not** trigger shared→default promotion. +- Ungrouped / `per-task-derived` task still routes direct-to-default (no regression). + +**Verification:** No path resolves a shared member's target to a sibling branch or default; recovery paths emit the routed/rejected audit events. + +--- + +### U4. Engine `promoteBranchGroup` bridge method + +**Goal:** Expose a real, callable `engine.promoteBranchGroup(groupId)` so the dashboard route works and the test stops mocking a non-existent method. *(R4)* + +**Dependencies:** U2. + +**Files:** +- `packages/engine/src/project-engine.ts` — add the method wrapping the standalone coordinator function (used internally at ~1853; auto-promotion at ~1848–1872). +- `packages/engine/src/group-merge-coordinator.ts` — ensure the standalone signature is callable from the method. +- `packages/dashboard/src/routes/register-integrated-routers.ts` — verify the `promoteBranchGroup` option wiring (~48–59) now hits a real method. +- Tests: `packages/dashboard/src/__tests__/routes-branch-groups.test.ts` (remove the mock-masking; assert real wiring), `packages/engine/src/__tests__/group-merge-coordinator.test.ts`. + +**Approach:** Add `promoteBranchGroup(groupId)` to the engine class that resolves store/rootDir/settings and delegates to the coordinator. Confirm `getEngine(projectId)` returns an object exposing it. Update the dashboard test to exercise the real path (the prior mock hid GAP A). + +**Patterns to follow:** existing engine method exposure and the option-callback injection pattern in `register-integrated-routers.ts`. + +**Test scenarios:** +- `POST /branch-groups/:id/promote` on a complete group reaches the engine method (no "not available on engine" throw). +- Promote on an incomplete group is rejected by the gate (completion-gated). +- Engine method delegates to the coordinator with resolved settings. + +**Verification:** The promote route succeeds against a real engine method; no test mocks `engine.promoteBranchGroup`. + +--- + +### U5. Create a single real GitHub PR for the group + +**Goal:** On promotion (PR mode, completion-gated), create exactly one GitHub PR for the group integration branch → default, persist `prNumber`/`prUrl`/`prState`, idempotently. *(R5, R8)* + +**Dependencies:** U1, U2, U4. + +**Files:** +- `packages/engine/src/group-merge-coordinator.ts` — `promoteBranchGroup` (~111–242): after the integration merge, create/reuse the PR via the injected callback instead of only flipping `prState`. +- `packages/dashboard/src/github.ts` — add a group-PR create helper reusing `createPrWithGh` (~722) / `createPrWithApi` (~771). +- `packages/cli/src/commands/daemon.ts` (~335), `packages/cli/src/commands/dashboard.ts` (~1560), `packages/cli/src/commands/serve.ts` (~361) — **all three** engine-construction sites must pass the new group-PR callback alongside `processPullRequestMerge`, or behavior diverges between `fn daemon` / `fn dashboard` / `fn serve`. +- `packages/engine/src/project-engine.ts` — accept the new option alongside `processPullRequestMerge` (~207). +- `packages/core/src/store.ts` — persist PR fields via `updateBranchGroup` (~4402); use `getBranchGroupByBranchName` (~4373) for idempotency. +- `packages/core/src/db.ts` — confirmed: no schema change needed (`branch_groups` already has `prState`/`prUrl`/`prNumber` at ~784–786); add an additive `IF NOT EXISTS` migration only if a checklist cache is required. +- Tests: `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts`, `branch-group-promotion-gate.test.ts`; new github-client test. + +**Approach:** Gate on unified completion (U2). If a PR already exists for the group (persisted `prNumber` or matching open PR via `getBranchGroupByBranchName`), reuse it — never open a second (KTD4). The GitHub client reaches the coordinator via the injected `processPullRequestMerge`-style option callback (KTD7), never a static dashboard import. Under `autoMerge: false`, creation is explicit and does not push automatically. **Assumption:** single-promoter — multi-node arbitration (no central claim/lease, FN-4820) is deferred. + +**Patterns to follow:** the existing `processPullRequestMerge` injected-callback seam (`project-engine.ts:207` → CLI construction sites); `createPrWithGh`/`createPrWithApi`; DI to avoid `@fusion/engine` ↔ dashboard cycles. + +**Test scenarios:** +- Complete group, PR mode → exactly one PR created; `prNumber`/`prUrl`/`prState=open` persisted. +- Re-running promotion → no second PR (idempotent). +- Incomplete group → no PR (gate blocks). +- `autoMerge: false` → PR creation is explicit, no auto-push. +- gh-CLI path and API path both produce a persisted PR (parity). +- GitHub failure → group left in a recoverable state (no partial `prState` lie); error surfaced. + +**Verification:** A completed group yields one real PR with populated number/url; re-promotion is a no-op. + +--- + +### U6. Keep the group PR in sync + terminal lifecycle + +**Goal:** As more members land, update the PR body (member checklist, x/total completion); on group merge/abandon, reconcile `prState` and the GitHub PR. *(R6, R7)* + +**Dependencies:** U5. + +**Files:** +- `packages/dashboard/src/github.ts` — add **net-new** `updatePr`/`editPrBody` and `closePr` helpers (only `mergePr` exists today at ~1785); no body-edit/close helper exists to reuse. +- `packages/engine/src/merger.ts` — trigger sync from `recordBranchGroupMemberLanding` (~7475) when a group PR exists, via the injected callback (KTD7). +- `packages/engine/src/group-merge-coordinator.ts` — terminal reconciliation (group complete → `merged`; abandon → `closed`). +- `packages/core/src/store.ts` — `updateBranchGroup` status transitions (auto-`closedAt` on leaving `open`). +- `packages/dashboard/src/routes/register-branch-groups-routes.ts` — surface refreshed state in serialization. +- Tests: new group-PR sync test; `store-pr-merged-transition.test.ts`; coordinator terminal-state tests. + +**Approach:** Build a **new group-PR sync helper** (push body + close + merge against the `branch_groups` row) — do **not** reuse `refreshPrInBackground`, which is task-scoped and pulls status the wrong direction (KTD3). On each member landing, if the group has an open PR, enqueue a body refresh (member list + completion); idempotency comes from the persisted `prNumber` (KTD4), so coalescing/retry must be built here, not inherited. On group completion/merge, set `prState=merged`; on abandon (`status=abandoned`), close the GitHub PR and set `prState=closed`. + +**Patterns to follow:** `updateBranchGroup` closing semantics; the injected-callback seam from U5 (KTD7). + +**Test scenarios:** +- Second member lands after PR open → PR body reflects 2/N; no duplicate PR. +- Group fully merged → `prState=merged`, GitHub PR merged/closed. +- Group abandoned → `prState=closed`, GitHub PR closed. +- Concurrent member landings → single coalesced refresh (idempotent), no race-duplicated updates. +- Sync failure is retryable and does not corrupt `prState`. +- Group PR closed/merged out-of-band on GitHub (persisted `prNumber` no longer open) → sync detects and reconciles `prState` rather than erroring or re-opening. + +**Verification:** PR body tracks completion as members land; terminal states reconcile both `prState` and the GitHub PR. + +--- + +### U7. Dashboard + CLI surfacing (agent-native parity) + +**Goal:** Surface completion-gated group-PR controls in the dashboard and provide an equivalent CLI/agent path. *(R8, R10)* + +**Dependencies:** U4, U5, U6. + +**Files:** +- `packages/dashboard/app/components/` — Group Task Modal / `MissionManager.tsx` / `TaskCard.tsx`: show progress before completion, reveal promote/PR-open + PR link after. +- `packages/cli/src/commands/task.ts` and/or `packages/cli/src/commands/git.ts` — a group promote/PR command reaching the same engine method. +- Tests: dashboard route/UI tests; CLI command test. + +**Approach:** Controls hidden until the unified completion predicate (U2) reports complete; once promoted, show the PR link from persisted `prUrl`. The CLI command calls the same promote entry point (no dashboard-only capability). + +**Patterns to follow:** existing branch-group dashboard APIs (`GET/POST /api/branch-groups...`); CLI command structure in `packages/cli/src/commands/`. + +**Test scenarios:** +- Incomplete group → progress shown, promote control hidden. +- Complete group → promote/PR control shown; after promote, PR link rendered. +- CLI promote command on a complete group opens/links the same single PR a dashboard user would get (parity). +- CLI promote on incomplete group → rejected with the same gate. + +**Verification:** Any group PR action available in the UI is reachable from the CLI; controls respect completion gating. + +--- + +### U8. End-to-end integration tests (planning + mission) + +**Goal:** Prove the full flow for both entry points: group creation → members land on the shared branch → single managed PR created and synced. *(R9)* + +**Dependencies:** U1–U7. + +**Files:** +- `packages/dashboard/src/__tests__/` — extend `planning.test.ts`, `mission-e2e.test.ts` / `mission-integration.test.ts`. +- `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts` — full lifecycle assertion. + +**Approach:** Drive each entry point through to a single PR, asserting member enumeration (U1), unified gating (U2), safe routing (U3), real PR creation (U5), and sync/terminal (U6). Assert no second PR appears on re-promotion and no shared member ever targets a sibling/default branch. + +**Test scenarios:** +- Covers F (planning E2E). Planning → shared group → all subtasks land → one PR created, synced to N/N, merged → `prState=merged`. +- Covers F (mission E2E). Mission triage → shared group → members land → one PR; abandon mid-flight → `prState=closed`. +- Re-running promotion in either flow → no duplicate PR. +- A self-healing finalize during the flow keeps members on the group branch (no lost work). + +**Verification:** Both entry points reach a single managed PR with correct terminal state; no duplicate PRs; no mis-routed members. + +--- + +## Risks & Dependencies + +- **Data loss (high).** Shared members share branch lineage; a regression in merge-target resolution can strand work (2026-05-23 incident). Mitigation: U3 characterization-first, defensive audit events, commit-ownership-anchored detection, and the U8 self-healing E2E case. +- **Idempotency / duplicate PRs (medium).** Mitigation: KTD4 (persisted `prNumber` + `getBranchGroupByBranchName` check) and explicit re-promotion no-op tests in U5/U8. +- **Multi-node promotion (deferred).** No central claim/lease today (FN-4820). Documented as a single-promoter assumption in U5; out of scope. +- **Circular import (low).** The coordinator must not import the dashboard GitHub client directly — inject it via the existing `processPullRequestMerge`-style option-callback seam (`project-engine.ts:207` → CLI construction sites), **not** `setCreateFnAgent` (KTD7). Wiring only one of the three CLI sites is the realistic mistake — see U5 file list. +- **Changeset.** `@runfusion/fusion` ships this behavior — add `.changeset/*.md` before commit (per repo convention). + +--- + +## Sources & Research + +- Repo trace: `branch-assignment.ts`, `store.ts` branch-group methods, `group-merge-coordinator.ts`, `register-branch-groups-routes.ts`, `register-integrated-routers.ts`, `github.ts`, planning/mission entry points. +- Learnings: `docs/missions.md` (shared-group invariant), `docs/architecture.md` (FN-5782/5830/5846 merge routing + promotion), `docs/incidents/2026-05-23-lost-work-tasks.md` (merge-target safety), `docs/dashboard-guide.md` (PR surface + `refreshPrInBackground`), `docs/rfcs/FN-5719-decouple-executor-merger.md` (cutover discipline), `docs/dag/milestone-b-schema-migration-plan.md` (migration pattern). From 66ca583ece159a91b87a1c00b941d4c6456a280d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:17:51 -0700 Subject: [PATCH 02/21] fix(FN-branch-group): stamp real BG- id into branchContext.groupId (U1) Planning and mission entry points discarded the BranchGroup returned by ensureBranchGroupForSource and stamped a synthetic planning:/mission: string that never resolved against getBranchGroup, breaking member enumeration. Capture and stamp the real BG- id; stop setTaskBranchGroup hardcoding assignmentMode; add a removable legacy read-side shim. Export TaskBranchContext. --- .../src/__tests__/branch-group-store.test.ts | 58 +++++++++++++++++++ .../core/src/__tests__/mission-store.test.ts | 26 +++++++-- packages/core/src/index.ts | 2 +- packages/core/src/mission-store.ts | 8 ++- packages/core/src/store.ts | 30 ++++++++-- .../src/__tests__/routes-planning.test.ts | 10 ++-- .../shared-branch-group-entry-points.test.ts | 11 ++-- .../register-planning-subtask-routes.ts | 42 +++++++++----- 8 files changed, 152 insertions(+), 35 deletions(-) diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index ae26efd00d..601fc7045b 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -179,6 +179,64 @@ describe("TaskStore branch groups", () => { expect(landed.status).toBe("open"); }); + it("returns [] for an empty branch group rather than throwing", async () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-empty", branchName: "fn/empty" }); + await expect(store.listTasksByBranchGroup(group.id)).resolves.toEqual([]); + await expect(store.listTasksByBranchGroup("BG-does-not-exist")).resolves.toEqual([]); + }); + + it("enumerates legacy rows stamped with the synthetic groupId via the read-side fallback", async () => { + // Simulate a pre-fix planning group whose members were stamped with `planning:`. + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-legacy", branchName: "fn/legacy" }); + const legacyTask = await store.createTask({ + description: "legacy member", + branchContext: { groupId: "planning:PS-legacy", source: "planning", assignmentMode: "shared" }, + }); + const newTask = await store.createTask({ + description: "new member", + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + }); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members.map((task) => task.id).sort()).toEqual([legacyTask.id, newTask.id].sort()); + }); + + it("enumerates legacy mission rows via the synthetic fallback", async () => { + const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-legacy", branchName: "fn/mission-legacy" }); + const legacyTask = await store.createTask({ + description: "legacy mission member", + branchContext: { groupId: "mission:M-legacy", source: "mission", assignmentMode: "shared" }, + }); + + const members = await store.listTasksByBranchGroup(group.id); + expect(members.map((task) => task.id)).toEqual([legacyTask.id]); + }); + + it("does not overwrite a per-task-derived assignmentMode to shared on setTaskBranchGroup", async () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-perTask", branchName: "fn/per-task" }); + const task = await store.createTask({ + description: "per-task-derived member", + branchContext: { groupId: "old", source: "planning", assignmentMode: "per-task-derived" }, + }); + + await store.setTaskBranchGroup(task.id, group.id); + const linked = await store.getTask(task.id); + expect(linked.branchContext).toEqual({ + groupId: group.id, + source: "planning", + assignmentMode: "per-task-derived", + }); + }); + + it("honors an explicit assignmentMode option on setTaskBranchGroup", async () => { + const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-explicit", branchName: "fn/explicit" }); + const task = await store.createTask({ description: "explicit mode" }); + + await store.setTaskBranchGroup(task.id, group.id, { assignmentMode: "per-task-derived" }); + const linked = await store.getTask(task.id); + expect(linked.branchContext?.assignmentMode).toBe("per-task-derived"); + }); + it("preserves autoMerge + branchContext in slim list/search/modifiedSince and archived slim", async () => { const task = await store.createTask({ description: "slim check" }); const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-2", branchName: "fn/mission" }); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 2790b84fb6..abff8b872e 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -2236,7 +2236,9 @@ describe("MissionStore", () => { expect(task?.branch).toMatch(/^release\/shared\//); expect(task?.branch).not.toBe("release/shared"); - expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`); + // U1: branchContext.groupId carries the real BranchGroup id, not the synthetic `mission:` string. + expect(task?.branchContext?.groupId).toBe(ts.getBranchGroupBySource("mission", mission.id)?.id); + expect(task?.branchContext?.groupId).toMatch(/^BG-/); expect(task?.branchContext?.assignmentMode).toBe("shared"); }); @@ -2258,7 +2260,9 @@ describe("MissionStore", () => { expect(task?.branch).toMatch(/^hotfix\/shared\//); expect(task?.branch).not.toBe("hotfix/shared"); - expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`); + // U1: branchContext.groupId carries the real BranchGroup id, not the synthetic `mission:` string. + expect(task?.branchContext?.groupId).toBe(ts.getBranchGroupBySource("mission", mission.id)?.id); + expect(task?.branchContext?.groupId).toMatch(/^BG-/); expect(task?.branchContext?.assignmentMode).toBe("shared"); }); @@ -2485,7 +2489,9 @@ describe("MissionStore", () => { expect(task?.branch).toMatch(/^feature\/manual\//); expect(task?.branch).not.toBe("feature/manual"); expect(task?.baseBranch).toBe("release"); - expect(task?.branchContext?.groupId).toBe(`mission:${mission.id}`); + // U1: branchContext.groupId carries the real BranchGroup id, not the synthetic `mission:` string. + expect(task?.branchContext?.groupId).toBe(ts.getBranchGroupBySource("mission", mission.id)?.id); + expect(task?.branchContext?.groupId).toMatch(/^BG-/); expect(task?.branchContext?.assignmentMode).toBe("shared"); }); @@ -2512,15 +2518,23 @@ describe("MissionStore", () => { expect(firstTask?.branch).not.toBe("feature/shared"); expect(secondTask?.branch).not.toBe("feature/shared"); expect(firstTask?.branch).not.toBe(secondTask?.branch); - expect(firstTask?.branchContext?.groupId).toBe(`mission:${mission.id}`); - expect(secondTask?.branchContext?.groupId).toBe(`mission:${mission.id}`); + const branchGroup = ts.getBranchGroupBySource("mission", mission.id); + // U1: both members carry the real BranchGroup id so listTasksByBranchGroup(group.id) resolves them. + expect(branchGroup?.id).toMatch(/^BG-/); + expect(firstTask?.branchContext?.groupId).toBe(branchGroup?.id); + expect(secondTask?.branchContext?.groupId).toBe(branchGroup?.id); expect(firstTask?.branchContext?.assignmentMode).toBe("shared"); expect(secondTask?.branchContext?.assignmentMode).toBe("shared"); expect(firstTask?.branchContext?.source).toBe("mission"); expect(secondTask?.branchContext?.source).toBe("mission"); - const branchGroup = ts.getBranchGroupBySource("mission", mission.id); expect(branchGroup?.branchName).toBe("feature/shared"); + + // U1: members enumerate by the real group id. + const members = await ts.listTasksByBranchGroup(branchGroup!.id); + expect(members.map((task) => task.id).sort()).toEqual( + [firstTask!.id, secondTask!.id].sort(), + ); }); it("triageSlice does not inject baseBranch when mission has none", async () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a00a01622f..b448ee560e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js"; +export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskBranchContext, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 70c70ee66b..ac2a040912 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -3812,6 +3812,9 @@ export class MissionStore extends EventEmitter { linkedTaskId = guard.existing.id; } else { let sharedBranchBaseForMission: string | undefined; + // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) + // resolves members. The group is only created in shared mode below. + let missionGroupId = `mission:${missionId}`; if (missionId && resolvedAssignmentMode === "shared") { const settings = await this.taskStore.getSettings(); const settingsDefaultBranch = @@ -3820,10 +3823,11 @@ export class MissionStore extends EventEmitter { : "main"; const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; sharedBranchBaseForMission = resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch; - this.taskStore.ensureBranchGroupForSource("mission", missionId, { + const group = this.taskStore.ensureBranchGroupForSource("mission", missionId, { branchName: sharedBranchBaseForMission, autoMerge: mission?.autoMerge ?? settingsAutoMerge, }); + missionGroupId = group.id; } const taskSegment = feature.id; @@ -3841,7 +3845,7 @@ export class MissionStore extends EventEmitter { ...(missionId ? { branchContext: { - groupId: `mission:${missionId}`, + groupId: missionGroupId, source: "mission" as const, assignmentMode: resolvedAssignmentMode, inheritedBaseBranch: resolvedBaseBranch, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d5ff59dcb9..7bc69d99f0 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; @@ -4431,7 +4431,11 @@ export class TaskStore extends EventEmitter { return this.getBranchGroup(id)!; } - async setTaskBranchGroup(taskId: string, branchGroupId: string | null): Promise { + async setTaskBranchGroup( + taskId: string, + branchGroupId: string | null, + options?: { assignmentMode?: TaskBranchAssignmentMode }, + ): Promise { await this.withTaskLock(taskId, async () => { const dir = this.taskDir(taskId); const task = await this.readTaskJson(dir); @@ -4442,10 +4446,14 @@ export class TaskStore extends EventEmitter { if (!group) { throw new Error(`Branch group ${branchGroupId} not found`); } + // Carry the group's actual assignment intent. The BranchGroup row does not + // persist an assignment mode, so prefer an explicit caller-provided mode, + // then preserve any existing branchContext.assignmentMode, and only fall + // back to "shared" when nothing else is known. branchContext = { groupId: group.id, source: group.sourceType, - assignmentMode: "shared", + assignmentMode: options?.assignmentMode ?? task.branchContext?.assignmentMode ?? "shared", }; } @@ -4466,8 +4474,22 @@ export class TaskStore extends EventEmitter { async listTasksByBranchGroup(groupId: string): Promise { const tasks = await this.listTasks({ includeArchived: false, slim: true }); + // LEGACY SHIM (removable): groups created before the membership-identity fix + // stamped branchContext.groupId with a synthetic string (`planning:` / + // `mission:`) instead of the real `BG-` id. Derive that synthetic form + // from the group's source so those old rows still enumerate. New rows match on the + // real id directly; this fallback can be deleted once no legacy groups remain. + const group = this.getBranchGroup(groupId); + const legacyGroupId = + group && (group.sourceType === "planning" || group.sourceType === "mission") + ? `${group.sourceType}:${group.sourceId}` + : undefined; return tasks - .filter((task) => task.branchContext?.groupId === groupId) + .filter( + (task) => + task.branchContext?.groupId === groupId || + (legacyGroupId !== undefined && task.branchContext?.groupId === legacyGroupId), + ) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 055ff8644b..a9ba673a52 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -2474,11 +2474,12 @@ describe("Planning Mode Routes", () => { const firstCreateCall = (store.createTask as ReturnType).mock.calls[0]?.[0]; const secondCreateCall = (store.createTask as ReturnType).mock.calls[1]?.[0]; + // U1: branchContext.groupId carries the real BranchGroup id, not the synthetic `planning:` string. expect(firstCreateCall).toMatchObject({ branch: "feature/auth-slice/auth-backend", baseBranch: "main", branchContext: { - groupId: `planning:${planningSessionId}`, + groupId: `BG-planning-${planningSessionId}`, source: "planning", assignmentMode: "shared", inheritedBaseBranch: "main", @@ -2488,7 +2489,7 @@ describe("Planning Mode Routes", () => { branch: "feature/auth-slice/auth-ui", baseBranch: "main", branchContext: { - groupId: `planning:${planningSessionId}`, + groupId: `BG-planning-${planningSessionId}`, source: "planning", assignmentMode: "shared", inheritedBaseBranch: "main", @@ -2558,13 +2559,14 @@ describe("Planning Mode Routes", () => { expect(firstCreateCall?.branch).not.toBe("feature/auth-breakdown"); expect(secondCreateCall?.branch).not.toBe("feature/auth-breakdown"); expect(firstCreateCall?.branch).not.toBe(secondCreateCall?.branch); + // U1: branchContext.groupId carries the real BranchGroup id, not the synthetic `planning:` string. expect(firstCreateCall?.branchContext).toMatchObject({ - groupId: `planning:${sessionId}`, + groupId: `BG-planning-${sessionId}`, source: "planning", assignmentMode: "shared", }); expect(secondCreateCall?.branchContext).toMatchObject({ - groupId: `planning:${sessionId}`, + groupId: `BG-planning-${sessionId}`, source: "planning", assignmentMode: "shared", }); diff --git a/packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts b/packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts index 024dd243d8..dd53027513 100644 --- a/packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts +++ b/packages/dashboard/src/__tests__/shared-branch-group-entry-points.test.ts @@ -288,10 +288,13 @@ describe("shared branch-group entry-point invariants", () => { expect(firstPlanning.branch).not.toBe("feature/auth-shared"); expect(secondPlanning.branch).not.toBe("feature/auth-shared"); expect(firstPlanning.branch).not.toBe(secondPlanning.branch); - const planningGroup = (store.getBranchGroupBySource as ReturnType).mock.results.at(-1)?.value as BranchGroup; - expect(firstPlanning.branchContext).toMatchObject({ groupId: `planning:${sessionId}`, source: "planning", assignmentMode: "shared" }); - expect(secondPlanning.branchContext).toMatchObject({ groupId: `planning:${sessionId}`, source: "planning", assignmentMode: "shared" }); - expect(planningGroup.branchName).toBe("feature/auth-shared"); + // U1: the real BG- id is stamped into branchContext.groupId so listTasksByBranchGroup(group.id) resolves members. + const ensuredPlanningGroup = (store.ensureBranchGroupForSource as ReturnType).mock.results.at(-1)?.value as BranchGroup; + expect(ensuredPlanningGroup.id).toBe(`BG-planning-${sessionId}`); + expect(ensuredPlanningGroup.branchName).toBe("feature/auth-shared"); + expect(firstPlanning.branchContext).toMatchObject({ groupId: ensuredPlanningGroup.id, source: "planning", assignmentMode: "shared" }); + expect(secondPlanning.branchContext).toMatchObject({ groupId: ensuredPlanningGroup.id, source: "planning", assignmentMode: "shared" }); + expect(firstPlanning.branchContext?.groupId).not.toBe(`planning:${sessionId}`); const newTask = await REQUEST(app, "POST", "/api/tasks", { title: "Shared entry-point task", diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 8105cbe6ca..ba02502e74 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -210,12 +210,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = resolveBranchSelection(branchSelection, branch, baseBranch); const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); - const planningBranchContext = { - groupId: `planning:${sessionId}`, - source: "planning" as const, - assignmentMode: branchMode, - inheritedBaseBranch: resolvedBaseBranch, - }; + // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) + // resolves members. The group is only created in shared mode below. + let planningGroupId = `planning:${sessionId}`; if (branchMode === "shared") { const settings = await scopedStore.getSettings(); @@ -225,12 +222,22 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann : "main"; const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; const branchGroupStore = scopedStore as { ensureBranchGroupForSource?: TaskStore["ensureBranchGroupForSource"] }; - branchGroupStore.ensureBranchGroupForSource?.("planning", sessionId, { + const group = branchGroupStore.ensureBranchGroupForSource?.("planning", sessionId, { branchName: resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch, autoMerge: session.autoMerge ?? settingsAutoMerge, }); + if (group) { + planningGroupId = group.id; + } } + const planningBranchContext = { + groupId: planningGroupId, + source: "planning" as const, + assignmentMode: branchMode, + inheritedBaseBranch: resolvedBaseBranch, + }; + const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); @@ -1267,12 +1274,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } = resolveBranchSelection(branchSelection, branch, baseBranch); const { mode: branchMode } = resolveBranchAssignmentContext(branchAssignment); - const planningBranchContext = { - groupId: `planning:${planningSessionId}`, - source: "planning" as const, - assignmentMode: branchMode, - inheritedBaseBranch: resolvedBaseBranch, - }; + // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) + // resolves members. The group is only created in shared mode below. + let planningGroupId = `planning:${planningSessionId}`; if (branchMode === "shared") { const settings = await scopedStore.getSettings(); @@ -1282,12 +1286,22 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann : "main"; const settingsAutoMerge = typeof settings.autoMerge === "boolean" ? settings.autoMerge : false; const branchGroupStore = scopedStore as { ensureBranchGroupForSource?: TaskStore["ensureBranchGroupForSource"] }; - branchGroupStore.ensureBranchGroupForSource?.("planning", planningSessionId, { + const group = branchGroupStore.ensureBranchGroupForSource?.("planning", planningSessionId, { branchName: resolvedBranch ?? resolvedBaseBranch ?? settingsDefaultBranch, autoMerge: session.autoMerge ?? settingsAutoMerge, }); + if (group) { + planningGroupId = group.id; + } } + const planningBranchContext = { + groupId: planningGroupId, + source: "planning" as const, + assignmentMode: branchMode, + inheritedBaseBranch: resolvedBaseBranch, + }; + const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); From 88b4b0d5b32125e460a63220c744199a8b01144c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:23:44 -0700 Subject: [PATCH 03/21] fix(FN-branch-group): unify landed/completion predicate in core (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route and coordinator disagreed on landed/complete: the route required mergeConfirmed + matching mergeTargetBranch, the coordinator accepted bare column===done/in-review and never checked the branch. Extract canonical isBranchGroupMemberLanded/isBranchGroupComplete in @fusion/core (stricter route semantics win — load-bearing for merge-target safety) and consume from both sides. Tightens promotion gating to fire only when all members are merge-confirmed onto the group branch. --- .../__tests__/branch-group-completion.test.ts | 87 +++++++++++++++++++ packages/core/src/branch-group-completion.ts | 35 ++++++++ packages/core/src/index.ts | 4 + .../__tests__/routes-branch-groups.test.ts | 26 ++++++ .../routes/register-branch-groups-routes.ts | 16 ++-- .../__tests__/group-merge-coordinator.test.ts | 83 ++++++++++++++---- .../shared-branch-group-lifecycle.test.ts | 2 +- .../engine/src/group-merge-coordinator.ts | 19 ++-- 8 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/__tests__/branch-group-completion.test.ts create mode 100644 packages/core/src/branch-group-completion.ts diff --git a/packages/core/src/__tests__/branch-group-completion.test.ts b/packages/core/src/__tests__/branch-group-completion.test.ts new file mode 100644 index 0000000000..43634f6e5f --- /dev/null +++ b/packages/core/src/__tests__/branch-group-completion.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { isBranchGroupComplete, isBranchGroupMemberLanded } from "../branch-group-completion.js"; +import type { BranchGroup, Task } from "../types.js"; + +const GROUP_BRANCH = "fusion/groups/planning-x"; + +const group = { branchName: GROUP_BRANCH } as Pick; + +function landedMember(): Pick { + return { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: GROUP_BRANCH, + }, + }; +} + +describe("isBranchGroupMemberLanded", () => { + it("returns true when merge is confirmed onto the group branch via integration", () => { + expect(isBranchGroupMemberLanded(landedMember(), group)).toBe(true); + }); + + it("returns false when mergeTargetBranch does not match the group branch", () => { + expect( + isBranchGroupMemberLanded( + { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: "fusion/fn-sibling", + }, + }, + group, + ), + ).toBe(false); + }); + + it("returns false when the merge is not confirmed", () => { + expect( + isBranchGroupMemberLanded( + { + mergeDetails: { + mergeConfirmed: false, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: GROUP_BRANCH, + }, + }, + group, + ), + ).toBe(false); + }); + + it("returns false when the merge target source is not branch-group-integration", () => { + expect( + isBranchGroupMemberLanded( + { + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "project-default", + mergeTargetBranch: GROUP_BRANCH, + }, + }, + group, + ), + ).toBe(false); + }); + + it("returns false when there are no merge details", () => { + expect(isBranchGroupMemberLanded({}, group)).toBe(false); + }); +}); + +describe("isBranchGroupComplete", () => { + it("returns true when every member is landed", () => { + expect(isBranchGroupComplete([landedMember(), landedMember()], group)).toBe(true); + }); + + it("returns false when one member is not landed", () => { + expect(isBranchGroupComplete([landedMember(), {}], group)).toBe(false); + }); + + it("returns false for an empty membership", () => { + expect(isBranchGroupComplete([], group)).toBe(false); + }); +}); diff --git a/packages/core/src/branch-group-completion.ts b/packages/core/src/branch-group-completion.ts new file mode 100644 index 0000000000..024466f076 --- /dev/null +++ b/packages/core/src/branch-group-completion.ts @@ -0,0 +1,35 @@ +import type { BranchGroup, Task } from "./types.js"; + +/** + * Canonical "member landed" predicate, shared by the dashboard branch-groups + * route and the engine group-merge coordinator so the two gates can never + * diverge (the historical divergence: the route required `mergeConfirmed` + + * matching `mergeTargetBranch`, while the coordinator accepted bare + * `column === "done"` or `in-review` + integration source and never checked + * the target branch). + * + * The stricter route semantics win: a member is landed iff it was actually + * merge-confirmed onto THIS group's branch via the branch-group-integration + * path. This is load-bearing for merge-target safety — a member marked done + * against a sibling `fusion/fn-*` branch or a mismatched branch MUST NOT count + * as landed (root cause of the 2026-05-23 lost-work incident). + */ +export function isBranchGroupMemberLanded( + task: Pick, + group: Pick, +): boolean { + return task.mergeDetails?.mergeConfirmed === true + && task.mergeDetails?.mergeTargetSource === "branch-group-integration" + && task.mergeDetails?.mergeTargetBranch === group.branchName; +} + +/** + * Canonical "group complete" predicate. A group is complete iff it has at + * least one member and every member is landed by {@link isBranchGroupMemberLanded}. + */ +export function isBranchGroupComplete( + members: Pick[], + group: Pick, +): boolean { + return members.length > 0 && members.every((member) => isBranchGroupMemberLanded(member, group)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b448ee560e..10717c2115 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -325,6 +325,10 @@ export { type MergeTargetResolution, type MergeTargetResolverOptions, } from "./task-merge.js"; +export { + isBranchGroupMemberLanded, + isBranchGroupComplete, +} from "./branch-group-completion.js"; export { countRecentIdenticalStallEntries, getInReviewStallReason, diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index 727ca2afa8..2ae1436a8b 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; +import { evaluateBranchGroupCompletion } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { request as REQUEST } from "../test-request.js"; @@ -109,6 +110,31 @@ describe("branch group routes", () => { expect(res.status).toBe(400); }); + it("route serialization and coordinator agree on landed/complete for the same fixture", async () => { + // Same fixture exercised through BOTH paths must yield identical results. + const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; + const mixedTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, false)]; + + // Coordinator path. + const completeCoord = evaluateBranchGroupCompletion({ members: completeTasks, group }); + const mixedCoord = evaluateBranchGroupCompletion({ members: mixedTasks, group }); + expect(completeCoord.complete).toBe(true); + expect(mixedCoord.complete).toBe(false); + + // Route serialization path. + const completeApp = buildApp(createStore(group, completeTasks)); + const completeRes = await REQUEST(completeApp, "GET", "/api/branch-groups/BG-1"); + expect(completeRes.body.group.completion.complete).toBe(true); + + const mixedApp = buildApp(createStore(group, mixedTasks)); + const mixedRes = await REQUEST(mixedApp, "GET", "/api/branch-groups/BG-1"); + expect(mixedRes.body.group.completion.complete).toBe(false); + + // No divergence between the two gates. + expect(completeRes.body.group.completion.complete).toBe(completeCoord.complete); + expect(mixedRes.body.group.completion.complete).toBe(mixedCoord.complete); + }); + it("creates group on assign when groupId absent", async () => { const store = createStore(group, tasks); const app = buildApp(store); diff --git a/packages/dashboard/src/routes/register-branch-groups-routes.ts b/packages/dashboard/src/routes/register-branch-groups-routes.ts index 04d1ad8269..2029cd7553 100644 --- a/packages/dashboard/src/routes/register-branch-groups-routes.ts +++ b/packages/dashboard/src/routes/register-branch-groups-routes.ts @@ -1,5 +1,6 @@ import { Router, type Request } from "express"; -import type { BranchGroup, Task, TaskStore } from "@fusion/core"; +import type { BranchGroup, TaskStore } from "@fusion/core"; +import { isBranchGroupComplete, isBranchGroupMemberLanded } from "@fusion/core"; import { badRequest, notFound } from "../api-error.js"; export interface BranchGroupsRouterOptions { @@ -11,19 +12,13 @@ function parseProjectId(req: Request): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } -function isMemberLanded(task: Task, group: BranchGroup): boolean { - return task.mergeDetails?.mergeConfirmed === true - && task.mergeDetails?.mergeTargetSource === "branch-group-integration" - && task.mergeDetails?.mergeTargetBranch === group.branchName; -} - async function serializeGroup(store: TaskStore, group: BranchGroup) { const members = await store.listTasksByBranchGroup(group.id); const memberRows = members.map((task) => ({ taskId: task.id, title: task.title ?? task.description, column: task.column, - landed: isMemberLanded(task, group), + landed: isBranchGroupMemberLanded(task, group), })); const landedCount = memberRows.filter((member) => member.landed).length; return { @@ -32,7 +27,7 @@ async function serializeGroup(store: TaskStore, group: BranchGroup) { completion: { landed: landedCount, total: memberRows.length, - complete: memberRows.length > 0 && landedCount === memberRows.length, + complete: isBranchGroupComplete(members, group), }, }; } @@ -100,8 +95,7 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup if (!group) throw notFound("Branch group not found"); const members = await store.listTasksByBranchGroup(group.id); - const landed = members.filter((member) => isMemberLanded(member, group)).length; - if (members.length === 0 || landed !== members.length) { + if (!isBranchGroupComplete(members, group)) { throw badRequest("Branch group completion gate not satisfied"); } diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index 5ceded3223..4096dd6320 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -30,12 +30,22 @@ afterEach(async () => { }); describe("evaluateBranchGroupCompletion", () => { - it("returns complete when all members are landed", () => { + const branchName = "fusion/groups/planning-x"; + const group = { branchName } as const; + const landed = (id: string) => ({ + id, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + } as any, + }); + + it("returns complete when all members are landed onto the group branch", () => { 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, + members: [landed("FN-A"), landed("FN-B")] as any, + group, }); expect(result).toEqual({ @@ -49,9 +59,10 @@ describe("evaluateBranchGroupCompletion", () => { 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 }, + landed("FN-A"), + { id: "FN-B", column: "todo" as const } as any, ] as any, + group, }); expect(result.complete).toBe(false); @@ -60,7 +71,7 @@ describe("evaluateBranchGroupCompletion", () => { }); it("treats empty groups as incomplete", () => { - const result = evaluateBranchGroupCompletion({ members: [] }); + const result = evaluateBranchGroupCompletion({ members: [], group }); expect(result).toEqual({ complete: false, totalMembers: 0, @@ -69,16 +80,46 @@ describe("evaluateBranchGroupCompletion", () => { }); }); - it("counts mixed done + landed in-review members as complete", () => { + it("does NOT count a member confirmed onto a mismatched branch", () => { 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 }, + landed("FN-A"), + { + id: "FN-B", + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: "fusion/fn-sibling", + } as any, + } as any, ] as any, + group, }); - expect(result.complete).toBe(true); - expect(result.pendingMemberIds).toEqual([]); + expect(result.complete).toBe(false); + expect(result.landedMemberIds).toEqual(["FN-A"]); + expect(result.pendingMemberIds).toEqual(["FN-B"]); + }); + + it("does NOT count a member whose merge is not confirmed", () => { + const result = evaluateBranchGroupCompletion({ + members: [ + { + id: "FN-A", + column: "in-review" as const, + mergeDetails: { + mergeConfirmed: false, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + } as any, + } as any, + ] as any, + group, + }); + + expect(result.complete).toBe(false); + expect(result.pendingMemberIds).toEqual(["FN-A"]); }); }); @@ -191,6 +232,16 @@ describe("promoteBranchGroup", () => { }; } + const landedMember = (id: string, branchName: string) => ({ + id, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + it("returns incomplete without merging when members are pending", async () => { const rootDir = makeRepo(); const group = makeGroup(); @@ -222,7 +273,7 @@ describe("promoteBranchGroup", () => { recordAudit: async (event) => { audits.push(event as Record); }, store: { getBranchGroup: () => group, - listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }], + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], updateBranchGroup: () => { throw new Error("should not update"); }, @@ -251,7 +302,7 @@ describe("promoteBranchGroup", () => { recordAudit: async (event) => { audits.push(event as Record); }, store: { getBranchGroup: () => group, - listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }], + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], updateBranchGroup: (_id: string, patch: Partial) => { group = { ...group, ...patch }; return group; @@ -272,7 +323,7 @@ describe("promoteBranchGroup", () => { recordAudit: async (event) => { audits.push(event as Record); }, store: { getBranchGroup: () => group, - listTasksByBranchGroup: async () => [{ id: "FN-A", column: "done" }], + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], updateBranchGroup: (_id: string, patch: Partial) => { group = { ...group, ...patch }; return group; diff --git a/packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts b/packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts index c35665a669..697f590464 100644 --- a/packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts @@ -215,7 +215,7 @@ describe("FN-5820 reliability interactions: shared branch group lifecycle", () = updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args), listTasksByBranchGroup: async () => { const members = [await store.getTask(task.id), await store.getTask(second.id)].filter(Boolean) as any[]; - expect(evaluateBranchGroupCompletion({ members: members as any }).complete).toBe(true); + expect(evaluateBranchGroupCompletion({ members: members as any, group }).complete).toBe(true); return members as any; }, } as any, diff --git a/packages/engine/src/group-merge-coordinator.ts b/packages/engine/src/group-merge-coordinator.ts index 61d1a7b64f..204c0e511e 100644 --- a/packages/engine/src/group-merge-coordinator.ts +++ b/packages/engine/src/group-merge-coordinator.ts @@ -2,7 +2,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import type { BranchGroup, BranchGroupPrState, MergeTargetResolution, Settings, Task, TaskStore } from "@fusion/core"; -import { resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core"; +import { isBranchGroupMemberLanded, resolveEffectiveGroupAutoMerge, resolveTaskMergeTarget } from "@fusion/core"; import { resolveIntegrationBranch } from "./integration-branch.js"; const execAsync = promisify(exec); @@ -41,16 +41,25 @@ export interface BranchGroupPromotionDecision { | "eligible"; } +/** + * Evaluates branch-group completion using the canonical `@fusion/core` + * `isBranchGroupMemberLanded` predicate so the engine gate can never diverge + * from the dashboard route gate. A member is landed iff it was merge-confirmed + * onto THIS group's branch via the branch-group-integration path; the group is + * complete iff it has at least one member and every member is landed. + * + * `group` (its `branchName`) is required: landing is branch-anchored, so a + * member done against a sibling/mismatched branch must NOT count as landed. + */ export function evaluateBranchGroupCompletion(input: { members: Pick[]; + group: Pick; }): 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) { + if (isBranchGroupMemberLanded(member, input.group)) { landedMemberIds.push(member.id); } else { pendingMemberIds.push(member.id); @@ -159,7 +168,7 @@ export async function promoteBranchGroup(input: { } const members = await input.store.listTasksByBranchGroup(group.id); - const completion = evaluateBranchGroupCompletion({ members }); + const completion = evaluateBranchGroupCompletion({ members, group }); if (!completion.complete) { return { groupId: group.id, From cad44b1f56a1b4557405f688191f9777311dccc0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:40:30 -0700 Subject: [PATCH 04/21] fix(FN-5846): commit-ownership-anchor already-merged attribution (U3) Audit of all shared-member merge + self-healing finalize paths: routing, merger finalize-success, and the 6 self-healing recovery paths were already group-branch-safe (FN-5846). Found a residual of the 2026-05-23 lost-work incident bug #2: already-merged-detector's ancestry strategy used bare git log --grep first-hit, and the ownership regex made the conventional scope optional (bare 'feat:' matched). Anchor attribution on trailers or task-scoped subject; scan candidates instead of accepting the first grep hit. Adds real-git characterization tests. --- .../fn-5846-shared-group-merge-routing.md | 2 +- .../already-merged-detector.real-git.test.ts | 143 ++++++++++++++++++ .../branch-group-merge-routing.test.ts | 55 +++++++ .../engine/src/__tests__/self-healing.test.ts | 11 ++ .../engine/src/already-merged-detector.ts | 66 +++++++- packages/engine/src/self-healing.ts | 8 +- 6 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 packages/engine/src/__tests__/already-merged-detector.real-git.test.ts diff --git a/.changeset/fn-5846-shared-group-merge-routing.md b/.changeset/fn-5846-shared-group-merge-routing.md index 4efc18ddc0..65aa0efd9a 100644 --- a/.changeset/fn-5846-shared-group-merge-routing.md +++ b/.changeset/fn-5846-shared-group-merge-routing.md @@ -2,4 +2,4 @@ "@runfusion/fusion": patch --- -Fix shared-branch-group member finalization so routed members land on the group's shared branch instead of being auto-finalized against the project default branch. +Fix shared-branch-group member finalization so routed members land on the group's shared branch instead of being auto-finalized against the project default branch. Also harden already-landed commit attribution so the recovery detector never claims a commit that merely mentions a task ID in prose (2026-05-23 lost-work regression): the `git log --grep` ancestry fallback is now ownership-anchored on a Fusion trailer or a task-scoped conventional-commit subject. diff --git a/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts b/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts new file mode 100644 index 0000000000..fa8289aca0 --- /dev/null +++ b/packages/engine/src/__tests__/already-merged-detector.real-git.test.ts @@ -0,0 +1,143 @@ +// Real-git characterization of findAlreadyMergedTaskCommit's ownership +// anchoring. These tests pin the 2026-05-23 lost-work incident's bug #2: +// the detector must NOT attribute a task to a commit that merely *mentions* +// the task ID in prose (the historical `git log --grep` first-hit bug). +import { afterEach, describe, expect, it } from "vitest"; +import { execSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { findAlreadyMergedTaskCommit } from "../already-merged-detector.js"; + +const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; +const describeIfGit = hasGit ? describe : describe.skip; + +function git(repo: string, command: string): string { + return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); +} + +describeIfGit("findAlreadyMergedTaskCommit ownership anchoring (real git)", () => { + const repos: string[] = []; + + afterEach(() => { + for (const repo of repos.splice(0)) { + rmSync(repo, { recursive: true, force: true }); + } + }); + + function setupRepo(): string { + const repo = mkdtempSync(path.join(os.tmpdir(), "fn-amd-")); + repos.push(repo); + git(repo, "git init -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test"'); + git(repo, "git commit --allow-empty -m 'init'"); + return repo; + } + + it("attributes via trailer when the owned commit carries Fusion-Task-Id", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "owned.txt"), "owned\n", "utf-8"); + git(repo, "git add src/owned.txt && git commit -m 'feat: landed work' -m 'Fusion-Task-Id: FN-AMD-1'"); + const landedSha = git(repo, "git rev-parse HEAD"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-1", + repoDir: repo, + baseBranch: "main", + }); + + expect(result).not.toBeNull(); + expect(result!.sha).toBe(landedSha); + expect(result!.strategy).toBe("trailer"); + }); + + it("attributes via lineage trailer when present", async () => { + const repo = setupRepo(); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "lineage.txt"), "lineage\n", "utf-8"); + git(repo, "git add src/lineage.txt && git commit -m 'feat: lineage work' -m 'Fusion-Task-Lineage: LINEAGE-XYZ'"); + const landedSha = git(repo, "git rev-parse HEAD"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-LIN", + lineageId: "LINEAGE-XYZ", + repoDir: repo, + baseBranch: "main", + }); + + expect(result).not.toBeNull(); + expect(result!.sha).toBe(landedSha); + expect(result!.strategy).toBe("trailer"); + }); + + // Incident bug #2 regression: a commit that merely *mentions* the task ID in + // its prose body (no anchored trailer) must NOT be attributed to the task, + // even when the task's own branch tip is already an ancestor of base. The + // ancestry `git log --grep=` strategy historically accepted the first + // such prose-mention hit and stranded/mis-attributed work. + it("does NOT attribute to a commit that only mentions the task ID in prose (ancestry path)", async () => { + const repo = setupRepo(); + + // An unrelated commit whose BODY mentions FN-AMD-2 in prose only. + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "unrelated.txt"), "unrelated\n", "utf-8"); + git( + repo, + "git add src/unrelated.txt && git commit -m 'feat: unrelated change' -m 'This also touches things related to FN-AMD-2 in passing.'", + ); + + // The task's own branch landed by being merged into main, but its commits + // carry NO trailer and NO conventional-subject anchor — only a generic + // message — so the only `--grep=FN-AMD-2` hit is the prose-mention above. + git(repo, "git checkout -b fusion/fn-amd-2"); + writeFileSync(path.join(repo, "src", "task.txt"), "task work\n", "utf-8"); + git(repo, "git add src/task.txt && git commit -m 'wip: generic message with no anchor'"); + git(repo, "git checkout main"); + git(repo, "git merge --no-ff --no-edit fusion/fn-amd-2 -m 'merge generic branch'"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-2", + repoDir: repo, + baseBranch: "main", + taskBranch: "fusion/fn-amd-2", + }); + + // It may legitimately attribute via patch-id/tree-equal to the REAL owned + // content, but it must NEVER return the unrelated prose-mention commit. + if (result && result.strategy === "ancestry") { + const subject = git(repo, `git show -s --format=%s ${result.sha}`); + const body = git(repo, `git show -s --format=%b ${result.sha}`); + const ownedBySubject = /^(?:[A-Za-z]+\([^)]*FN-AMD-2[^)]*\):|FN-AMD-2:)/.test(subject); + const ownedByTrailer = /(?:^|\n)Fusion-Task-Id: FN-AMD-2\s*(?:\n|$)/.test(body); + expect(ownedBySubject || ownedByTrailer).toBe(true); + } + }); + + it("attributes via ancestry when the landed commit carries a conventional-subject anchor", async () => { + const repo = setupRepo(); + + // The merge into main carries a conventional subject anchored on the task + // ID; ancestry attribution should accept it (it is genuinely owned). + git(repo, "git checkout -b fusion/fn-amd-3"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "anchored.txt"), "anchored\n", "utf-8"); + git(repo, "git add src/anchored.txt && git commit -m 'feat(FN-AMD-3): real anchored work'"); + git(repo, "git checkout main"); + git(repo, "git merge --ff-only fusion/fn-amd-3"); + + const result = await findAlreadyMergedTaskCommit({ + taskId: "FN-AMD-3", + repoDir: repo, + baseBranch: "main", + taskBranch: "fusion/fn-amd-3", + }); + + expect(result).not.toBeNull(); + // Trailer path won't match (no trailer); ownership-anchored ancestry should. + const subject = git(repo, `git show -s --format=%s ${result!.sha}`); + expect(subject).toContain("FN-AMD-3"); + }); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts b/packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts index cc11e8ab7d..fe13539c95 100644 --- a/packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts @@ -74,6 +74,61 @@ describe("FN-5782 reliability interactions: branch group merge routing", () => { } }, 30_000); + it.skipIf(!hasGit)("routes a shared member to the group branch even when it inherited a sibling fusion/fn-* baseBranch (lost-work regression)", async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-SIBLING", settings: { testMode: true } as any }); + + try { + const { rootDir, store, task } = fixture; + await stageMergeBranch(store, rootDir, task.id, "fn5782SiblingInherit"); + + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-FN5782-SIBLING", + branchName: "fusion/groups/fn-5782-sibling", + }); + await store.setTaskBranchGroup(task.id, group.id); + + // 2026-05-23 lost-work shape: a shared member inherited a sibling + // `fusion/fn-*` branch as its base/inherited base (propagated from a + // sibling-dispatched parent). The resolver MUST still land it on the + // group branch, never on the sibling, and never on main. + await store.updateTask(task.id, { + baseBranch: "fusion/fn-9999-sibling-parent", + branchContext: { + groupId: group.id, + source: "planning", + assignmentMode: "shared", + inheritedBaseBranch: "fusion/fn-9999-sibling-parent", + }, + } as any); + + const auditSpy = vi.spyOn(store as any, "recordRunAuditEvent"); + const result = await aiMergeTask(store, rootDir, task.id); + expect(result.merged).toBe(true); + + // Landed on the group branch; NOT on the sibling, NOT on main. + expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782SiblingInherit.ts`)).toContain("fn5782SiblingInherit"); + expect(() => git(rootDir, "git show main:packages/engine/src/fn5782SiblingInherit.ts")).toThrow(); + expect(() => git(rootDir, "git show fusion/fn-9999-sibling-parent:packages/engine/src/fn5782SiblingInherit.ts")).toThrow(); + + const recovered = await store.getTask(task.id); + expect(recovered?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration"); + expect(recovered?.mergeDetails?.mergeTargetBranch).toBe(group.branchName); + + expect(auditSpy).toHaveBeenCalledWith(expect.objectContaining({ + domain: "git", + mutationType: "merge:branch-group-routed", + target: task.id, + metadata: expect.objectContaining({ + mergeTargetBranch: group.branchName, + mergeTargetSource: "branch-group-integration", + }), + })); + } finally { + await fixture.cleanup(); + } + }, 45_000); + it.skipIf(!hasGit)("records shared-member landing even when autoMerge is false", async () => { const fixture = await makeReliabilityFixture({ taskId: "FN-5819-RI-AUTO-OFF", diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 18e547aa53..9e8f86b31c 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -2772,6 +2772,12 @@ describe("SelfHealingManager", () => { if (cmd.includes("Fusion-Task-Id: FN-2900")) { return "trailerSha123feat: ship something opaque\n" as any; } + // Ownership-verification body fetch (FN-5441/5446): the real commit + // located via trailer grep carries the anchored trailer in its body, + // so commitOwnedByTask accepts it though the subject lacks the task ID. + if (cmd.includes("--format=%b") && cmd.includes("trailerSha123")) { + return "Fusion-Task-Id: FN-2900\n" as any; + } if (cmd.includes("--fixed-strings")) return "" as any; } if (cmd.includes("git show --shortstat")) { @@ -2829,6 +2835,11 @@ describe("SelfHealingManager", () => { if (cmd.includes("git log") && cmd.includes("Fusion-Task-Id: FN-2901")) { return "rangeSha901\u001ffeat: ship something opaque\n" as any; } + // Ownership-verification body fetch (FN-5441/5446): real trailer-grep + // hit carries the anchored trailer in its body. + if (cmd.includes("git log") && cmd.includes("--format=%b") && cmd.includes("rangeSha901")) { + return "Fusion-Task-Id: FN-2901\n" as any; + } if (cmd.includes("git diff --shortstat") && cmd.includes("rebasebase901..rangeSha901")) { return " 4 files changed, 104 insertions(+), 1 deletion(-)\n" as any; } diff --git a/packages/engine/src/already-merged-detector.ts b/packages/engine/src/already-merged-detector.ts index e47127f815..b222688715 100644 --- a/packages/engine/src/already-merged-detector.ts +++ b/packages/engine/src/already-merged-detector.ts @@ -34,6 +34,49 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Ownership anchor shared with self-healing's `commitOwnedByTask`. + * + * The 2026-05-23 lost-work incident (bug #2) was a `git log --grep=` + * first-hit attribution: a commit whose body merely *mentioned* a task ID in + * prose was accepted as that task's landed commit, stranding/mis-attributing + * the real work. The trailer strategies above are already anchored; the + * ancestry strategy below uses a loose `--grep=`, so its candidate must + * be ownership-verified here before it is accepted. + * + * Accept when ANY of: + * - `Fusion-Task-Lineage: ` is a complete trailer line in the body + * - `Fusion-Task-Id: ` is a complete trailer line in the body + * - the subject is anchored on the task ID in conventional-commit form: + * `(...): …` or `: …` + */ +function commitOwnedByTask( + taskId: string, + lineageId: string | undefined, + subject: string, + body: string, +): boolean { + if (lineageId && new RegExp(`(?:^|\\n)Fusion-Task-Lineage: ${escapeRegex(lineageId)}\\s*(?:\\n|$)`).test(body)) { + return true; + } + if (new RegExp(`(?:^|\\n)Fusion-Task-Id: ${escapeRegex(taskId)}\\s*(?:\\n|$)`).test(body)) { + return true; + } + // Subject anchor MUST mention the task ID — either inside a conventional + // scope (`(<…taskId…>): …`) or as a leading `: …`. The scope + // group is intentionally NOT optional here: a bare `feat: …` with no task ID + // is NOT ownership evidence (a prose commit such as `feat: unrelated change` + // whose body merely mentions the task must be rejected — incident bug #2). + const subjectAnchor = new RegExp( + `^(?:[A-Za-z]+\\([^)]*\\b${escapeRegex(taskId)}\\b[^)]*\\):|${escapeRegex(taskId)}:)`, + ); + return subjectAnchor.test(subject); +} + export async function findAlreadyMergedTaskCommit( input: AlreadyMergedLookupInput, ): Promise { @@ -97,22 +140,33 @@ export async function findAlreadyMergedTaskCommit( stdio: ["pipe", "pipe", "pipe"], }); + // FN-5441/5446 (2026-05-23 lost-work bug #2): `--grep=` is a loose + // match that also hits commits merely mentioning the task ID in prose. + // Gather candidates (bounded) and accept only the first whose subject/body + // is OWNERSHIP-anchored on the task — never the first raw grep hit. const ancestryCommand = [ "git log", "--first-parent", - "--format=%H", + "--format=%H%x1f%s%x1f%b%x1e", `--grep=${shellQuote(taskId)}`, - "--max-count=1", + "--max-count=20", shellQuote(baseBranch), ].join(" "); const { stdout } = await execAsync(ancestryCommand, { cwd: repoDir, timeout: 30_000, - maxBuffer: 1024 * 1024, + maxBuffer: 4 * 1024 * 1024, }); - const sha = stdout.trim(); - if (sha) { - return { sha, strategy: "ancestry" }; + const records = stdout + .split("\x1e") + .map((record) => record.trim()) + .filter((record) => record.length > 0); + for (const record of records) { + const [candidateSha, candidateSubject = "", candidateBody = ""] = record.split("\x1f"); + const sha = candidateSha?.trim(); + if (sha && commitOwnedByTask(taskId, lineageId, candidateSubject, candidateBody)) { + return { sha, strategy: "ancestry" }; + } } } catch { // Fall through to patch-id checks. diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 4f73c4410a..fa9834489a 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -493,9 +493,13 @@ function commitOwnedByTask(taskId: string, lineageId: string | undefined, subjec if (new RegExp(`(?:^|\\n)Fusion-Task-Id: ${escapeRegex(taskId)}\\s*(?:\\n|$)`).test(body)) { return true; } - // Subject anchor: `(...): …` or `: …` at start. + // Subject anchor: `(<…taskId…>): …` or `: …` at start. + // The conventional scope group is intentionally NOT optional: a bare + // `: …` (e.g. `feat: unrelated change`) carries no task ID and is NOT + // ownership evidence, even if the body mentions the task in prose (incident + // bug #2 — a prose-mention must never claim a task). const subjectAnchor = new RegExp( - `^(?:[A-Za-z]+(?:\\([^)]*\\b${escapeRegex(taskId)}\\b[^)]*\\))?:|${escapeRegex(taskId)}:)`, + `^(?:[A-Za-z]+\\([^)]*\\b${escapeRegex(taskId)}\\b[^)]*\\):|${escapeRegex(taskId)}:)`, ); return subjectAnchor.test(subject); } From 508b9c44d0c280a4eecbb20615db506bb9a6484b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 09:46:54 -0700 Subject: [PATCH 05/21] fix(FN-branch-group): add engine.promoteBranchGroup bridge method (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard promote route called engine.promoteBranchGroup(groupId) as a method that never existed — only a standalone coordinator function did — so the route was dead, masked by a vi.fn mock in the test. Add the real method on ProjectEngine delegating to the coordinator (resolving store/cwd/settings like attemptBranchGroupPromotion), and de-mock the test so it now fails if the method goes missing. No PR-creation behavior yet (U5). --- .../__tests__/routes-branch-groups.test.ts | 76 ++++++++++++--- .../__tests__/group-merge-coordinator.test.ts | 97 +++++++++++++++++++ packages/engine/src/project-engine.ts | 41 +++++++- 3 files changed, 202 insertions(+), 12 deletions(-) diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index 2ae1436a8b..7ba82cf6fa 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; -import { evaluateBranchGroupCompletion } from "@fusion/engine"; +import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { request as REQUEST } from "../test-request.js"; @@ -96,18 +96,72 @@ describe("branch group routes", () => { expect((store.setTaskBranchGroup as unknown as ReturnType)).toHaveBeenLastCalledWith("FN-1", null); }); - it("promotes completed groups and rejects incomplete groups", async () => { - const promoteBranchGroup = vi.fn(async () => ({ prNumber: 202, prUrl: "https://example/pr/202", prState: "open", status: "open" })); - const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; - let app = buildApp(createStore(group, completeTasks), promoteBranchGroup); - let res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); - expect(res.status).toBe(200); - expect(promoteBranchGroup).toHaveBeenCalledWith("BG-1"); - expect(res.body.prNumber).toBe(202); + it("exposes a real, callable promoteBranchGroup method on the engine class (regression guard)", () => { + // U4: the dashboard promote route reaches engine.promoteBranchGroup AS A + // METHOD. If that method ever goes missing from ProjectEngine, this fails + // instead of being silently masked by a route-level vi.fn mock. + expect(typeof (ProjectEngine.prototype as { promoteBranchGroup?: unknown }).promoteBranchGroup).toBe("function"); + }); - app = buildApp(createStore(group, tasks), promoteBranchGroup); - res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + it("promotes a completed group by reaching the real engine method (not a hand-rolled mock)", async () => { + // Drive the route through the ACTUAL ProjectEngine.promoteBranchGroup body + // bound to a stub context, so the wiring proves it reaches a real, callable + // method that delegates to the coordinator — not a fabricated vi.fn. + const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; + + const finalizedGroup: BranchGroup = { ...group, status: "finalized", prState: "merged" }; + const engineStore = { + getSettings: vi.fn(async () => ({ + autoMerge: false, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request", + })), + getBranchGroup: vi.fn(() => finalizedGroup), + listTasksByBranchGroup: vi.fn(async () => completeTasks), + updateBranchGroup: vi.fn(() => finalizedGroup), + recordRunAuditEvent: vi.fn(async () => {}), + }; + // Minimal ProjectEngine-shaped context the real method body reads. + const engineContext = { + runtime: { getTaskStore: () => engineStore }, + config: { workingDirectory: "/tmp/project" }, + }; + // Bind the REAL method (the same one the dashboard route invokes). + const realPromote = (ProjectEngine.prototype as unknown as { + promoteBranchGroup: (this: unknown, groupId: string) => Promise>; + }).promoteBranchGroup; + const boundPromote = ((groupId: string) => + realPromote.call(engineContext, groupId)) as unknown as ReturnType; + + const app = buildApp(createStore(group, completeTasks), boundPromote); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + // already-finalized group → method short-circuits before any git work and + // returns the persisted state; what matters is the route reached the method. + expect(res.status).toBe(200); + expect(res.body.groupId).toBe("BG-1"); + expect(res.body.reason).toBe("already-finalized"); + expect(engineStore.getBranchGroup).toHaveBeenCalledWith("BG-1"); + }); + + it("rejects promotion of an incomplete group at the completion gate (no engine call)", async () => { + const realPromote = (ProjectEngine.prototype as unknown as { + promoteBranchGroup: (this: unknown, groupId: string) => Promise>; + }).promoteBranchGroup; + const promoteSpy = vi.fn((groupId: string) => realPromote.call({}, groupId)); + const app = buildApp(createStore(group, tasks), promoteSpy as unknown as ReturnType); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); expect(res.status).toBe(400); + expect(promoteSpy).not.toHaveBeenCalled(); + }); + + it("surfaces the error path when the engine lacks a promoteBranchGroup method", async () => { + // If the bridge method is missing from the resolved engine, the route's + // option callback throws "promoteBranchGroup is not available on engine". + const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; + const app = buildApp(createStore(group, completeTasks), undefined); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBeGreaterThanOrEqual(400); }); it("route serialization and coordinator agree on landed/complete for the same fixture", async () => { diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index 4096dd6320..ff1f3e084b 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -11,6 +11,7 @@ import { promoteBranchGroup, resolveBranchGroupMergeRouting, } from "../group-merge-coordinator.js"; +import { ProjectEngine } from "../project-engine.js"; const dirs: string[] = []; @@ -336,6 +337,102 @@ describe("promoteBranchGroup", () => { }); }); +describe("ProjectEngine.promoteBranchGroup (U4 bridge method)", () => { + // The dashboard promote route calls engine.promoteBranchGroup AS A METHOD. + // These tests invoke the REAL method body bound to a minimal engine-shaped + // context, proving it resolves store/rootDir/settings and delegates to the + // standalone coordinator — without standing up a full ProjectEngine. + const realPromote = ProjectEngine.prototype.promoteBranchGroup; + + function makeGroup(overrides?: Partial) { + 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, + }; + } + + const landedMember = (id: string, branchName: string) => ({ + id, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + + function makeEngineContext(rootDir: string, store: unknown, settings: Record) { + const getSettingsCalls = { count: 0 }; + const fullStore = { + ...(store as Record), + getSettings: async () => { + getSettingsCalls.count += 1; + return settings; + }, + recordRunAuditEvent: async () => {}, + }; + return { + context: { + runtime: { getTaskStore: () => fullStore }, + config: { workingDirectory: rootDir }, + }, + getSettingsCalls, + }; + } + + it("resolves settings via the store and delegates to the coordinator (promotes a complete group)", 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 { context, getSettingsCalls } = makeEngineContext(rootDir, { + getBranchGroup: () => group, + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], + updateBranchGroup: (_id: string, patch: Partial) => { + group = { ...group, ...patch }; + return group; + }, + }, { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }); + + const result = await realPromote.call(context as any, "BG-1"); + + expect(getSettingsCalls.count).toBe(1); + expect(result.promoted).toBe(true); + expect(result.reason).toBe("promoted"); + expect(group.status).toBe("finalized"); + expect(execSync("git show main:group.txt", { cwd: rootDir, encoding: "utf8" })).toContain("promoted"); + }); + + it("rejects an incomplete group at the coordinator completion gate", async () => { + const rootDir = makeRepo(); + const group = makeGroup(); + const { context } = makeEngineContext(rootDir, { + getBranchGroup: () => group, + listTasksByBranchGroup: async () => [{ id: "FN-A", column: "todo" }], + updateBranchGroup: () => { + throw new Error("should not update an incomplete group"); + }, + }, { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }); + + const result = await realPromote.call(context as any, "BG-1"); + + expect(result.reason).toBe("incomplete"); + expect(result.promoted).toBe(false); + expect(() => execSync("git show main:group.txt", { cwd: rootDir })).toThrow(); + }); +}); + describe("resolveBranchGroupMergeRouting", () => { it("returns null for non-shared tasks", async () => { const routing = await resolveBranchGroupMergeRouting({ diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index cfa8d5ca20..ea77b1081b 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -27,7 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js"; import { runAiMerge } from "./merger-ai.js"; -import { promoteBranchGroup } from "./group-merge-coordinator.js"; +import { promoteBranchGroup, type BranchGroupPromotionResult } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; @@ -924,6 +924,45 @@ export class ProjectEngine { return this.internalEnqueueMerge(taskId); } + /** + * Promote a shared branch group: merge the group branch into the integration + * branch and reconcile `prState` (completion-gated, idempotent). + * + * This is the single engine bridge method (KTD5) that the dashboard promote + * route reaches via the `promoteBranchGroup` option callback in + * `register-integrated-routers.ts`. It resolves the same store / rootDir / + * settings context the internal auto-promotion path (`attemptBranchGroupPromotion`) + * uses and delegates to the standalone coordinator function — no logic is + * duplicated here. + */ + async promoteBranchGroup(groupId: string): Promise { + const store = this.runtime.getTaskStore(); + const cwd = this.config.workingDirectory; + const settings = await store.getSettings(); + const promotionSettings = { + autoMerge: settings.autoMerge, + globalPause: settings.globalPause, + enginePaused: settings.enginePaused, + mergeStrategy: settings.mergeStrategy, + integrationBranch: settings.integrationBranch, + baseBranch: settings.baseBranch, + }; + return await promoteBranchGroup({ + store, + rootDir: cwd, + 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); + }, + }); + } + /** * Perform an AI-powered merge for a task, serialized through the merge queue. * This is the manual "merge now" path — it shares the same queue as auto-merge From b1454c198e3631aa8c72f5fbe69fb400b851ca0d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:01:53 -0700 Subject: [PATCH 06/21] feat(FN-branch-group): create single real GitHub PR on group promotion (U5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group promotion in PR mode previously flipped prState to 'open' without ever calling GitHub — prNumber/prUrl were never populated. Add an injected CreateGroupPrFn (mirrors the processPullRequestMerge seam, no engine→dashboard import): coordinator creates-or-reuses exactly one PR per group, persists prNumber/prUrl/prState, and leaves state untouched on GitHub failure so re-promotion retries. Idempotent via persisted prNumber + getBranchGroupByBranchName. Wired at all three CLI engine-construction sites (daemon/dashboard/serve). --- .changeset/fn-branch-group-single-pr.md | 5 + .../cli/src/commands/__tests__/daemon.test.ts | 1 + .../cli/src/commands/__tests__/serve.test.ts | 1 + packages/cli/src/commands/daemon.ts | 2 + packages/cli/src/commands/dashboard.ts | 2 + packages/cli/src/commands/serve.ts | 2 + packages/cli/src/commands/task-lifecycle.ts | 38 +++- .../__tests__/github-create-group-pr.test.ts | 131 +++++++++++ packages/dashboard/src/github.ts | 87 +++++++- packages/dashboard/src/index.ts | 2 +- .../__tests__/group-merge-coordinator.test.ts | 206 ++++++++++++++++++ .../engine/src/group-merge-coordinator.ts | 76 ++++++- packages/engine/src/index.ts | 1 + packages/engine/src/project-engine-manager.ts | 2 + packages/engine/src/project-engine.ts | 12 +- 15 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-branch-group-single-pr.md create mode 100644 packages/dashboard/src/__tests__/github-create-group-pr.test.ts diff --git a/.changeset/fn-branch-group-single-pr.md b/.changeset/fn-branch-group-single-pr.md new file mode 100644 index 0000000000..81cbe2dbf1 --- /dev/null +++ b/.changeset/fn-branch-group-single-pr.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Branch-group promotion now creates a single real GitHub PR for the group integration branch when promoting a completed PR-mode group. The PR number/url/state are persisted on the branch group and promotion is idempotent — re-running never opens a second PR (an existing persisted or open PR is reused). The GitHub client is injected into the engine via the same option-callback seam as `processPullRequestMerge`, wired at the `fn daemon`, `fn dashboard`, and `fn serve` construction sites. PR creation only happens for eligible (completion-gated, auto-merge-allowed) groups, and a GitHub failure leaves the group recoverable rather than persisting a false PR state. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index b34295b8a6..57db955bcb 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -640,6 +640,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ vi.mock("../task-lifecycle.js", () => ({ getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), + createGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 068d3fa7d2..86911ae9a9 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -694,6 +694,7 @@ vi.mock("../port-prompt.js", () => ({ vi.mock("../task-lifecycle.js", () => ({ getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), + createGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 8b6c065443..3826827f3a 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -42,6 +42,7 @@ import { import { getMergeStrategy, processPullRequestMergeTask, + createGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; @@ -334,6 +335,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), + createGroupPr: createGroupPrCallback(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), }); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 16e3ad4bd8..9663e6d82d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -46,6 +46,7 @@ import { getMergeStrategy, getTaskBranchName, processPullRequestMergeTask, + createGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -1559,6 +1560,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), + createGroupPr: createGroupPrCallback(githubClient), getTaskMergeBlocker, }); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3d475f955a..32b022c04d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -42,6 +42,7 @@ import { import { getMergeStrategy, processPullRequestMergeTask, + createGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; @@ -360,6 +361,7 @@ export async function runServe( getMergeStrategy, processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), + createGroupPr: createGroupPrCallback(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), }); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 2e867a480d..eaa277003e 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -20,7 +20,7 @@ import type { TaskStore } from "@fusion/core"; import { resolveTaskMergeTarget } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine"; -import type { WorktreePool } from "@fusion/engine"; +import type { CreateGroupPrFn, WorktreePool } from "@fusion/engine"; /** * Minimal interface for GitHub operations needed by the PR merge workflow. @@ -163,6 +163,42 @@ function toBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState { return "open"; } +/** + * Build the `createGroupPr` engine callback (KTD7) used by the branch-group + * promotion coordinator. Closes over a GitHub client so the engine never imports + * the dashboard client directly. Pushes the group integration branch to origin + * (so `gh pr create --head` / the REST API can find it), then creates or reuses + * the single managed PR for the group. + * + * Idempotency: reuses an existing PR for the group head branch on GitHub. The + * coordinator additionally skips this call when a `prNumber` is already persisted, + * so a re-promotion never opens a second PR. + */ +export function createGroupPrCallback( + github: Pick, +): CreateGroupPrFn { + return async ({ cwd, group, members, headBranch, baseBranch }) => { + const existing = await github.findPrForBranch({ head: headBranch, state: "all" }); + if (existing) { + return { prNumber: existing.number, prUrl: existing.url, prState: toBranchGroupPrState(existing) }; + } + + await pushTaskBranchToOrigin(cwd, headBranch); + const membersWithBranch = members.map((member) => ({ + id: member.id, + title: member.title, + branchName: getTaskBranchName(member.id), + })); + const created = await github.createPr({ + title: buildGroupPullRequestTitle(group, members), + body: buildGroupPullRequestBody(group, membersWithBranch), + head: headBranch, + base: baseBranch, + }); + return { prNumber: created.number, prUrl: created.url, prState: toBranchGroupPrState(created) }; + }; +} + async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise { try { const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 }); diff --git a/packages/dashboard/src/__tests__/github-create-group-pr.test.ts b/packages/dashboard/src/__tests__/github-create-group-pr.test.ts new file mode 100644 index 0000000000..fdc1c0f11f --- /dev/null +++ b/packages/dashboard/src/__tests__/github-create-group-pr.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGh: vi.fn(), + runGhAsync: vi.fn(), + runGhJson: vi.fn(), + runGhJsonAsync: vi.fn(), + getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))), + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + }; +}); + +import { runGh, runGhJsonAsync } from "@fusion/core"; +import { GitHubClient, createGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody } from "../github.js"; + +const mockRunGh = vi.mocked(runGh); +const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); + +const group = { + id: "BG-1", + branchName: "fusion/groups/planning-x", + sourceType: "planning" as const, + sourceId: "PS-1", +}; +const members = [ + { id: "FN-A", title: "Alpha" }, + { id: "FN-B", title: "Beta" }, +]; + +describe("createGroupPullRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a PR via the gh-CLI backend and returns persisted shape", async () => { + // findPrForBranch (gh): no existing PR. + mockRunGhJsonAsync.mockResolvedValueOnce([] as any); + // createPr (gh): returns the PR url on stdout. + mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/55\n"); + const client = new GitHubClient({ forceMode: "gh-cli" }); + + const result = await createGroupPullRequest(client, { + group, + members, + headBranch: group.branchName, + baseBranch: "main", + }); + + expect(result).toEqual({ + prNumber: 55, + prUrl: "https://github.com/owner/repo/pull/55", + prState: "open", + }); + const createArgs = mockRunGh.mock.calls[0][0]; + expect(createArgs).toEqual(expect.arrayContaining(["pr", "create", "--head", group.branchName, "--base", "main"])); + }); + + it("creates a PR via the REST API backend and returns persisted shape", async () => { + const client = new GitHubClient({ token: "ghp_token", forceMode: "token" }); + const fetchSpy = vi.spyOn(global, "fetch" as any) + // findPrForBranch (API): empty list. + .mockResolvedValueOnce({ ok: true, json: async () => [] } as any) + // createPr (API). + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + number: 77, + html_url: "https://github.com/owner/repo/pull/77", + title: "T", + state: "open", + head: { ref: group.branchName }, + base: { ref: "main" }, + comments: 0, + }), + } as any); + + const result = await createGroupPullRequest(client, { + group, + members, + headBranch: group.branchName, + baseBranch: "main", + }); + + expect(result).toEqual({ + prNumber: 77, + prUrl: "https://github.com/owner/repo/pull/77", + prState: "open", + }); + fetchSpy.mockRestore(); + }); + + it("reuses an existing open PR instead of creating a second one (idempotent)", async () => { + mockRunGhJsonAsync.mockResolvedValueOnce([ + { number: 12, url: "https://github.com/owner/repo/pull/12", title: "T", state: "OPEN", baseRefName: "main", headRefName: group.branchName, mergedAt: null }, + ] as any); + const client = new GitHubClient({ forceMode: "gh-cli" }); + + const result = await createGroupPullRequest(client, { + group, + members, + headBranch: group.branchName, + baseBranch: "main", + }); + + expect(result).toEqual({ + prNumber: 12, + prUrl: "https://github.com/owner/repo/pull/12", + prState: "open", + }); + // createPr must NOT have been called. + expect(mockRunGh).not.toHaveBeenCalled(); + }); +}); + +describe("group PR title/body builders", () => { + it("title includes the group id, source, and member count", () => { + expect(buildGroupPullRequestTitle(group, members)).toBe("BG-1: planning/PS-1 (2 tasks)"); + }); + + it("body lists every member task", () => { + const body = buildGroupPullRequestBody(group, members); + expect(body).toContain("Automated group PR for BG-1."); + expect(body).toContain("- FN-A: Alpha"); + expect(body).toContain("- FN-B: Beta"); + }); +}); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 636f0f3169..a739faec25 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import type { DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core"; +import type { BranchGroup, BranchGroupPrState, DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, Task, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, @@ -3693,3 +3693,88 @@ export function parseGitHubBadgeUrl(url: string): { owner: string; repo: string return { owner: parsed.owner, repo: parsed.repo }; } +/** Map a `PrInfo.status` to the persisted `BranchGroup.prState`. */ +function prInfoToBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState { + if (!prInfo) return "none"; + if (prInfo.status === "merged") return "merged"; + if (prInfo.status === "closed") return "closed"; + return "open"; +} + +/** Build the title for a single managed group PR. */ +export function buildGroupPullRequestTitle( + group: Pick, + members: Pick[], +): string { + return `${group.id}: ${group.sourceType}/${group.sourceId} (${members.length} tasks)`; +} + +/** Build the body for a single managed group PR (member checklist + completion). */ +export function buildGroupPullRequestBody( + group: Pick, + members: Pick[], +): string { + const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"}`); + return [ + `Automated group PR for ${group.id}.`, + `Source: ${group.sourceType}/${group.sourceId}`, + `Integration branch: \`${group.branchName}\``, + "", + "Included tasks:", + ...(lines.length > 0 ? lines : ["- (none)"]), + ].join("\n"); +} + +export interface CreateGroupPrInput { + group: Pick; + members: Pick[]; + /** Head branch — the group integration branch. */ + headBranch: string; + /** Base branch — the project default / integration target. */ + baseBranch: string; +} + +export interface CreateGroupPrResult { + prNumber: number; + prUrl: string; + prState: BranchGroupPrState; +} + +/** + * Create (or reuse) the single managed GitHub PR for a branch group. + * + * Idempotency: if an existing PR is already open for the group head branch on + * GitHub, it is reused rather than opening a second one. This is the GitHub-side + * idempotency guard; the coordinator additionally checks the persisted + * `prNumber` before ever calling this helper. + * + * Backend parity: dispatches through `GitHubClient.findPrForBranch` / + * `GitHubClient.createPr`, which transparently use the `gh` CLI when available + * and fall back to the REST API, so both paths produce the same result shape. + */ +export async function createGroupPullRequest( + github: Pick, + input: CreateGroupPrInput, +): Promise { + const existing = await github.findPrForBranch({ head: input.headBranch, state: "all" }); + if (existing) { + return { + prNumber: existing.number, + prUrl: existing.url, + prState: prInfoToBranchGroupPrState(existing), + }; + } + + const created = await github.createPr({ + title: buildGroupPullRequestTitle(input.group, input.members), + body: buildGroupPullRequestBody(input.group, input.members), + head: input.headBranch, + base: input.baseBranch, + }); + return { + prNumber: created.number, + prUrl: created.url, + prState: prInfoToBranchGroupPrState(created), + }; +} + diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index d1741a4210..0e903a0234 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -11,7 +11,7 @@ export { type RuntimeLogSink, } from "./runtime-logger.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; -export { GitHubClient, isPrMergeReady, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js"; +export { GitHubClient, isPrMergeReady, createGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrInput, type CreateGroupPrResult } from "./github.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index ff1f3e084b..8889e630db 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -337,6 +337,211 @@ describe("promoteBranchGroup", () => { }); }); +describe("promoteBranchGroup PR creation (U5)", () => { + function makeGroup(overrides?: Partial): any { + return { + id: "BG-PR-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/planning-x", + autoMerge: true, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + const landedMember = (id: string, branchName: string) => ({ + id, + title: `${id} title`, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + + function makePrRepo(): string { + 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 }); + return rootDir; + } + + function makeStore(getGroup: () => any, setGroup: (g: any) => void, members: any[], byBranch?: () => any) { + return { + getBranchGroup: () => getGroup(), + getBranchGroupByBranchName: byBranch ?? (() => null), + listTasksByBranchGroup: async () => members, + updateBranchGroup: (_id: string, patch: Record) => { + setGroup({ ...getGroup(), ...patch }); + return getGroup(); + }, + } as any; + } + + const prSettings = { + autoMerge: true, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request" as const, + baseBranch: "main", + }; + + it("creates exactly one PR for a complete PR-mode group and persists prNumber/prUrl/prState=open", async () => { + const rootDir = makePrRepo(); + let group = makeGroup(); + let createCalls = 0; + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [landedMember("FN-A", group.branchName)]), + createGroupPr: async ({ headBranch, baseBranch, members }) => { + createCalls += 1; + expect(headBranch).toBe("fusion/groups/planning-x"); + expect(baseBranch).toBe("main"); + expect(members.map((m: any) => m.id)).toEqual(["FN-A"]); + return { prNumber: 42, prUrl: "https://github.com/x/y/pull/42", prState: "open" }; + }, + }); + + expect(result.reason).toBe("promoted"); + expect(createCalls).toBe(1); + expect(group.status).toBe("finalized"); + expect(group.prState).toBe("open"); + expect(group.prNumber).toBe(42); + expect(group.prUrl).toBe("https://github.com/x/y/pull/42"); + }); + + it("is idempotent: a persisted prNumber means re-promotion never opens a second PR", async () => { + const rootDir = makePrRepo(); + let createCalls = 0; + const createGroupPr = async () => { + createCalls += 1; + return { prNumber: 7, prUrl: "https://github.com/x/y/pull/7", prState: "open" as const }; + }; + + // First promotion creates the PR. + let group = makeGroup(); + await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [landedMember("FN-A", group.branchName)]), + createGroupPr, + }); + expect(createCalls).toBe(1); + expect(group.prNumber).toBe(7); + + // Re-running while the group already has prState=open short-circuits at the + // top guard (already-finalized) — the creator is NOT called again. + const again = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [landedMember("FN-A", group.branchName)]), + createGroupPr, + }); + expect(again.reason).toBe("already-finalized"); + expect(createCalls).toBe(1); + }); + + it("reuses an existing PR via getBranchGroupByBranchName without invoking the creator", async () => { + const rootDir = makePrRepo(); + let group = makeGroup(); + let createCalls = 0; + const sibling = makeGroup({ id: "BG-PR-OTHER", prNumber: 99, prUrl: "https://github.com/x/y/pull/99", prState: "open" }); + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore( + () => group, + (g) => { group = g; }, + [landedMember("FN-A", group.branchName)], + () => sibling, + ), + createGroupPr: async () => { + createCalls += 1; + return { prNumber: 1, prUrl: "x", prState: "open" as const }; + }, + }); + + expect(result.reason).toBe("promoted"); + expect(createCalls).toBe(0); + expect(group.prNumber).toBe(99); + expect(group.prUrl).toBe("https://github.com/x/y/pull/99"); + expect(group.prState).toBe("open"); + }); + + it("does not create a PR for an incomplete group (gate blocks before creation)", async () => { + const rootDir = makePrRepo(); + let group = makeGroup(); + let createCalls = 0; + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [{ id: "FN-A", column: "todo" }]), + createGroupPr: async () => { + createCalls += 1; + return { prNumber: 1, prUrl: "x", prState: "open" as const }; + }, + }); + + expect(result.reason).toBe("incomplete"); + expect(createCalls).toBe(0); + expect(group.prState).toBe("none"); + expect(group.status).toBe("open"); + }); + + it("leaves the group recoverable when PR creation fails (no partial prState lie)", async () => { + const rootDir = makePrRepo(); + let group = makeGroup(); + await expect( + promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [landedMember("FN-A", group.branchName)]), + createGroupPr: async () => { + throw new Error("gh: network down"); + }, + }), + ).rejects.toThrow("gh: network down"); + + // prState/status must NOT be flipped to a lie; re-promotion can retry. + expect(group.prState).toBe("none"); + expect(group.status).toBe("open"); + }); + + it("autoMerge:false group is not promoted (PR creation only on eligible/explicit promote)", async () => { + const rootDir = makePrRepo(); + let group = makeGroup({ autoMerge: false }); + let createCalls = 0; + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store: makeStore(() => group, (g) => { group = g; }, [landedMember("FN-A", group.branchName)]), + createGroupPr: async () => { + createCalls += 1; + return { prNumber: 1, prUrl: "x", prState: "open" as const }; + }, + }); + + expect(result.reason).toBe("gated"); + expect(createCalls).toBe(0); + expect(group.prState).toBe("none"); + }); +}); + describe("ProjectEngine.promoteBranchGroup (U4 bridge method)", () => { // The dashboard promote route calls engine.promoteBranchGroup AS A METHOD. // These tests invoke the REAL method body bound to a minimal engine-shaped @@ -383,6 +588,7 @@ describe("ProjectEngine.promoteBranchGroup (U4 bridge method)", () => { context: { runtime: { getTaskStore: () => fullStore }, config: { workingDirectory: rootDir }, + options: {}, }, getSettingsCalls, }; diff --git a/packages/engine/src/group-merge-coordinator.ts b/packages/engine/src/group-merge-coordinator.ts index 204c0e511e..e2ee141e55 100644 --- a/packages/engine/src/group-merge-coordinator.ts +++ b/packages/engine/src/group-merge-coordinator.ts @@ -12,6 +12,28 @@ export interface BranchGroupMergeRouting { mergeTarget: MergeTargetResolution; } +/** + * Injected callback (KTD7) that creates — or reuses — the single managed GitHub + * PR for a branch group. Closes over a dashboard-built `GitHubClient` at the CLI + * construction sites so the engine never statically imports `@fusion/dashboard` + * (avoids the engine ↔ dashboard import cycle). Mirrors the `processPullRequestMerge` + * injection seam. + * + * Returns the GitHub PR number/url and the persisted-state mapping. Idempotency is + * enforced both here (reuse an existing open PR for the head branch) and by the + * coordinator (skip the call entirely when a `prNumber` is already persisted). + */ +export type CreateGroupPrFn = (input: { + /** Project working directory — needed to push the head branch to origin. */ + cwd: string; + group: BranchGroup; + members: Task[]; + /** Head branch — the group integration branch. */ + headBranch: string; + /** Base branch — the integration/default target. */ + baseBranch: string; +}) => Promise<{ prNumber: number; prUrl: string; prState: BranchGroupPrState }>; + export interface BranchGroupCompletionStatus { complete: boolean; totalMembers: number; @@ -118,10 +140,16 @@ async function ensureGroupBranchExists(rootDir: string, branchName: string, star * Promotion is intentionally idempotent and must never run inline in aiMergeTask. */ export async function promoteBranchGroup(input: { - store: Pick; + store: Pick; rootDir: string; groupId: string; settings: Pick & Partial>; + /** + * Injected GitHub PR creator (KTD7). When PR mode is active and the group is + * complete, the coordinator uses this to create the single managed PR. Omitted + * for direct-merge mode and in tests that don't exercise PR creation. + */ + createGroupPr?: CreateGroupPrFn; recordAudit?: (event: { domain: string; mutationType: string; @@ -219,9 +247,53 @@ export async function promoteBranchGroup(input: { } const isPrMode = input.settings.mergeStrategy === "pull-request"; + + let prNumber: number | undefined = group.prNumber; + let prUrl: string | undefined = group.prUrl; + let prState: BranchGroupPrState = isPrMode ? "open" : "merged"; + + if (isPrMode) { + // Idempotency (KTD4): never open a second PR. Prefer a PR already persisted + // on this group; otherwise reuse any open PR another group row may hold for + // the same head branch. Only when neither exists do we invoke the injected + // creator. The injected creator itself also reuses an existing GitHub PR. + const persistedPr = group.prNumber + ? { prNumber: group.prNumber, prUrl: group.prUrl } + : (() => { + const existing = input.store.getBranchGroupByBranchName(group.branchName); + return existing && existing.id !== group.id && existing.prNumber + ? { prNumber: existing.prNumber, prUrl: existing.prUrl } + : null; + })(); + + if (persistedPr) { + prNumber = persistedPr.prNumber; + prUrl = persistedPr.prUrl; + prState = "open"; + } else if (input.createGroupPr) { + // GitHub failure must leave the group recoverable: do NOT flip prState to a + // lie. The group is already merged to the integration branch locally; we + // surface the error so the caller can retry promotion (which is idempotent). + const created = await input.createGroupPr({ + cwd: input.rootDir, + group, + members, + headBranch: group.branchName, + baseBranch: integrationBranch, + }); + prNumber = created.prNumber; + prUrl = created.prUrl; + prState = created.prState; + } + // If neither a persisted PR nor a createGroupPr callback is available, fall + // back to the legacy behaviour (flip prState to "open" without a number). + } + const updatedGroup = input.store.updateBranchGroup(group.id, { status: "finalized", - prState: isPrMode ? "open" : "merged", + prState, + prNumber: prNumber ?? null, + prUrl: prUrl ?? null, }); await input.recordAudit?.({ diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 71bed8fbb2..02febd4933 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -65,6 +65,7 @@ export { type BranchGroupPromotionDecision, type BranchGroupCompletionStatus, type BranchGroupPromotionResult, + type CreateGroupPrFn, } from "./group-merge-coordinator.js"; export { resolveMergeIntegrationRoot, diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index 0006d8826a..1e33434976 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -36,6 +36,7 @@ import { runtimeLog } from "./logger.js"; export interface EngineManagerOptions { getMergeStrategy?: ProjectEngineOptions["getMergeStrategy"]; processPullRequestMerge?: ProjectEngineOptions["processPullRequestMerge"]; + createGroupPr?: ProjectEngineOptions["createGroupPr"]; getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"]; onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"]; } @@ -481,6 +482,7 @@ export class ProjectEngineManager { projectId: project.id, getMergeStrategy: this.options.getMergeStrategy, processPullRequestMerge: this.options.processPullRequestMerge, + createGroupPr: this.options.createGroupPr, getTaskMergeBlocker: this.options.getTaskMergeBlocker, onInsightRunProcessed: this.options.onInsightRunProcessed, ...overrides, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index ea77b1081b..6b6912f9ad 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -27,7 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js"; import { runAiMerge } from "./merger-ai.js"; -import { promoteBranchGroup, type BranchGroupPromotionResult } from "./group-merge-coordinator.js"; +import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; @@ -205,6 +205,14 @@ export interface ProjectEngineOptions { * can be "pull-request". Injected from CLI layer. */ processPullRequestMerge?: ProcessPullRequestMergeFn; + /** + * Creates (or reuses) the single managed GitHub PR for a branch group during + * promotion (KTD7). Injected from the CLI layer because it depends on the + * dashboard `GitHubClient`; the engine must not statically import it. Mirrors + * the `processPullRequestMerge` seam. When absent, PR-mode promotion flips + * `prState` to "open" without creating a real PR (legacy behaviour). + */ + createGroupPr?: CreateGroupPrFn; /** * Returns the merge blocker reason for a task, or null/undefined if * the task is eligible for merge. Imported from @fusion/core. @@ -952,6 +960,7 @@ export class ProjectEngine { rootDir: cwd, groupId, settings: promotionSettings, + createGroupPr: this.options.createGroupPr, recordAudit: async (event) => { await store.recordRunAuditEvent({ domain: event.domain as any, @@ -1894,6 +1903,7 @@ export class ProjectEngine { rootDir: cwd, groupId: taskForPromotion.branchContext!.groupId, settings: promotionSettings, + createGroupPr: this.options.createGroupPr, recordAudit: async (event) => { await store.recordRunAuditEvent({ domain: event.domain as any, From 415470c7bdf6683dce9fe14a426f027811b65666 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:20:30 -0700 Subject: [PATCH 07/21] feat(FN-branch-group): sync group PR as members land + terminal lifecycle (U6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push the single group PR's body (member checklist, x/N landed) on each member landing via an injected SyncGroupPrFn — new updatePr/closePr GitHubClient helpers (gh CLI + API parity); refreshPrInBackground is task-scoped/wrong direction and intentionally not reused. Sync failures are non-fatal+retryable; out-of-band closed/merged PRs reconcile prState instead of erroring. New POST /branch-groups/:id/abandon closes the PR best-effort and marks the group abandoned. Also fixes the U5-introduced stub-context regression in the U4 dashboard bridge test (missing options). --- .changeset/fn-branch-group-single-pr.md | 2 + .../cli/src/commands/__tests__/daemon.test.ts | 2 + .../cli/src/commands/__tests__/serve.test.ts | 2 + .../commands/__tests__/task-lifecycle.test.ts | 87 +++++++ packages/cli/src/commands/daemon.ts | 2 + packages/cli/src/commands/dashboard.ts | 2 + packages/cli/src/commands/serve.ts | 2 + packages/cli/src/commands/task-lifecycle.ts | 91 ++++++- .../__tests__/github-sync-group-pr.test.ts | 180 ++++++++++++++ .../__tests__/routes-branch-groups.test.ts | 92 ++++++++ packages/dashboard/src/github.ts | 222 ++++++++++++++++++ packages/dashboard/src/index.ts | 2 +- .../routes/register-branch-groups-routes.ts | 48 ++++ .../src/routes/register-integrated-routers.ts | 12 + .../branch-group-pr-sync.test.ts | 174 ++++++++++++++ .../engine/src/group-merge-coordinator.ts | 38 +++ packages/engine/src/index.ts | 3 + packages/engine/src/merger.ts | 61 +++++ packages/engine/src/project-engine-manager.ts | 2 + packages/engine/src/project-engine.ts | 10 +- 20 files changed, 1030 insertions(+), 4 deletions(-) create mode 100644 packages/dashboard/src/__tests__/github-sync-group-pr.test.ts create mode 100644 packages/engine/src/__tests__/reliability-interactions/branch-group-pr-sync.test.ts diff --git a/.changeset/fn-branch-group-single-pr.md b/.changeset/fn-branch-group-single-pr.md index 81cbe2dbf1..648a2c6dcc 100644 --- a/.changeset/fn-branch-group-single-pr.md +++ b/.changeset/fn-branch-group-single-pr.md @@ -3,3 +3,5 @@ --- Branch-group promotion now creates a single real GitHub PR for the group integration branch when promoting a completed PR-mode group. The PR number/url/state are persisted on the branch group and promotion is idempotent — re-running never opens a second PR (an existing persisted or open PR is reused). The GitHub client is injected into the engine via the same option-callback seam as `processPullRequestMerge`, wired at the `fn daemon`, `fn dashboard`, and `fn serve` construction sites. PR creation only happens for eligible (completion-gated, auto-merge-allowed) groups, and a GitHub failure leaves the group recoverable rather than persisting a false PR state. + +The single managed group PR is now kept in sync through its terminal lifecycle: as additional members land, the PR body is rewritten with the latest member checklist and x/N completion (idempotent body rewrite — sync failures are non-fatal and retry on the next landing). When the persisted PR is closed or merged out-of-band on GitHub, the stored `prState` is reconciled rather than re-opened. Abandoning a group best-effort closes its GitHub PR and marks `prState` `closed` (or preserves `merged`). New injected `syncGroupPr` callback and dashboard `updatePr`/`closePr` GitHub-client helpers back this flow. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 57db955bcb..3ed047db2f 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -641,6 +641,8 @@ vi.mock("../task-lifecycle.js", () => ({ getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), + syncGroupPrCallback: vi.fn(() => vi.fn()), + closeGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 86911ae9a9..b2898816e3 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -695,6 +695,8 @@ vi.mock("../task-lifecycle.js", () => ({ getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), + syncGroupPrCallback: vi.fn(() => vi.fn()), + closeGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index b1bffd32b7..b812ef9908 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -23,11 +23,21 @@ vi.mock("node:child_process", () => ({ }, })); +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + }; +}); + import { activeSessionRegistry } from "@fusion/engine"; import { cleanupMergedTaskArtifacts, processPullRequestMergeTask, getTaskBranchName, + syncGroupPrCallback, + closeGroupPrCallback, } from "../task-lifecycle.js"; interface MockTask { @@ -1312,3 +1322,80 @@ describe("cleanupMergedTaskArtifacts FN-5455", () => { ).resolves.toBeUndefined(); }); }); + +describe("syncGroupPrCallback (U6)", () => { + const group = { + id: "BG-1", + branchName: "fusion/groups/x", + sourceType: "planning" as const, + sourceId: "PS-1", + prNumber: 42, + prUrl: "https://github.com/owner/repo/pull/42", + prState: "open" as const, + status: "open" as const, + autoMerge: false, + createdAt: 0, + updatedAt: 0, + }; + const members = [ + { id: "FN-A", title: "Alpha" }, + { id: "FN-B", title: "Beta" }, + ] as never[]; + + it("edits the PR body when the PR is open and returns the persisted shape", async () => { + 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); + const result = await sync({ group: group as never, members }); + expect(result).toEqual({ prNumber: 42, prUrl: "https://github.com/owner/repo/pull/42", prState: "open" }); + expect(github.updatePr).toHaveBeenCalledTimes(1); + const body = (github.updatePr.mock.calls[0][0] as { body: string }).body; + expect(body).toContain("Completion: 0/2 landed"); + expect(body).toContain("FN-A: Alpha"); + }); + + it("reconciles (does not edit) when the PR is closed out-of-band", async () => { + const github = { + getPrStatus: vi.fn(async () => ({ number: 42, url: "https://github.com/owner/repo/pull/42", status: "closed", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), + updatePr: vi.fn(), + }; + const sync = syncGroupPrCallback(github as never); + const result = await sync({ group: group as never, members }); + expect(result.prState).toBe("closed"); + expect(github.updatePr).not.toHaveBeenCalled(); + }); + + it("throws when the group has no persisted prNumber", async () => { + const github = { getPrStatus: vi.fn(), updatePr: vi.fn() }; + const sync = syncGroupPrCallback(github as never); + await expect(sync({ group: { ...group, prNumber: undefined } as never, members })).rejects.toThrow(/no persisted prNumber/); + }); +}); + +describe("closeGroupPrCallback (U6)", () => { + const group = { id: "BG-1", prNumber: 42 }; + + it("closes an open PR and returns closed state", async () => { + const github = { + getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "open", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), + closePr: vi.fn(async () => ({ number: 42, url: "u", status: "closed", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), + }; + const close = closeGroupPrCallback(github as never); + const result = await close({ group: group as never }); + expect(result.prState).toBe("closed"); + expect(github.closePr).toHaveBeenCalledWith({ number: 42 }); + }); + + it("reconciles (does not close) when already merged out-of-band", async () => { + const github = { + getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "merged", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), + closePr: vi.fn(), + }; + const close = closeGroupPrCallback(github as never); + const result = await close({ group: group as never }); + expect(result.prState).toBe("merged"); + expect(github.closePr).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 3826827f3a..27d1af4f67 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -43,6 +43,7 @@ import { getMergeStrategy, processPullRequestMergeTask, createGroupPrCallback, + syncGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; @@ -336,6 +337,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), createGroupPr: createGroupPrCallback(githubClient), + syncGroupPr: syncGroupPrCallback(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), }); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 9663e6d82d..3c147c3340 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -47,6 +47,7 @@ import { getTaskBranchName, processPullRequestMergeTask, createGroupPrCallback, + syncGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; @@ -1561,6 +1562,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), createGroupPr: createGroupPrCallback(githubClient), + syncGroupPr: syncGroupPrCallback(githubClient), getTaskMergeBlocker, }); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 32b022c04d..8f01b2c798 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -43,6 +43,7 @@ import { getMergeStrategy, processPullRequestMergeTask, createGroupPrCallback, + syncGroupPrCallback, } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; @@ -362,6 +363,7 @@ export async function runServe( processPullRequestMerge: (s, wd, taskId, pool) => processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker, pool), createGroupPr: createGroupPrCallback(githubClient), + syncGroupPr: syncGroupPrCallback(githubClient), getTaskMergeBlocker, onInsightRunProcessed: (s: unknown, r: unknown) => onMemoryInsightRunProcessed(s as ScheduledTask, r as AutomationRunResult), }); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index eaa277003e..609d62df8d 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -17,10 +17,10 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); import type { TaskStore } from "@fusion/core"; -import { resolveTaskMergeTarget } from "@fusion/core"; +import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine"; -import type { CreateGroupPrFn, WorktreePool } from "@fusion/engine"; +import type { CreateGroupPrFn, SyncGroupPrFn, CloseGroupPrFn, WorktreePool } from "@fusion/engine"; /** * Minimal interface for GitHub operations needed by the PR merge workflow. @@ -37,6 +37,9 @@ interface GitHubOperations { blockingReasons: string[]; }>; mergePr(params: { number: number; method?: "merge" | "squash" | "rebase" }): Promise; + getPrStatus(owner: string, repo: string, number: number): Promise; + updatePr(params: { number: number; title?: string; body?: string }): Promise; + closePr(params: { number: number }): Promise; } /** @@ -199,6 +202,90 @@ export function createGroupPrCallback( }; } +/** + * Build a completion-aware group PR body: a member checklist marking each task + * landed/unlanded, plus an x/N completion summary (U6, R6). Rewritten in full on + * every sync, so repeated pushes are idempotent and coalesce naturally. + */ +function buildGroupPrSyncBody(group: BranchGroup, members: Task[]): string { + const landedCount = members.filter((member) => isBranchGroupMemberLanded(member, group)).length; + const lines = members.map((member) => { + const landed = isBranchGroupMemberLanded(member, group); + return `- [${landed ? "x" : " "}] ${member.id}: ${member.title || "(untitled)"} — \`${getTaskBranchName(member.id)}\``; + }); + return [ + `Automated group PR for ${group.id}.`, + `Source: ${group.sourceType}/${group.sourceId}`, + `Integration branch: \`${group.branchName}\``, + `Completion: ${landedCount}/${members.length} landed`, + "", + "Included tasks:", + ...(lines.length > 0 ? lines : ["- (none)"]), + ].join("\n"); +} + +/** + * Build the `syncGroupPr` engine callback (KTD7, U6). Pushes an updated body + * (member checklist + x/N completion) onto the single managed group PR as + * members land. Closes over a GitHub client so the engine never imports the + * dashboard client. + * + * Out-of-band reconciliation: reads the PR's current state first; if it is no + * longer open (closed/merged on GitHub), returns the reconciled prState rather + * than editing or re-opening it, so the caller can persist the corrected state. + */ +export function syncGroupPrCallback( + github: Pick, +): SyncGroupPrFn { + return async ({ group, members }) => { + if (group.prNumber == null) { + throw new Error(`syncGroupPr: group ${group.id} has no persisted prNumber`); + } + const repo = getCurrentRepo(); + if (!repo) { + throw new Error("syncGroupPr: could not determine repository"); + } + const current = await github.getPrStatus(repo.owner, repo.repo, group.prNumber); + const currentState = toBranchGroupPrState(current); + if (currentState !== "open") { + return { prNumber: current.number, prUrl: current.url, prState: currentState }; + } + const updated = await github.updatePr({ + number: group.prNumber, + title: buildGroupPullRequestTitle(group, members), + body: buildGroupPrSyncBody(group, members), + }); + return { prNumber: updated.number, prUrl: updated.url, prState: toBranchGroupPrState(updated) }; + }; +} + +/** + * Build the `closeGroupPr` engine callback (KTD7, U6). Best-effort closes the + * single managed group PR during terminal reconciliation when a group is + * abandoned. If the PR is already closed/merged out-of-band, returns the + * reconciled state rather than erroring. + */ +export function closeGroupPrCallback( + github: Pick, +): CloseGroupPrFn { + return async ({ group }) => { + if (group.prNumber == null) { + throw new Error(`closeGroupPr: group ${group.id} has no persisted prNumber`); + } + const repo = getCurrentRepo(); + if (!repo) { + throw new Error("closeGroupPr: could not determine repository"); + } + const current = await github.getPrStatus(repo.owner, repo.repo, group.prNumber); + const currentState = toBranchGroupPrState(current); + if (currentState !== "open") { + return { prNumber: current.number, prUrl: current.url, prState: currentState }; + } + const closed = await github.closePr({ number: group.prNumber }); + return { prNumber: closed.number, prUrl: closed.url, prState: toBranchGroupPrState(closed) }; + }; +} + async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise { try { const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 }); diff --git a/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts b/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts new file mode 100644 index 0000000000..2ec6403a7c --- /dev/null +++ b/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGh: vi.fn(), + runGhAsync: vi.fn(), + runGhJson: vi.fn(), + runGhJsonAsync: vi.fn(), + getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))), + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + }; +}); + +import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core"; +import { GitHubClient, syncGroupPullRequest, closeGroupPullRequest } from "../github.js"; + +const mockRunGh = vi.mocked(runGh); +const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); +const mockIsGhAvailable = vi.mocked(isGhAvailable); +const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); + +const group = { + id: "BG-1", + branchName: "fusion/groups/planning-x", + sourceType: "planning" as const, + sourceId: "PS-1", + prNumber: 42, +}; +const members = [ + { id: "FN-A", title: "Alpha" }, + { id: "FN-B", title: "Beta" }, +]; + +const ghPrViewOpen = { + number: 42, + url: "https://github.com/owner/repo/pull/42", + title: "T", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: group.branchName, +}; + +describe("syncGroupPullRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsGhAvailable.mockReturnValue(true); + mockIsGhAuthenticated.mockReturnValue(true); + }); + + it("edits the PR body via the gh-CLI backend when the PR is open", async () => { + // getPrStatus (gh view): open. updatePr→getPrStatus (gh view): open again. + mockRunGhJsonAsync.mockResolvedValue(ghPrViewOpen as any); + const client = new GitHubClient({ forceMode: undefined as never }); + // Force gh-auth path by relying on mocked isGhAvailable/isGhAuthenticated. + + const result = await syncGroupPullRequest(client, { group, members }); + + expect(result).toEqual({ + prNumber: 42, + prUrl: "https://github.com/owner/repo/pull/42", + prState: "open", + }); + // pr edit was invoked with the group's PR number and a body. + const editArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "edit")?.[0]; + expect(editArgs).toBeDefined(); + expect(editArgs).toEqual(expect.arrayContaining(["pr", "edit", "42", "--body"])); + }); + + it("edits the PR body via the REST API backend when the PR is open", async () => { + // Force the API path: gh CLI unavailable so getPrStatus/updatePr use REST. + mockIsGhAvailable.mockReturnValue(false); + mockIsGhAuthenticated.mockReturnValue(false); + const client = new GitHubClient({ token: "ghp_token", forceMode: "token" }); + const fetchSpy = vi.spyOn(global, "fetch" as any) + // getPrStatus (API): open. + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + number: 42, + html_url: "https://github.com/owner/repo/pull/42", + title: "T", + state: "open", + merged: false, + head: { ref: group.branchName }, + base: { ref: "main" }, + comments: 0, + updated_at: "2026-06-03T00:00:00Z", + }), + } as any) + // updatePr (API PATCH). + .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) + // updatePr→getPrStatus (API): open. + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + number: 42, + html_url: "https://github.com/owner/repo/pull/42", + title: "T2", + state: "open", + merged: false, + head: { ref: group.branchName }, + base: { ref: "main" }, + comments: 0, + updated_at: "2026-06-03T00:00:01Z", + }), + } as any); + + const result = await syncGroupPullRequest(client, { group, members }); + expect(result.prState).toBe("open"); + expect(result.prNumber).toBe(42); + // PATCH was sent with a body containing the completion checklist. + const patchCall = fetchSpy.mock.calls.find((c) => (c[1] as any)?.method === "PATCH"); + expect(patchCall).toBeDefined(); + fetchSpy.mockRestore(); + }); + + it("reconciles (no edit) when the PR is closed out-of-band on GitHub", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await syncGroupPullRequest(client, { group, members }); + + expect(result.prState).toBe("closed"); + // pr edit must NOT be invoked when the PR is already terminal. + expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined(); + }); + + it("reconciles to merged (no edit) when the PR is merged out-of-band", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await syncGroupPullRequest(client, { group, members }); + expect(result.prState).toBe("merged"); + expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined(); + }); + + it("throws when the group has no persisted prNumber", async () => { + const client = new GitHubClient({ forceMode: undefined as never }); + await expect( + syncGroupPullRequest(client, { group: { ...group, prNumber: null as never }, members }), + ).rejects.toThrow(/no persisted prNumber/); + }); +}); + +describe("closeGroupPullRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsGhAvailable.mockReturnValue(true); + mockIsGhAuthenticated.mockReturnValue(true); + }); + + it("closes an open PR via the gh-CLI backend", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any); + // First getPrStatus returns open, then close, then getPrStatus returns closed. + mockRunGhJsonAsync + .mockResolvedValueOnce(ghPrViewOpen as any) + .mockResolvedValueOnce({ ...ghPrViewOpen, state: "CLOSED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + + expect(result.prState).toBe("closed"); + const closeArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "close")?.[0]; + expect(closeArgs).toEqual(expect.arrayContaining(["pr", "close", "42"])); + }); + + it("reconciles (no close) when the PR is already merged out-of-band", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + expect(result.prState).toBe("merged"); + expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index 7ba82cf6fa..b35a196092 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -5,6 +5,7 @@ import express from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; +import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js"; import { request as REQUEST } from "../test-request.js"; function buildTask(id: string, groupId: string, landed: boolean): Task { @@ -123,9 +124,11 @@ describe("branch group routes", () => { recordRunAuditEvent: vi.fn(async () => {}), }; // Minimal ProjectEngine-shaped context the real method body reads. + // `options` must be present: the method reads this.options.createGroupPr (U5). const engineContext = { runtime: { getTaskStore: () => engineStore }, config: { workingDirectory: "/tmp/project" }, + options: {}, }; // Bind the REAL method (the same one the dashboard route invokes). const realPromote = (ProjectEngine.prototype as unknown as { @@ -197,3 +200,92 @@ describe("branch group routes", () => { expect((store.ensureBranchGroupForSource as unknown as ReturnType)).toHaveBeenCalled(); }); }); + +describe("branch group abandon (U6, R7)", () => { + function buildOpenGroup(): BranchGroup { + return { + id: "BG-AB", + sourceType: "planning", + sourceId: "PS-AB", + branchName: "feature/shared-ab", + autoMerge: false, + prState: "open", + prNumber: 55, + prUrl: "https://example/pr/55", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } + + function buildAbandonStore(initial: BranchGroup) { + let current = { ...initial }; + const updateBranchGroup = vi.fn((_id: string, patch: Partial) => { + current = { ...current, ...patch, status: patch.status ?? current.status }; + return current; + }); + const store = { + getRootDir: vi.fn(() => "/tmp/project"), + getBranchGroup: vi.fn(() => current), + listTasksByBranchGroup: vi.fn(async () => [] as Task[]), + updateBranchGroup, + } as unknown as TaskStore; + return { store, updateBranchGroup, getCurrent: () => current }; + } + + function mount(store: TaskStore, closeGroupPr?: ReturnType) { + const app = express(); + app.use(express.json()); + app.use("/branch-groups", createBranchGroupsRouter(store, { closeGroupPr })); + return app; + } + + it("closes the GitHub PR (close callback invoked) and sets prState=closed", async () => { + const { store, updateBranchGroup } = buildAbandonStore(buildOpenGroup()); + const closeGroupPr = vi.fn(async () => ({ prNumber: 55, prUrl: "https://example/pr/55", prState: "closed" as const })); + const app = mount(store, closeGroupPr); + + const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(200); + expect(closeGroupPr).toHaveBeenCalledTimes(1); + expect(updateBranchGroup).toHaveBeenCalledWith("BG-AB", expect.objectContaining({ status: "abandoned", prState: "closed" })); + expect(res.body.group.status).toBe("abandoned"); + expect(res.body.group.prState).toBe("closed"); + }); + + it("still marks the row abandoned/closed when the close callback throws (best-effort)", async () => { + const { store, updateBranchGroup } = buildAbandonStore(buildOpenGroup()); + const closeGroupPr = vi.fn(async () => { throw new Error("github down"); }); + const app = mount(store, closeGroupPr); + + const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(200); + expect(updateBranchGroup).toHaveBeenCalledWith("BG-AB", expect.objectContaining({ status: "abandoned", prState: "closed" })); + expect(res.body.group.prState).toBe("closed"); + }); + + it("does not invoke close when there is no persisted PR", async () => { + const noPr = { ...buildOpenGroup(), prNumber: undefined, prUrl: undefined, prState: "none" as const }; + const { store } = buildAbandonStore(noPr); + const closeGroupPr = vi.fn(async () => ({ prNumber: 0, prUrl: "", prState: "closed" as const })); + const app = mount(store, closeGroupPr); + + const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(200); + expect(closeGroupPr).not.toHaveBeenCalled(); + expect(res.body.group.status).toBe("abandoned"); + }); + + it("preserves prState=merged on abandon if the group was already merged", async () => { + const merged = { ...buildOpenGroup(), prState: "merged" as const }; + const { store } = buildAbandonStore(merged); + const closeGroupPr = vi.fn(); + const app = mount(store, closeGroupPr as unknown as ReturnType); + + const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(200); + // Already merged → do not close; keep merged terminal state. + expect(closeGroupPr).not.toHaveBeenCalled(); + expect(res.body.group.prState).toBe("merged"); + }); +}); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index a739faec25..451926b3a4 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -219,6 +219,20 @@ export interface MergePrParams { method?: "merge" | "squash" | "rebase"; } +export interface UpdatePrParams { + owner?: string; + repo?: string; + number: number; + title?: string; + body?: string; +} + +export interface ClosePrParams { + owner?: string; + repo?: string; + number: number; +} + export interface BadgeBatchRequest { alias: string; type: "pr" | "issue"; @@ -1913,6 +1927,121 @@ export class GitHubClient { }; } + /** + * Edit the title and/or body of an existing PR by number. Uses gh CLI if + * available, otherwise the REST API. Returns the refreshed PR status. + * + * Used by the group-PR sync path to push an updated member checklist / + * completion summary onto the single managed group PR (U6, R6). + */ + async updatePr(params: UpdatePrParams): Promise { + if (this.hasGhAuth()) { + try { + return await this.updatePrWithGh(params); + } catch (err) { + if (this.token) { + return this.updatePrWithApi(params); + } + throw new Error(getGhErrorMessage(err)); + } + } + + if (this.token) { + return this.updatePrWithApi(params); + } + throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided."); + } + + private async updatePrWithGh(params: UpdatePrParams): Promise { + const resolved = this.resolveRepo(params.owner, params.repo); + const args = [ + "pr", "edit", String(params.number), + "--repo", `${resolved.owner}/${resolved.repo}`, + ]; + if (params.title !== undefined) { + args.push("--title", params.title); + } + if (params.body !== undefined) { + args.push("--body", params.body); + } + runGh(args); + return this.getPrStatus(resolved.owner, resolved.repo, params.number); + } + + private async updatePrWithApi(params: UpdatePrParams): Promise { + const resolved = this.resolveRepo(params.owner, params.repo); + const payload: Record = {}; + if (params.title !== undefined) payload.title = params.title; + if (params.body !== undefined) payload.body = params.body; + const response = await fetch( + `${this.baseUrl}/repos/${encodeURIComponent(resolved.owner)}/${encodeURIComponent(resolved.repo)}/pulls/${params.number}`, + { + method: "PATCH", + headers: this.buildHeaders(), + body: JSON.stringify(payload), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`); + } + + return this.getPrStatus(resolved.owner, resolved.repo, params.number); + } + + /** + * Close an existing PR by number without merging. Uses gh CLI if available, + * otherwise the REST API. Returns the refreshed PR status. + * + * Used by terminal reconciliation when a branch group is abandoned (U6, R7). + */ + async closePr(params: ClosePrParams): Promise { + if (this.hasGhAuth()) { + try { + return await this.closePrWithGh(params); + } catch (err) { + if (this.token) { + return this.closePrWithApi(params); + } + throw new Error(getGhErrorMessage(err)); + } + } + + if (this.token) { + return this.closePrWithApi(params); + } + throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided."); + } + + private async closePrWithGh(params: ClosePrParams): Promise { + const resolved = this.resolveRepo(params.owner, params.repo); + runGh([ + "pr", "close", String(params.number), + "--repo", `${resolved.owner}/${resolved.repo}`, + ]); + return this.getPrStatus(resolved.owner, resolved.repo, params.number); + } + + private async closePrWithApi(params: ClosePrParams): Promise { + const resolved = this.resolveRepo(params.owner, params.repo); + const response = await fetch( + `${this.baseUrl}/repos/${encodeURIComponent(resolved.owner)}/${encodeURIComponent(resolved.repo)}/pulls/${params.number}`, + { + method: "PATCH", + headers: this.buildHeaders(), + body: JSON.stringify({ state: "closed" }), + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`); + } + + return this.getPrStatus(resolved.owner, resolved.repo, params.number); + } + /** * List PR comments using gh CLI if available, otherwise REST API. */ @@ -3693,6 +3822,17 @@ export function parseGitHubBadgeUrl(url: string): { owner: string; repo: string return { owner: parsed.owner, repo: parsed.repo }; } +/** Resolve the current repo, throwing if it can't be determined. */ +function getCurrentRepoOrThrow(): { owner: string; repo: string } { + const currentRepo = getCurrentRepo(); + if (!currentRepo) { + throw new Error( + "Could not determine repository. Run from a git repository with a GitHub remote.", + ); + } + return currentRepo; +} + /** Map a `PrInfo.status` to the persisted `BranchGroup.prState`. */ function prInfoToBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState { if (!prInfo) return "none"; @@ -3778,3 +3918,85 @@ export async function createGroupPullRequest( }; } +export interface SyncGroupPrInput { + group: Pick; + members: Pick[]; +} + +/** + * Push an updated title/body onto the single managed group PR (U6, R6). + * + * The body always reflects the *full* current member state (checklist + + * completion summary), so repeated calls are idempotent body rewrites — each + * landing pushes the latest state and naturally coalesces with the previous one; + * no queue is needed (KTD4: idempotency anchors on the persisted `prNumber`). + * + * Out-of-band reconciliation: if the persisted PR is no longer open on GitHub + * (closed/merged out-of-band), this does NOT re-open or edit it — it returns the + * reconciled `prState` so the caller can persist it instead of erroring. + * + * Backend parity: dispatches through `GitHubClient.getPrStatus` / `updatePr`, + * which use the `gh` CLI when available and fall back to the REST API. + */ +export async function syncGroupPullRequest( + github: Pick, + input: SyncGroupPrInput, +): Promise { + const prNumber = input.group.prNumber; + if (prNumber == null) { + throw new Error(`syncGroupPullRequest: group ${input.group.id} has no persisted prNumber`); + } + + const { owner, repo } = getCurrentRepoOrThrow(); + const current = await github.getPrStatus(owner, repo, prNumber); + const currentState = prInfoToBranchGroupPrState(current); + + // Out-of-band terminal state: do not re-open or edit a closed/merged PR. + if (currentState !== "open") { + return { prNumber: current.number, prUrl: current.url, prState: currentState }; + } + + const updated = await github.updatePr({ + number: prNumber, + title: buildGroupPullRequestTitle(input.group, input.members), + body: buildGroupPullRequestBody(input.group, input.members), + }); + return { + prNumber: updated.number, + prUrl: updated.url, + prState: prInfoToBranchGroupPrState(updated), + }; +} + +/** + * Close the single managed group PR (U6, R7) — best-effort terminal + * reconciliation when a branch group is abandoned. If the PR is already + * closed/merged out-of-band on GitHub, returns the reconciled state instead of + * erroring. + */ +export async function closeGroupPullRequest( + github: Pick, + group: Pick, +): Promise { + const prNumber = group.prNumber; + if (prNumber == null) { + throw new Error(`closeGroupPullRequest: group ${group.id} has no persisted prNumber`); + } + + const { owner, repo } = getCurrentRepoOrThrow(); + const current = await github.getPrStatus(owner, repo, prNumber); + const currentState = prInfoToBranchGroupPrState(current); + + // Already terminal (closed or merged) — reconcile rather than re-close. + if (currentState !== "open") { + return { prNumber: current.number, prUrl: current.url, prState: currentState }; + } + + const closed = await github.closePr({ number: prNumber }); + return { + prNumber: closed.number, + prUrl: closed.url, + prState: prInfoToBranchGroupPrState(closed), + }; +} + diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 0e903a0234..14754a1292 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -11,7 +11,7 @@ export { type RuntimeLogSink, } from "./runtime-logger.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; -export { GitHubClient, isPrMergeReady, createGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrInput, type CreateGroupPrResult } from "./github.js"; +export { GitHubClient, isPrMergeReady, createGroupPullRequest, syncGroupPullRequest, closeGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrInput, type CreateGroupPrResult, type SyncGroupPrInput } from "./github.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { diff --git a/packages/dashboard/src/routes/register-branch-groups-routes.ts b/packages/dashboard/src/routes/register-branch-groups-routes.ts index 2029cd7553..49fddd8149 100644 --- a/packages/dashboard/src/routes/register-branch-groups-routes.ts +++ b/packages/dashboard/src/routes/register-branch-groups-routes.ts @@ -5,6 +5,17 @@ import { badRequest, notFound } from "../api-error.js"; export interface BranchGroupsRouterOptions { promoteBranchGroup?: (input: { groupId: string; projectId?: string }) => Promise>; + /** + * Terminal reconciliation when a group is abandoned (U6, R7): best-effort + * close the single managed GitHub PR. Returns the reconciled prState so the + * route can persist it. Injected so the router does not hard-depend on a + * GitHub client being available; when omitted, abandon still marks the row + * `abandoned`/`closed` without touching GitHub. + */ + closeGroupPr?: (input: { + group: BranchGroup; + projectId?: string; + }) => Promise<{ prNumber: number; prUrl: string; prState: BranchGroup["prState"] } | null>; } function parseProjectId(req: Request): string | undefined { @@ -108,5 +119,42 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup res.json({ groupId: id, ...result }); }); + // Terminal reconciliation: abandon a group. Best-effort closes the single + // managed GitHub PR (U6, R7), then marks the row `abandoned` with prState + // `closed`. The PR close is best-effort: if it fails or no closeGroupPr is + // wired, the row is still marked abandoned/closed (the GitHub PR is left for + // out-of-band reconciliation on the next read/sync). + router.post("/:id/abandon", async (req, res) => { + const id = String(req.params.id ?? "").trim(); + if (!id) throw badRequest("id is required"); + const group = store.getBranchGroup(id); + if (!group) throw notFound("Branch group not found"); + + let prState: BranchGroup["prState"] = group.prState === "merged" ? "merged" : "closed"; + let prNumber = group.prNumber; + let prUrl = group.prUrl; + + if (group.prNumber != null && group.prState === "open" && options?.closeGroupPr) { + try { + const reconciled = await options.closeGroupPr({ group, projectId: parseProjectId(req) }); + if (reconciled) { + prState = reconciled.prState; + prNumber = reconciled.prNumber; + prUrl = reconciled.prUrl; + } + } catch { + // Best-effort: leave the GitHub PR for out-of-band reconciliation. + } + } + + const updated = store.updateBranchGroup(id, { + status: "abandoned", + prState, + prNumber: prNumber ?? null, + prUrl: prUrl ?? null, + }); + res.json({ groupId: id, group: await serializeGroup(store, updated) }); + }); + return router; } diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index 5761480eb3..08c6d6c9a4 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -13,6 +13,7 @@ import { createDevServerRouter } from "../dev-server-routes.js"; import type { AiSessionStore } from "../ai-session-store.js"; import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js"; import { createBranchGroupsRouter } from "./register-branch-groups-routes.js"; +import { GitHubClient, closeGroupPullRequest } from "../github.js"; interface IntegratedRoutersOptions { router: Router; @@ -56,6 +57,17 @@ export function registerIntegratedRouters({ } return await promote(groupId); }, + closeGroupPr: async ({ group }) => { + // Best-effort terminal reconciliation: close the single managed GitHub PR + // (U6, R7). The route still marks the row abandoned/closed if this returns + // null or throws. + if (group.prNumber == null) { + return null; + } + const client = new GitHubClient(); + const result = await closeGroupPullRequest(client, group); + return { prNumber: result.prNumber, prUrl: result.prUrl, prState: result.prState }; + }, })); } diff --git a/packages/engine/src/__tests__/reliability-interactions/branch-group-pr-sync.test.ts b/packages/engine/src/__tests__/reliability-interactions/branch-group-pr-sync.test.ts new file mode 100644 index 0000000000..5304db69d8 --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/branch-group-pr-sync.test.ts @@ -0,0 +1,174 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { type TaskStore } from "@fusion/core"; +import { aiMergeTask } from "../../merger.js"; +import type { SyncGroupPrFn } from "../../group-merge-coordinator.js"; +import { git, hasGit, makeReliabilityFixture } from "./_helpers.js"; + +/** + * U6 (R6): keep the single managed group PR in sync as members land. These tests + * drive `aiMergeTask` (which fires `recordBranchGroupMemberLanding`) and assert + * the injected `syncGroupPr` callback is invoked with the latest member state + * when the group has a persisted open PR — and that a sync failure is non-fatal. + */ +async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise { + 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("U6: group PR sync on member landing", () => { + it.skipIf(!hasGit)("pushes an updated body when a member lands and the group PR is open", async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U6-SYNC-A", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const second = await store.createTask({ + id: "FN-U6-SYNC-B", + title: "U6 Second", + description: "second member", + column: "in-review", + baseBranch: "main", + branch: "fusion/fn-u6-sync-b", + prompt: "## File Scope\n- packages/engine/src/**/*.ts\n", + steps: [], + } as any); + + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U6-A", + branchName: "fusion/groups/fn-u6-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); + + // Simulate a group PR already created and open (as if a prior promotion ran). + store.updateBranchGroup(group.id, { prState: "open", prNumber: 99, prUrl: "https://github.com/o/r/pull/99" }); + + const syncCalls: Array<{ prNumber: number | null; memberIds: string[] }> = []; + const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group: g, members }) => { + syncCalls.push({ prNumber: g.prNumber, memberIds: members.map((m: { id: string }) => m.id) }); + return { prNumber: g.prNumber!, prUrl: g.prUrl!, prState: "open" as const }; + }); + + await stageMergeBranch(store, rootDir, second.id, "fnU6SyncB"); + const merge = await aiMergeTask(store, rootDir, second.id, { syncGroupPr }); + expect(merge.merged).toBe(true); + + // Sync callback fired with the persisted PR number and the group's members. + expect(syncCalls.length).toBeGreaterThanOrEqual(1); + expect(syncCalls[0].prNumber).toBe(99); + expect(syncCalls[0].memberIds).toEqual(expect.arrayContaining([task.id, second.id])); + // No duplicate PR creation — prState stays open, prNumber unchanged. + expect(store.getBranchGroup(group.id)?.prNumber).toBe(99); + expect(store.getBranchGroup(group.id)?.prState).toBe("open"); + } finally { + await fixture.cleanup(); + } + }, 45_000); + + it.skipIf(!hasGit)("does not call sync when the group has no persisted PR", async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U6-SYNC-NOPR", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U6-NOPR", + branchName: "fusion/groups/fn-u6-nopr", + autoMerge: true, + }); + await store.setTaskBranchGroup(task.id, group.id); + await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any); + + const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group: g }) => ({ prNumber: 0, prUrl: "", prState: "none" as const })); + + await stageMergeBranch(store, rootDir, task.id, "fnU6NoPr"); + const merge = await aiMergeTask(store, rootDir, task.id, { syncGroupPr }); + expect(merge.merged).toBe(true); + expect(syncGroupPr).not.toHaveBeenCalled(); + } finally { + await fixture.cleanup(); + } + }, 45_000); + + it.skipIf(!hasGit)("a sync failure is non-fatal: the landing still succeeds and prState is unchanged", async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U6-SYNC-FAIL", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U6-FAIL", + branchName: "fusion/groups/fn-u6-fail", + autoMerge: true, + }); + await store.setTaskBranchGroup(task.id, group.id); + await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any); + store.updateBranchGroup(group.id, { prState: "open", prNumber: 7, prUrl: "https://github.com/o/r/pull/7" }); + + const syncGroupPr: SyncGroupPrFn = vi.fn(async () => { + throw new Error("github down"); + }); + + await stageMergeBranch(store, rootDir, task.id, "fnU6Fail"); + const merge = await aiMergeTask(store, rootDir, task.id, { syncGroupPr }); + expect(merge.merged).toBe(true); + expect(syncGroupPr).toHaveBeenCalled(); + // prState/prNumber unchanged despite the sync failure (retryable next landing). + expect(store.getBranchGroup(group.id)?.prState).toBe("open"); + expect(store.getBranchGroup(group.id)?.prNumber).toBe(7); + } finally { + await fixture.cleanup(); + } + }, 45_000); + + it.skipIf(!hasGit)("reconciles prState when the persisted PR is closed/merged out-of-band", async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U6-SYNC-OOB", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U6-OOB", + branchName: "fusion/groups/fn-u6-oob", + autoMerge: true, + }); + await store.setTaskBranchGroup(task.id, group.id); + await store.updateTask(task.id, { branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" } } as any); + store.updateBranchGroup(group.id, { prState: "open", prNumber: 13, prUrl: "https://github.com/o/r/pull/13" }); + + // GitHub reports the PR merged out-of-band; sync returns the reconciled state. + const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group: g }) => ({ + prNumber: g.prNumber!, + prUrl: g.prUrl!, + prState: "merged" as const, + })); + + await stageMergeBranch(store, rootDir, task.id, "fnU6Oob"); + const merge = await aiMergeTask(store, rootDir, task.id, { syncGroupPr }); + expect(merge.merged).toBe(true); + // The merger persists the reconciled prState rather than leaving stale "open". + expect(store.getBranchGroup(group.id)?.prState).toBe("merged"); + } finally { + await fixture.cleanup(); + } + }, 45_000); +}); diff --git a/packages/engine/src/group-merge-coordinator.ts b/packages/engine/src/group-merge-coordinator.ts index e2ee141e55..f0a9452e85 100644 --- a/packages/engine/src/group-merge-coordinator.ts +++ b/packages/engine/src/group-merge-coordinator.ts @@ -34,6 +34,44 @@ export type CreateGroupPrFn = (input: { baseBranch: string; }) => Promise<{ prNumber: number; prUrl: string; prState: BranchGroupPrState }>; +/** Result shape shared by group-PR sync/close callbacks. */ +export interface GroupPrReconcileResult { + prNumber: number; + prUrl: string; + prState: BranchGroupPrState; +} + +/** + * Injected callback (KTD7) that PUSHES an updated body/title onto the single + * managed group PR (member checklist + x/N completion) as members land (U6, R6). + * Mirrors {@link CreateGroupPrFn}'s injection seam; closes over a dashboard-built + * `GitHubClient` at the CLI sites so the engine never imports the dashboard. + * + * The body always reflects the full current member state, so repeated calls are + * idempotent body rewrites that naturally coalesce — no queue is needed. + * + * Out-of-band reconciliation: when the persisted PR is closed/merged on GitHub, + * this returns the reconciled `prState` (closed/merged) rather than re-opening or + * erroring, so the caller can persist the corrected state. + * + * The group passed in carries the persisted `prNumber`; callers must only invoke + * this when `prNumber` is set. + */ +export type SyncGroupPrFn = (input: { + group: BranchGroup; + members: Task[]; +}) => Promise; + +/** + * Injected callback (KTD7) that closes the single managed group PR (best-effort) + * during terminal reconciliation when a group is abandoned (U6, R7). If the PR is + * already closed/merged out-of-band, it returns the reconciled state instead of + * erroring. Callers must only invoke this when a `prNumber` is persisted. + */ +export type CloseGroupPrFn = (input: { + group: BranchGroup; +}) => Promise; + export interface BranchGroupCompletionStatus { complete: boolean; totalMembers: number; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 02febd4933..7e19eaf2cc 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -66,6 +66,9 @@ export { type BranchGroupCompletionStatus, type BranchGroupPromotionResult, type CreateGroupPrFn, + type SyncGroupPrFn, + type CloseGroupPrFn, + type GroupPrReconcileResult, } from "./group-merge-coordinator.js"; export { resolveMergeIntegrationRoot, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 5df20aea11..c4af61163e 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -82,6 +82,7 @@ import { type PostMergeAuditMode, type TaskSourceIssue, type Task, + type BranchGroup, type AutostashOrphanRecord, normalizeMergeAdvanceAutoSyncMode, isMergeRequestContractShadowEnabled, @@ -5941,6 +5942,14 @@ export interface MergerOptions { allowDirtyLocalCheckoutSync?: boolean; /** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */ pluginRunner?: import("./plugin-runner.js").PluginRunner; + /** + * Injected group-PR sync callback (KTD7, U6). When a shared branch-group + * member lands and its group has a persisted open PR, the merger uses this to + * push an updated PR body (member checklist + x/N completion). Failures are + * non-fatal and retryable on the next landing. Injected from the CLI layer so + * the engine never imports the dashboard GitHub client. + */ + syncGroupPr?: import("./group-merge-coordinator.js").SyncGroupPrFn; } function quoteArg(value: string): string { @@ -7509,6 +7518,58 @@ export async function aiMergeTask( } catch { // best-effort audit } + + // U6 (R6): keep the single managed group PR in sync as members land. Only + // when the group already has a persisted open PR; the body always reflects + // the full current member state, so each landing pushes the latest x/N + // (idempotent body rewrite — coalesces naturally, no queue). Failures are + // non-fatal and retryable on the next landing / explicit refresh. + if (options.syncGroupPr) { + try { + const latestGroup = await Promise.resolve( + (store as any).getBranchGroup?.(groupRouting.branchGroup.id), + ) as BranchGroup | null | undefined; + if (latestGroup && latestGroup.prNumber != null && latestGroup.prState === "open") { + const members = (await Promise.resolve( + (store as any).listTasksByBranchGroup?.(latestGroup.id), + )) as Task[] | undefined; + const reconciled = await options.syncGroupPr({ + group: latestGroup, + members: members ?? [], + }); + // Out-of-band reconciliation: if GitHub reports the PR is no longer + // open (closed/merged), persist the corrected prState rather than + // leaving a stale "open". + if (reconciled.prState !== latestGroup.prState) { + await Promise.resolve( + (store as any).updateBranchGroup?.(latestGroup.id, { + prState: reconciled.prState, + prNumber: reconciled.prNumber, + prUrl: reconciled.prUrl, + }), + ); + } + } + } catch (err) { + // Non-fatal: never fail the merge/landing because PR sync failed. + try { + await (store as any).recordRunAuditEvent?.({ + taskId, + agentId: "merger", + runId: `merge-${taskId}`, + domain: "git", + mutationType: "merge:branch-group-pr-sync-failed", + target: taskId, + metadata: { + groupId: groupRouting.branchGroup.id, + error: err instanceof Error ? err.message : String(err), + }, + }); + } catch { + // best-effort audit + } + } + } }; if (groupRouting) { const auditRunId = `merge-${taskId}`; diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index 1e33434976..9feef342f8 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -37,6 +37,7 @@ export interface EngineManagerOptions { getMergeStrategy?: ProjectEngineOptions["getMergeStrategy"]; processPullRequestMerge?: ProjectEngineOptions["processPullRequestMerge"]; createGroupPr?: ProjectEngineOptions["createGroupPr"]; + syncGroupPr?: ProjectEngineOptions["syncGroupPr"]; getTaskMergeBlocker?: ProjectEngineOptions["getTaskMergeBlocker"]; onInsightRunProcessed?: ProjectEngineOptions["onInsightRunProcessed"]; } @@ -483,6 +484,7 @@ export class ProjectEngineManager { getMergeStrategy: this.options.getMergeStrategy, processPullRequestMerge: this.options.processPullRequestMerge, createGroupPr: this.options.createGroupPr, + syncGroupPr: this.options.syncGroupPr, getTaskMergeBlocker: this.options.getTaskMergeBlocker, onInsightRunProcessed: this.options.onInsightRunProcessed, ...overrides, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 6b6912f9ad..1f1d3a5365 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -27,7 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js"; import { runAiMerge } from "./merger-ai.js"; -import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn } from "./group-merge-coordinator.js"; +import { promoteBranchGroup, type BranchGroupPromotionResult, type CreateGroupPrFn, type SyncGroupPrFn } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; @@ -213,6 +213,13 @@ export interface ProjectEngineOptions { * `prState` to "open" without creating a real PR (legacy behaviour). */ createGroupPr?: CreateGroupPrFn; + /** + * Pushes an updated body onto the single managed group PR as members land + * (KTD7, U6). Injected from the CLI layer alongside `createGroupPr`; closes + * over the dashboard `GitHubClient`. When absent, member landings do not sync + * the PR body. + */ + syncGroupPr?: SyncGroupPrFn; /** * Returns the merge blocker reason for a task, or null/undefined if * the task is eligible for merge. Imported from @fusion/core. @@ -1982,6 +1989,7 @@ export class ProjectEngine { usageLimitPauser, agentStore, signal: this.mergeAbortController.signal, + syncGroupPr: this.options.syncGroupPr, onSession: (session: { dispose: () => void }) => { this.activeMergeSession = session; }, From 9512e983305c4965ba6be36680a438f90fcc25c0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:31:18 -0700 Subject: [PATCH 08/21] feat(FN-branch-group): surface group PR controls in dashboard + CLI (U7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend BranchGroupCard/GroupTaskModal with an Abandon action (open PRs) and terminal merged/closed badges; promote stays completion-gated. New fn branch-group list|show|promote (alias fn bg) reaching the same coordinator path with createGroupPrCallback wired — agent-native parity with the dashboard promote flow, same completion-gate rejection. --- .changeset/fn-branch-group-single-pr.md | 2 + packages/cli/src/bin.ts | 45 +++++ .../commands/__tests__/branch-group.test.ts | 176 ++++++++++++++++++ packages/cli/src/commands/branch-group.ts | 169 +++++++++++++++++ packages/dashboard/app/api/legacy.ts | 7 + .../app/components/BranchGroupCard.tsx | 40 +++- .../app/components/GroupTaskModal.tsx | 28 ++- .../__tests__/BranchGroupCard.test.tsx | 41 ++++ .../__tests__/GroupTaskModal.test.tsx | 34 +++- 9 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/branch-group.test.ts create mode 100644 packages/cli/src/commands/branch-group.ts diff --git a/.changeset/fn-branch-group-single-pr.md b/.changeset/fn-branch-group-single-pr.md index 648a2c6dcc..41c03337fa 100644 --- a/.changeset/fn-branch-group-single-pr.md +++ b/.changeset/fn-branch-group-single-pr.md @@ -5,3 +5,5 @@ Branch-group promotion now creates a single real GitHub PR for the group integration branch when promoting a completed PR-mode group. The PR number/url/state are persisted on the branch group and promotion is idempotent — re-running never opens a second PR (an existing persisted or open PR is reused). The GitHub client is injected into the engine via the same option-callback seam as `processPullRequestMerge`, wired at the `fn daemon`, `fn dashboard`, and `fn serve` construction sites. PR creation only happens for eligible (completion-gated, auto-merge-allowed) groups, and a GitHub failure leaves the group recoverable rather than persisting a false PR state. The single managed group PR is now kept in sync through its terminal lifecycle: as additional members land, the PR body is rewritten with the latest member checklist and x/N completion (idempotent body rewrite — sync failures are non-fatal and retry on the next landing). When the persisted PR is closed or merged out-of-band on GitHub, the stored `prState` is reconciled rather than re-opened. Abandoning a group best-effort closes its GitHub PR and marks `prState` `closed` (or preserves `merged`). New injected `syncGroupPr` callback and dashboard `updatePr`/`closePr` GitHub-client helpers back this flow. + +The branch-group surface is completion-gated end-to-end: the dashboard branch-group card and Group Task modal show member progress before completion, reveal the promote/Open-PR control only when the group is complete, render the persisted PR link once promoted, expose an Abandon action while the PR is open, and display a terminal merged/closed state. A new agent-native CLI command (`fn branch-group list | show | promote `) reaches the same promotion coordinator path the dashboard uses — promoting a complete group opens/links the same single managed PR, and an incomplete group is rejected with the same completion-gate message. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 4a34970f45..f4c6edd21b 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -124,6 +124,7 @@ async function loadCommandHandlers() { const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js"); + const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote } = await import("./commands/branch-group.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js"); const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice, runMissionLinkGoal, runMissionUnlinkGoal, runMissionGoals } = await import("./commands/mission.js"); @@ -184,6 +185,9 @@ async function loadCommandHandlers() { runGitFetch, runGitPull, runGitPush, + runBranchGroupList, + runBranchGroupShow, + runBranchGroupPromote, runBackupCreate, runBackupList, runBackupRestore, @@ -365,6 +369,10 @@ PR: fn git push Push current branch fn git pull Pull current branch fn git fetch [remote] Fetch from remote (default: origin) + fn branch-group list List branch groups with completion + PR state + fn branch-group show Show a branch group's members and completion gate + fn branch-group promote + Promote a complete group (opens/links the single managed PR) fn agent stop Stop a running agent (pause execution) fn agent start Start a stopped agent (resume execution) fn agent import [--dry-run] [--skip-existing] @@ -623,6 +631,9 @@ async function main() { runGitFetch, runGitPull, runGitPush, + runBranchGroupList, + runBranchGroupShow, + runBranchGroupPromote, runBackupCreate, runBackupList, runBackupRestore, @@ -1554,6 +1565,40 @@ async function main() { break; } + case "branch-group": + case "bg": { + const subcommand = args[1]; + switch (subcommand) { + case "list": + case "ls": + await runBranchGroupList(projectName); + break; + case "show": { + const id = args[2]; + if (!id) { + console.error("Usage: fn branch-group show "); + process.exit(1); + } + await runBranchGroupShow(id, projectName); + break; + } + case "promote": { + const id = args[2]; + if (!id) { + console.error("Usage: fn branch-group promote "); + process.exit(1); + } + await runBranchGroupPromote(id, projectName); + break; + } + default: + console.error(`Unknown subcommand: branch-group ${subcommand || ""}`); + console.log("Try: fn branch-group list | show | promote "); + process.exit(1); + } + break; + } + case "backup": { const create = args.includes("--create"); const list = args.includes("--list"); diff --git a/packages/cli/src/commands/__tests__/branch-group.test.ts b/packages/cli/src/commands/__tests__/branch-group.test.ts new file mode 100644 index 0000000000..580a5fcb20 --- /dev/null +++ b/packages/cli/src/commands/__tests__/branch-group.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ---- Mocks ---------------------------------------------------------------- + +vi.mock("../../project-context.js", () => ({ + resolveProject: vi.fn(), +})); + +const promoteBranchGroupMock = vi.fn(); +vi.mock("@fusion/engine", () => ({ + promoteBranchGroup: (...args: unknown[]) => promoteBranchGroupMock(...args), + resolveIntegrationBranch: vi.fn(async () => "main"), +})); + +// The canonical completion predicate lives in @fusion/core; keep its real +// behavior so the CLI gate matches the dashboard route gate (parity). +vi.mock("@fusion/dashboard", () => ({ + GitHubClient: vi.fn(function GitHubClient() {}), +})); + +const createGroupPrCallbackMock = vi.fn(() => async () => ({ prNumber: 1, prUrl: "x", prState: "open" as const })); +vi.mock("../task-lifecycle.js", () => ({ + createGroupPrCallback: (...args: unknown[]) => createGroupPrCallbackMock(...args), +})); + +import { resolveProject } from "../../project-context.js"; +import { runBranchGroupPromote, runBranchGroupList } from "../branch-group.js"; + +const LANDED_TASK = { + id: "FN-1", + title: "one", + description: "one", + column: "in-review", + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: "feature/shared", + }, + branchContext: { source: "planning", assignmentMode: "shared", groupId: "BG-1" }, +}; + +const UNLANDED_TASK = { + ...LANDED_TASK, + id: "FN-2", + column: "in-progress", + mergeDetails: undefined, +}; + +function makeStore(group: Record, members: unknown[]) { + return { + getBranchGroup: vi.fn(() => group), + listBranchGroups: vi.fn(() => [group]), + listTasksByBranchGroup: vi.fn(async () => members), + getSettings: vi.fn(async () => ({ + autoMerge: false, + globalPause: false, + enginePaused: false, + mergeStrategy: "merge", + baseBranch: "main", + })), + recordRunAuditEvent: vi.fn(), + }; +} + +const BASE_GROUP = { + id: "BG-1", + sourceType: "planning", + sourceId: "PS-1", + branchName: "feature/shared", + status: "open" as const, + prState: "none" as const, + autoMerge: false, +}; + +describe("branch-group CLI promote (agent-native parity)", () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + promoteBranchGroupMock.mockReset(); + createGroupPrCallbackMock.mockClear(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + vi.mocked(resolveProject).mockReset(); + }); + + it("promotes a complete group via the same coordinator path and prints the PR url", async () => { + const store = makeStore(BASE_GROUP, [LANDED_TASK]); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", + projectPath: "/tmp/p", + projectName: "p", + isRegistered: true, + store: store as never, + }); + promoteBranchGroupMock.mockResolvedValue({ + groupId: "BG-1", + promoted: true, + alreadyFinalized: false, + reason: "promoted", + status: "open", + prState: "open", + prNumber: 42, + prUrl: "https://example/pr/42", + }); + + await runBranchGroupPromote("BG-1"); + + // Reaches the SAME standalone coordinator the engine bridge method delegates to, + // with the createGroupPr callback wired (the dashboard route ends here too). + expect(createGroupPrCallbackMock).toHaveBeenCalledTimes(1); + expect(promoteBranchGroupMock).toHaveBeenCalledTimes(1); + const callArg = promoteBranchGroupMock.mock.calls[0][0] as Record; + expect(callArg.groupId).toBe("BG-1"); + expect(callArg.createGroupPr).toBeTypeOf("function"); + expect(logSpy.mock.calls.flat().join("\n")).toContain("https://example/pr/42"); + }); + + it("returns the same prUrl shape the promote route returns (parity)", async () => { + const store = makeStore(BASE_GROUP, [LANDED_TASK]); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never, + }); + const routeShape = { + groupId: "BG-1", + promoted: true, + alreadyFinalized: false, + reason: "promoted", + status: "open", + prState: "open", + prNumber: 7, + prUrl: "https://example/pr/7", + }; + promoteBranchGroupMock.mockResolvedValue(routeShape); + + await runBranchGroupPromote("BG-1"); + + const result = await promoteBranchGroupMock.mock.results[0].value; + expect(result).toMatchObject({ prNumber: 7, prUrl: "https://example/pr/7", prState: "open" }); + }); + + it("rejects an incomplete group with the same completion gate message", async () => { + const store = makeStore(BASE_GROUP, [LANDED_TASK, UNLANDED_TASK]); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never, + }); + + await expect(runBranchGroupPromote("BG-1")).rejects.toThrow(/process.exit/); + expect(promoteBranchGroupMock).not.toHaveBeenCalled(); + expect(errSpy.mock.calls.flat().join("\n")).toContain("Branch group completion gate not satisfied"); + }); + + it("lists groups with completion + PR state", async () => { + const store = makeStore({ ...BASE_GROUP, prState: "open", prNumber: 3 }, [LANDED_TASK]); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never, + }); + + await runBranchGroupList(); + + const out = logSpy.mock.calls.flat().join("\n"); + expect(out).toContain("BG-1"); + expect(out).toContain("feature/shared"); + expect(out).toContain("PR open"); + }); +}); diff --git a/packages/cli/src/commands/branch-group.ts b/packages/cli/src/commands/branch-group.ts new file mode 100644 index 0000000000..dc940a8eb6 --- /dev/null +++ b/packages/cli/src/commands/branch-group.ts @@ -0,0 +1,169 @@ +import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, type BranchGroup, type Settings } from "@fusion/core"; +import { promoteBranchGroup, resolveIntegrationBranch } from "@fusion/engine"; +import { GitHubClient } from "@fusion/dashboard"; +import { resolveProject } from "../project-context.js"; +import { createGroupPrCallback } from "./task-lifecycle.js"; + +/** + * Agent-native parity (R10): expose the same branch-group surfacing/controls a + * dashboard user gets (`GET /api/branch-groups`, `GET /:id`, `POST /:id/promote`) + * from the CLI. + * + * Pattern chosen: store-direct + the standalone `promoteBranchGroup` coordinator + * (the same function the engine bridge method delegates to), with the + * `createGroupPr` callback wired exactly as the dashboard/daemon construction + * sites wire it (`createGroupPrCallback(githubClient)`). The dashboard route's + * `promoteBranchGroup` option ultimately reaches this same coordinator function, + * so the CLI promote produces the SAME single managed PR — parity of outcome. + * + * This matches the established CLI convention (`task merge`, `task pr-create`, + * `git pull`) of operating against the resolved `TaskStore` and engine helpers + * directly rather than calling the dashboard HTTP API. + */ + +interface BranchGroupCommandContext { + store: TaskStore; + projectPath: string; +} + +async function getBranchGroupContext(projectName?: string): Promise { + try { + const context = await resolveProject(projectName); + if (context) { + return { store: context.store, projectPath: context.projectPath }; + } + } catch { + // fall through to a local store rooted at cwd + } + if (projectName) { + throw new Error(`Project ${projectName} not found`); + } + const store = new TaskStore(process.cwd()); + await store.init(); + return { store, projectPath: process.cwd() }; +} + +async function serializeCompletion(store: TaskStore, group: BranchGroup) { + const members = await store.listTasksByBranchGroup(group.id); + const memberRows = members.map((task) => ({ + taskId: task.id, + title: task.title ?? task.description, + column: task.column, + landed: isBranchGroupMemberLanded(task, group), + })); + const landed = memberRows.filter((member) => member.landed).length; + return { + members: memberRows, + landed, + total: memberRows.length, + complete: isBranchGroupComplete(members, group), + }; +} + +export async function runBranchGroupList(projectName?: string) { + const { store } = await getBranchGroupContext(projectName); + const groups = store.listBranchGroups(); + + if (groups.length === 0) { + console.log("\n No branch groups yet.\n"); + return; + } + + console.log(); + for (const group of groups) { + const completion = await serializeCompletion(store, group); + const prState = group.prState === "none" ? "no PR" : `PR ${group.prState}`; + const gate = completion.complete ? "complete" : `${completion.landed}/${completion.total}`; + console.log(` ${group.id} ${group.branchName} [${group.status}] (${gate}) ${prState}`); + } + console.log(); +} + +export async function runBranchGroupShow(id: string, projectName?: string) { + const { store } = await getBranchGroupContext(projectName); + const group = store.getBranchGroup(id); + if (!group) { + console.error(`\n ✗ Branch group ${id} not found\n`); + process.exit(1); + } + + const completion = await serializeCompletion(store, group); + + console.log(); + console.log(` Branch group ${group.id}`); + console.log(` Branch: ${group.branchName}`); + console.log(` Source: ${group.sourceType}/${group.sourceId}`); + console.log(` Status: ${group.status}`); + console.log(` PR state: ${group.prState}${group.prNumber != null ? ` (#${group.prNumber})` : ""}`); + if (group.prUrl) { + console.log(` PR URL: ${group.prUrl}`); + } + console.log(` Progress: ${completion.landed} of ${completion.total} members finished${completion.complete ? " (complete)" : ""}`); + console.log(); + console.log(" Members:"); + for (const member of completion.members) { + const mark = member.landed ? "✓" : "○"; + console.log(` ${mark} ${member.taskId} ${member.title} [${member.column}]`); + } + console.log(); +} + +export async function runBranchGroupPromote(id: string, projectName?: string) { + const { store, projectPath } = await getBranchGroupContext(projectName); + const group = store.getBranchGroup(id); + if (!group) { + console.error(`\n ✗ Branch group ${id} not found\n`); + process.exit(1); + } + + // Completion gate — mirror the dashboard `POST /:id/promote` gate (R8) so the + // CLI rejects an incomplete group with the same message a dashboard user sees. + const members = await store.listTasksByBranchGroup(group.id); + if (!isBranchGroupComplete(members, group)) { + console.error("\n ✗ Branch group completion gate not satisfied\n"); + process.exit(1); + } + + const settings = (await store.getSettings()) as Settings; + const resolvedIntegrationBranch = await resolveIntegrationBranch(projectPath, settings); + const githubClient = new GitHubClient(process.env.GITHUB_TOKEN); + + console.log(`\n Promoting branch group ${group.id}…\n`); + + try { + const result = await promoteBranchGroup({ + store, + rootDir: projectPath, + groupId: group.id, + settings: { + autoMerge: settings.autoMerge, + globalPause: settings.globalPause, + enginePaused: settings.enginePaused, + mergeStrategy: settings.mergeStrategy, + integrationBranch: resolvedIntegrationBranch, + baseBranch: settings.baseBranch, + }, + createGroupPr: createGroupPrCallback(githubClient), + recordAudit: (event) => { + store.recordRunAuditEvent({ + agentId: "cli:branch-group-promote", + runId: `cli-promote-${group.id}`, + domain: event.domain as Parameters[0]["domain"], + mutationType: event.mutationType as Parameters[0]["mutationType"], + target: event.target, + metadata: event.metadata, + }); + }, + }); + + if (result.prUrl) { + console.log(` ✓ Group ${result.groupId} — PR ${result.prState}: ${result.prUrl}`); + } else { + console.log(` ✓ Group ${result.groupId} — ${result.reason} (status: ${result.status}, prState: ${result.prState})`); + } + console.log(); + } catch (err) { + console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } +} diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0a987a4900..3b702ee8c3 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -618,6 +618,13 @@ export function apiPromoteBranchGroup(id: string, projectId?: string): Promise

{ + return api<{ groupId: string; group: BranchGroupSummary }>(withProjectId(`/branch-groups/${id}/abandon`, projectId), { + method: "POST", + body: JSON.stringify({}), + }); +} + export type RecoverBranchBindingOutcome = | { taskId: string; result: "applied"; branch: string; aheadCount: number; integrationBase: string; previousBranch: string | null } | { taskId: string; result: "skipped"; reason: "binding-intact" | "no-live-branch" | "ambiguous-candidates" | "no-unique-work"; candidates?: Array<{ branch: string; aheadCount: number }> }; diff --git a/packages/dashboard/app/components/BranchGroupCard.tsx b/packages/dashboard/app/components/BranchGroupCard.tsx index c4073e757c..81d618ba8f 100644 --- a/packages/dashboard/app/components/BranchGroupCard.tsx +++ b/packages/dashboard/app/components/BranchGroupCard.tsx @@ -2,7 +2,7 @@ import "./BranchGroupCard.css"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react"; import type { BranchGroupSummary } from "../api"; -import { apiGetBranchGroup, apiPromoteBranchGroup } from "../api"; +import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api"; import { subscribeSse } from "../sse-bus"; interface BranchGroupCardProps { @@ -15,6 +15,7 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [promoting, setPromoting] = useState(false); + const [abandoning, setAbandoning] = useState(false); const [collapsed, setCollapsed] = useState(false); const loadGroup = useCallback(async () => { @@ -87,6 +88,16 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { } }, [groupId, loadGroup, projectId]); + const onAbandon = useCallback(async () => { + setAbandoning(true); + try { + await apiAbandonBranchGroup(groupId, projectId); + await loadGroup(); + } finally { + setAbandoning(false); + } + }, [groupId, loadGroup, projectId]); + if (loading) { return

Loading branch group…
; } @@ -137,7 +148,19 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { )} - {!collapsed && complete && ( + {!collapsed && (group.prState === "merged" || group.prState === "closed") && ( +
+ {group.prState === "merged" ? "Group PR merged" : "Group PR closed"} + {group.prUrl && ( + + PR #{group.prNumber ?? "—"} + + + )} +
+ )} + + {!collapsed && complete && group.prState !== "merged" && group.prState !== "closed" && ( diff --git a/packages/dashboard/app/components/GroupTaskModal.tsx b/packages/dashboard/app/components/GroupTaskModal.tsx index 2c66efa8fb..c09e6700f1 100644 --- a/packages/dashboard/app/components/GroupTaskModal.tsx +++ b/packages/dashboard/app/components/GroupTaskModal.tsx @@ -1,7 +1,7 @@ import "./GroupTaskModal.css"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react"; -import { apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api"; +import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api"; import { subscribeSse } from "../sse-bus"; interface GroupTaskModalProps { @@ -16,6 +16,7 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb const [group, setGroup] = useState(null); const [loading, setLoading] = useState(false); const [promoting, setPromoting] = useState(false); + const [abandoning, setAbandoning] = useState(false); const [error, setError] = useState(null); const loadGroup = useCallback(async () => { @@ -81,6 +82,17 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb } }, [groupId, loadGroup, projectId]); + const onAbandon = useCallback(async () => { + if (!groupId) return; + setAbandoning(true); + try { + await apiAbandonBranchGroup(groupId, projectId); + await loadGroup(); + } finally { + setAbandoning(false); + } + }, [groupId, loadGroup, projectId]); + if (!isOpen || !groupId) return null; return ( @@ -138,7 +150,13 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb )} - {group.completion.complete && ( + {(group.prState === "merged" || group.prState === "closed") && ( +
+ {group.prState === "merged" ? "Group PR merged" : "Group PR closed"} +
+ )} + + {group.completion.complete && group.prState !== "merged" && group.prState !== "closed" && (
{group.autoMerge ? ( Auto-merge enabled @@ -148,6 +166,12 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb {group.prState === "none" ? "Open PR" : "Merge group into main"} )} + {group.prState === "open" && ( + + )}
)} diff --git a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx index 161817e9cc..e29793d4a9 100644 --- a/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/BranchGroupCard.test.tsx @@ -5,10 +5,12 @@ import { BranchGroupCard } from "../BranchGroupCard"; const apiGetBranchGroup = vi.fn(); const apiPromoteBranchGroup = vi.fn(); +const apiAbandonBranchGroup = vi.fn(); vi.mock("../../api", () => ({ apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args), apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args), + apiAbandonBranchGroup: (...args: unknown[]) => apiAbandonBranchGroup(...args), })); vi.mock("../../sse-bus", () => ({ @@ -50,6 +52,7 @@ describe("BranchGroupCard", () => { beforeEach(() => { apiGetBranchGroup.mockReset(); apiPromoteBranchGroup.mockReset(); + apiAbandonBranchGroup.mockReset(); }); it("hides promote control while incomplete", async () => { @@ -96,6 +99,44 @@ describe("BranchGroupCard", () => { expect(await screen.findByRole("link", { name: /pr #9/i })).toBeInTheDocument(); }); + const completeMembers = [ + { taskId: "FN-1", title: "one", column: "done", landed: true }, + { taskId: "FN-2", title: "two", column: "done", landed: true }, + ]; + + it("shows Abandon control while group PR is open", async () => { + apiGetBranchGroup.mockResolvedValue({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "open", prNumber: 11, prUrl: "https://example/pr/11" }), + }); + apiAbandonBranchGroup.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) }); + + render(); + const abandon = await screen.findByRole("button", { name: /abandon group/i }); + fireEvent.click(abandon); + + await waitFor(() => { + expect(apiAbandonBranchGroup).toHaveBeenCalledWith("BG-1", undefined); + }); + }); + + it("shows terminal merged state and hides promote/abandon", async () => { + apiGetBranchGroup.mockResolvedValue({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 5, prUrl: "https://example/pr/5" }), + }); + render(); + expect(await screen.findByText("Group PR merged")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull(); + }); + + it("shows terminal closed state", async () => { + apiGetBranchGroup.mockResolvedValue({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "closed", prNumber: 6, prUrl: "https://example/pr/6" }), + }); + render(); + expect(await screen.findByText("Group PR closed")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull(); + }); + it("shows members by default and collapses via toggle", async () => { apiGetBranchGroup.mockResolvedValue({ group: makeGroup() }); render(); diff --git a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx index a5b93f7d2e..19570e00b6 100644 --- a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GroupTaskModal } from "../GroupTaskModal"; -import { apiGetBranchGroup, apiPromoteBranchGroup } from "../../api"; +import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api"; vi.mock("../../api", async () => { const actual = await vi.importActual("../../api"); @@ -9,6 +9,7 @@ vi.mock("../../api", async () => { ...actual, apiGetBranchGroup: vi.fn(), apiPromoteBranchGroup: vi.fn(), + apiAbandonBranchGroup: vi.fn(), }; }); @@ -19,6 +20,12 @@ vi.mock("../../hooks/useNavigationHistory", () => ({ const mockedGet = vi.mocked(apiGetBranchGroup); const mockedPromote = vi.mocked(apiPromoteBranchGroup); +const mockedAbandon = vi.mocked(apiAbandonBranchGroup); + +const completeMembers = [ + { taskId: "FN-1", title: "First", column: "done", landed: true }, + { taskId: "FN-2", title: "Second", column: "done", landed: true }, +]; function makeGroup(overrides: Record = {}) { return { @@ -42,6 +49,7 @@ describe("GroupTaskModal", () => { beforeEach(() => { mockedPromote.mockReset(); mockedGet.mockReset(); + mockedAbandon.mockReset(); }); it("renders group summary and member open action", async () => { @@ -102,4 +110,28 @@ describe("GroupTaskModal", () => { expect(link.getAttribute("href")).toContain("/pull/1"); expect(link.textContent).toContain("open"); }); + + it("abandons an open group PR", async () => { + mockedGet.mockResolvedValue({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "open", prNumber: 2, prUrl: "https://github.com/org/repo/pull/2" }), + } as Awaited>); + mockedAbandon.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) } as Awaited>); + + render(); + + const action = await screen.findByRole("button", { name: /abandon group/i }); + await userEvent.click(action); + await waitFor(() => expect(mockedAbandon).toHaveBeenCalledWith("BG-1", undefined)); + }); + + it("shows terminal state and hides controls when merged", async () => { + mockedGet.mockResolvedValue({ + group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 3, prUrl: "https://github.com/org/repo/pull/3" }), + } as Awaited>); + + render(); + + expect(await screen.findByText("Group PR merged")).toBeDefined(); + expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull(); + }); }); From 3bea12f5d871217c609bccae76c080b5e8878fa0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:48:23 -0700 Subject: [PATCH 09/21] test(FN-branch-group): end-to-end planning + mission single-PR flows (U8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine half: real-git E2E covering planning- and mission-sourced groups — members land on the group branch (never main/sibling), completion-gated single PR via injected callback, re-promote idempotency, sync on later landing, abandon→closed, and a self-healing finalize mid-flow staying group-anchored. Core half: real triageFeature stamps the BG- id, member enumeration, and the canonical completion gate flipping on landing. --- .../branch-group-entry-point-e2e.test.ts | 128 ++++++ .../branch-group-single-pr-e2e.test.ts | 393 ++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts create mode 100644 packages/engine/src/__tests__/reliability-interactions/branch-group-single-pr-e2e.test.ts diff --git a/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts b/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts new file mode 100644 index 0000000000..1747a4c405 --- /dev/null +++ b/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { TaskStore } from "../store.js"; +import { isBranchGroupComplete } from "../branch-group-completion.js"; + +/** + * U8 (R9): entry-point half of the end-to-end single managed-PR flow. + * + * Composition choice (stated honestly): a single test that drives planning → + * engine → GitHub across the dashboard↔engine↔core package boundaries is + * impractical. So the flow is composed: + * - This core test proves the ENTRY-POINT contract with REAL core objects + * (TaskStore + MissionStore) and a real temp-dir SQLite store: mission triage + * stamps the real `BG-` group id into `branchContext.groupId`, members never + * take the shared branch as their own working branch, and + * `listTasksByBranchGroup(group.id)` enumerates exactly those members — which + * is what completion gating and PR rollup depend on. + * - The engine half (land on shared branch → ONE PR → sync/idempotency/abandon + * → safe self-heal routing) is proven with real git + real merger/coordinator + * in `packages/engine/src/__tests__/reliability-interactions/branch-group-single-pr-e2e.test.ts`, + * using a group created the same way (same sourceType/branchName shape). + * - The planning route entry point's group + branchContext shape is proven by + * the route-level planning tests; this file covers the mission entry point at + * the core level (where mission triage lives). + * + * No network and no GitHub: PR creation is the engine-side concern; here we only + * assert the membership identity the PR flow consumes. + */ + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "fusion-bg-entry-e2e-")); +} + +describe("U8 entry-point E2E: mission triage → shared group membership identity", () => { + let rootDir: string; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); + await store.init(); + }); + + afterEach(async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("creates a shared group with a real BG- id and enumerates triaged members by group.id", async () => { + const missionStore = store.getMissionStore(); + const mission = missionStore.createMission({ + title: "Launch billing", + description: "Mission entry-point e2e", + baseBranch: "main", + }); + const milestone = missionStore.addMilestone(mission.id, { title: "M1" }); + const slice = missionStore.addSlice(milestone.id, { title: "S1" }); + const featureA = missionStore.addFeature(slice.id, { title: "Billing backend", description: "backend" }); + const featureB = missionStore.addFeature(slice.id, { title: "Billing UI", description: "ui" }); + + // Triage both features in shared mode (the default mission branch strategy) — + // the same entry point the dashboard/mission flow uses. + await missionStore.triageFeature(featureA.id, undefined, undefined, { branch: "fusion/groups/billing", assignmentMode: "shared" }); + await missionStore.triageFeature(featureB.id, undefined, undefined, { branch: "fusion/groups/billing", assignmentMode: "shared" }); + + // A real BranchGroup row exists for this mission with a BG- id (not synthetic). + const group = store.getBranchGroupBySource("mission", mission.id); + expect(group).not.toBeNull(); + expect(group!.id.startsWith("BG-")).toBe(true); + expect(group!.branchName).toBe("fusion/groups/billing"); + + // Both triaged tasks carry the REAL group id in branchContext (U1), not the + // legacy synthetic `mission:` form. + const linkedA = missionStore.getFeature(featureA.id)!.taskId!; + const linkedB = missionStore.getFeature(featureB.id)!.taskId!; + const taskA = (await store.getTask(linkedA))!; + const taskB = (await store.getTask(linkedB))!; + expect(taskA.branchContext?.groupId).toBe(group!.id); + expect(taskB.branchContext?.groupId).toBe(group!.id); + expect(taskA.branchContext?.groupId).not.toBe(`mission:${mission.id}`); + expect(taskA.branchContext?.source).toBe("mission"); + expect(taskA.branchContext?.assignmentMode).toBe("shared"); + + // No member uses the shared branch as its own working branch (per-task working + // branches are derived from the shared branch base). + expect(taskA.branch).not.toBe(group!.branchName); + expect(taskB.branch).not.toBe(group!.branchName); + expect(taskA.branch).not.toBe(taskB.branch); + + // Enumeration by the real group id returns exactly the triaged members — the + // query completion gating and PR rollup depend on. + const members = await store.listTasksByBranchGroup(group!.id); + expect(members.map((m) => m.id).sort()).toEqual([linkedA, linkedB].sort()); + + // Before either lands, the group is not complete (canonical predicate). + expect(isBranchGroupComplete(members, group!)).toBe(false); + + // Simulate both members landing on the group branch (mergeConfirmed + matching + // target) — the canonical completion gate then reports complete. + for (const id of [linkedA, linkedB]) { + await store.updateTask(id, { + column: "done", + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group!.branchName, + }, + } as never); + } + // Read members fresh via getTask: listTasksByBranchGroup's slim-list path has + // a short startup memo (2.5s) that can return a pre-landing snapshot within + // the same fast test; enumeration identity is already asserted above, so here + // we evaluate the canonical completion gate against the authoritative rows. + const landedMembers = await Promise.all([linkedA, linkedB].map((id) => store.getTask(id))); + expect(isBranchGroupComplete(landedMembers.filter(Boolean) as never[], group!)).toBe(true); + }); + + it("returns [] for a group with no members (empty group is not an error, not complete)", async () => { + const group = store.createBranchGroup({ sourceType: "mission", sourceId: "M-empty", branchName: "fusion/groups/empty" }); + const members = await store.listTasksByBranchGroup(group.id); + expect(members).toEqual([]); + expect(isBranchGroupComplete(members, group)).toBe(false); + }); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/branch-group-single-pr-e2e.test.ts b/packages/engine/src/__tests__/reliability-interactions/branch-group-single-pr-e2e.test.ts new file mode 100644 index 0000000000..49b9c8eb39 --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/branch-group-single-pr-e2e.test.ts @@ -0,0 +1,393 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { type BranchGroup, type Task, type TaskStore } from "@fusion/core"; +import { + evaluateBranchGroupCompletion, + promoteBranchGroup, + type CreateGroupPrFn, + type CloseGroupPrFn, + type SyncGroupPrFn, +} from "../../group-merge-coordinator.js"; +import { aiMergeTask } from "../../merger.js"; +import { SelfHealingManager } from "../../self-healing.js"; +import { git, hasGit, makeReliabilityFixture } from "./_helpers.js"; + +/** + * U8 (R9): end-to-end single managed-PR flow for both entry points. + * + * Composition choice (stated honestly): + * - These engine-side tests prove the LOAD-BEARING half of the flow with REAL + * git in temp dirs and REAL store/merger/coordinator objects: members land on + * the shared group branch (never main / a sibling fusion/fn-* branch), the + * completion gate is satisfied, promotion creates EXACTLY ONE PR via the + * injected `createGroupPr` (the ONLY mocked seam — never real GitHub), the PR + * is synced as members land, re-promotion is idempotent, abandon closes it, + * and terminal states reconcile. + * - The two entry points (planning vs mission) differ here only by the group's + * `sourceType`/`branchName` shape — created the same way both entry points + * create it (`ensureBranchGroupForSource` → real BG- id stamped into + * `branchContext.groupId`). The entry-point WIRING (group + branchContext + * shape produced by planning routes / mission triage) is proven separately by + * the real-store mission entry-point test + * (`packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts`) and the + * route-level planning tests. A single planning→engine→GitHub test across the + * dashboard↔engine package boundary is impractical, so the flow is composed. + */ + +type StagedMember = { + taskId: string; + branch: string; + worktreePath: string; + fileName: string; +}; + +/** Stages a shared member exactly like the existing lifecycle harness does. */ +async function stageSharedMember( + store: TaskStore, + rootDir: string, + input: { taskId: string; groupId: string; source: "planning" | "mission"; fileName: string }, +): Promise { + const task = await store.getTask(input.taskId); + const branch = `fusion/${input.taskId.toLowerCase()}`; + const worktreePath = join(`${rootDir}-worktrees`, input.taskId.toLowerCase()); + + await store.updateTask(input.taskId, { + baseBranch: "", + branch, + column: "in-review", + branchContext: { groupId: input.groupId, source: input.source, assignmentMode: "shared" }, + 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 ${input.fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${input.fileName}.ts`)}'`); + git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${input.fileName}.ts`)}`); + git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${input.fileName}`)}`); + git(rootDir, "git checkout main"); + store.enqueueMergeQueue(input.taskId); + + return { taskId: input.taskId, branch, worktreePath, fileName: input.fileName }; +} + +/** + * A promote driver that resolves members from the real store but asserts the + * canonical completion gate agrees, mirroring the established lifecycle harness + * pattern (CASE 3/4). All git work runs against the real temp repo. + */ +function makePromoteDriver( + store: TaskStore, + rootDir: string, + group: BranchGroup, + memberIds: string[], +) { + return async (extra?: { + createGroupPr?: CreateGroupPrFn; + recordAudit?: (event: { mutationType: string; metadata?: Record }) => void; + settings?: Record; + }) => + promoteBranchGroup({ + store: { + getBranchGroup: (...args: any[]) => (store as any).getBranchGroup(...args), + getBranchGroupByBranchName: (...args: any[]) => (store as any).getBranchGroupByBranchName(...args), + updateBranchGroup: (...args: any[]) => (store as any).updateBranchGroup(...args), + listTasksByBranchGroup: async () => { + const members = (await Promise.all(memberIds.map((id) => store.getTask(id)))).filter(Boolean) as Task[]; + return members as any; + }, + } as any, + rootDir, + groupId: group.id, + settings: { + autoMerge: true, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request", + baseBranch: "main", + ...(extra?.settings ?? {}), + } as any, + ...(extra?.createGroupPr ? { createGroupPr: extra.createGroupPr } : {}), + ...(extra?.recordAudit + ? { + recordAudit: (event) => extra.recordAudit?.({ mutationType: event.mutationType, metadata: event.metadata }), + } + : {}), + }); +} + +describe("U8 end-to-end: single managed group PR (planning + mission)", () => { + it.skipIf(!hasGit)( + "PLANNING E2E: members land on shared branch → ONE PR created → synced on landing → terminal merged", + async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U8-PLAN-A", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const second = await store.createTask({ + id: "FN-U8-PLAN-B", + title: "Planning second member", + description: "second shared member", + column: "in-review", + baseBranch: "main", + branch: "fusion/fn-u8-plan-b", + prompt: "## File Scope\n- packages/engine/src/**/*.ts\n", + steps: [], + } as any); + + // Group created exactly as the planning entry point creates it. + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U8-PLAN", + branchName: "fusion/groups/fn-u8-plan", + autoMerge: true, + }); + await store.setTaskBranchGroup(task.id, group.id); + await store.setTaskBranchGroup(second.id, group.id); + + // Members enumerate by the REAL group id (U1). + const enumeratedBefore = await store.listTasksByBranchGroup(group.id); + expect(enumeratedBefore.map((m) => m.id).sort()).toEqual([task.id, second.id].sort()); + // No member uses the shared branch as its own working branch. + for (const member of enumeratedBefore) { + expect(member.branch).not.toBe(group.branchName); + } + + // The injected GitHub seam — the ONLY mock. Never hits real GitHub. + const syncCalls: Array<{ memberIds: string[] }> = []; + const syncGroupPr: SyncGroupPrFn = vi.fn(async ({ group: g, members }) => { + syncCalls.push({ memberIds: members.map((m: Task) => m.id) }); + return { prNumber: g.prNumber!, prUrl: g.prUrl!, prState: "open" as const }; + }); + + // First member lands on the group branch (U2/U3 routing). + await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "planning", fileName: "fnU8PlanA" }); + expect((await aiMergeTask(store, rootDir, task.id, { syncGroupPr })).merged).toBe(true); + const firstLanded = await store.getTask(task.id); + expect(firstLanded?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration"); + expect(firstLanded?.mergeDetails?.mergeTargetBranch).toBe(group.branchName); + await store.updateTask(task.id, { column: "done" } as any); + // No PR yet → no sync call yet. + expect(syncGroupPr).not.toHaveBeenCalled(); + + // Promotion of an incomplete group is gate-blocked, no PR created. + const createGroupPr: CreateGroupPrFn = vi.fn(async () => ({ + prNumber: 4242, + prUrl: "https://github.com/o/r/pull/4242", + prState: "open" as const, + })); + const promote = makePromoteDriver(store, rootDir, group, [task.id, second.id]); + const incomplete = await promote({ createGroupPr }); + expect(incomplete.reason).toBe("incomplete"); + expect(createGroupPr).not.toHaveBeenCalled(); + + // Second member lands. + await stageSharedMember(store, rootDir, { taskId: second.id, groupId: group.id, source: "planning", fileName: "fnU8PlanB" }); + expect((await aiMergeTask(store, rootDir, second.id, { syncGroupPr })).merged).toBe(true); + await store.updateTask(second.id, { column: "done" } as any); + + // Completion gate now satisfied (canonical predicate). + const members = (await store.listTasksByBranchGroup(group.id)) as Task[]; + expect(evaluateBranchGroupCompletion({ members, group }).complete).toBe(true); + + // Promote → EXACTLY ONE PR via createGroupPr; persisted open. + const promoted = await promote({ createGroupPr }); + expect(promoted.reason).toBe("promoted"); + expect(createGroupPr).toHaveBeenCalledTimes(1); + const afterPromote = store.getBranchGroup(group.id)!; + expect(afterPromote.prNumber).toBe(4242); + expect(afterPromote.prUrl).toBe("https://github.com/o/r/pull/4242"); + expect(afterPromote.prState).toBe("open"); + + // Work assembled on the group branch, NEVER on main. + expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fnU8PlanA.ts`)).toContain("fnU8PlanA"); + expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fnU8PlanB.ts`)).toContain("fnU8PlanB"); + + // Re-promote → idempotent: no second createGroupPr, same PR number. + const again = await promote({ createGroupPr }); + expect(again.reason).toBe("already-finalized"); + expect(createGroupPr).toHaveBeenCalledTimes(1); + expect(store.getBranchGroup(group.id)?.prNumber).toBe(4242); + + // A subsequent landing on the now-open PR fires a sync (keeps the single + // managed PR in sync — R6) and never opens a second PR. The exact x/N + // member-list pushed into the PR body is asserted deterministically by the + // dedicated U6 sync suite (branch-group-pr-sync.test.ts); here we prove the + // sync seam fires on landing while the PR is open and the PR number is + // stable (no duplicate). + const third = await store.createTask({ + id: "FN-U8-PLAN-C", + title: "Planning third member", + description: "third shared member", + column: "in-review", + baseBranch: "main", + branch: "fusion/fn-u8-plan-c", + prompt: "## File Scope\n- packages/engine/src/**/*.ts\n", + steps: [], + } as any); + await store.setTaskBranchGroup(third.id, group.id); + await stageSharedMember(store, rootDir, { taskId: third.id, groupId: group.id, source: "planning", fileName: "fnU8PlanC" }); + const syncCountBefore = syncCalls.length; + expect((await aiMergeTask(store, rootDir, third.id, { syncGroupPr })).merged).toBe(true); + // A new sync fired for the landing while the PR is open (no second PR). + expect(syncCalls.length).toBeGreaterThan(syncCountBefore); + expect(syncGroupPr).toHaveBeenCalled(); + expect(syncCalls.at(-1)?.memberIds).toEqual(expect.arrayContaining([task.id])); + expect(store.getBranchGroup(group.id)?.prNumber).toBe(4242); + expect(store.getBranchGroup(group.id)?.prState).toBe("open"); + // Third member also assembled on the group branch, never main. + expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fnU8PlanC.ts`)).toContain("fnU8PlanC"); + expect(() => git(rootDir, "git show main:packages/engine/src/fnU8PlanC.ts")).toThrow(); + + // Terminal: group PR merged out-of-band → prState reconciles to merged. + store.updateBranchGroup(group.id, { status: "finalized", prState: "merged" }); + expect(store.getBranchGroup(group.id)?.prState).toBe("merged"); + } finally { + await fixture.cleanup(); + } + }, + 60_000, + ); + + it.skipIf(!hasGit)( + "MISSION E2E: members enumerate by group id → land → ONE PR → abandon mid-flight closes PR (prState=closed)", + async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U8-MIS-A", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + const second = await store.createTask({ + id: "FN-U8-MIS-B", + title: "Mission second member", + description: "second shared member", + column: "in-review", + baseBranch: "main", + branch: "fusion/fn-u8-mis-b", + prompt: "## File Scope\n- packages/engine/src/**/*.ts\n", + steps: [], + } as any); + + // Group created exactly as mission triage creates it. + const group = store.createBranchGroup({ + sourceType: "mission", + sourceId: "M-U8-MIS", + branchName: "fusion/groups/fn-u8-mis", + autoMerge: true, + }); + await store.setTaskBranchGroup(task.id, group.id); + await store.setTaskBranchGroup(second.id, group.id); + + // Members enumerate by the real group id (U1). + const enumerated = await store.listTasksByBranchGroup(group.id); + expect(enumerated.map((m) => m.id).sort()).toEqual([task.id, second.id].sort()); + + // Both members land on the shared branch, never main. + await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "mission", fileName: "fnU8MisA" }); + await stageSharedMember(store, rootDir, { taskId: second.id, groupId: group.id, source: "mission", fileName: "fnU8MisB" }); + expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true); + expect((await aiMergeTask(store, rootDir, second.id)).merged).toBe(true); + await store.updateTask(task.id, { column: "done" } as any); + await store.updateTask(second.id, { column: "done" } as any); + expect(() => git(rootDir, "git show main:packages/engine/src/fnU8MisA.ts")).toThrow(); + expect(() => git(rootDir, "git show main:packages/engine/src/fnU8MisB.ts")).toThrow(); + + // Promote → ONE PR (mission entry point produces an identical flow). + const createGroupPr: CreateGroupPrFn = vi.fn(async () => ({ + prNumber: 808, + prUrl: "https://github.com/o/r/pull/808", + prState: "open" as const, + })); + const promote = makePromoteDriver(store, rootDir, group, [task.id, second.id]); + const promoted = await promote({ createGroupPr }); + expect(promoted.reason).toBe("promoted"); + expect(createGroupPr).toHaveBeenCalledTimes(1); + expect(store.getBranchGroup(group.id)?.prNumber).toBe(808); + expect(store.getBranchGroup(group.id)?.prState).toBe("open"); + + // Abandon mid-flight: close callback invoked, prState=closed (R7). + const closeGroupPr: CloseGroupPrFn = vi.fn(async ({ group: g }) => ({ + prNumber: g.prNumber!, + prUrl: g.prUrl!, + prState: "closed" as const, + })); + const current = store.getBranchGroup(group.id)!; + let prState: BranchGroup["prState"] = "closed"; + if (current.prNumber != null && current.prState === "open") { + const reconciled = await closeGroupPr({ group: current }); + prState = reconciled.prState; + } + store.updateBranchGroup(group.id, { status: "abandoned", prState }); + expect(closeGroupPr).toHaveBeenCalledTimes(1); + const abandoned = store.getBranchGroup(group.id)!; + expect(abandoned.status).toBe("abandoned"); + expect(abandoned.prState).toBe("closed"); + // Idempotent re-abandon attempt does not re-close (already closed). + expect(store.getBranchGroup(group.id)?.prState).toBe("closed"); + } finally { + await fixture.cleanup(); + } + }, + 60_000, + ); + + it.skipIf(!hasGit)( + "SAFETY: a self-healing finalize during the flow keeps the member on the group branch (no main, no sibling)", + async () => { + const fixture = await makeReliabilityFixture({ taskId: "FN-U8-SAFE-A", settings: { testMode: true, autoMerge: true } as any }); + try { + const { rootDir, store, task } = fixture; + // A sibling fusion/fn-* branch exists in the repo to prove routing never + // resolves a shared member against it. + const group = store.createBranchGroup({ + sourceType: "planning", + sourceId: "PS-U8-SAFE", + branchName: "fusion/groups/fn-u8-safe", + autoMerge: true, + }); + await stageSharedMember(store, rootDir, { taskId: task.id, groupId: group.id, source: "planning", fileName: "fnU8SafeA" }); + await store.setTaskBranchGroup(task.id, group.id); + + // Member lands on the group branch. + expect((await aiMergeTask(store, rootDir, task.id)).merged).toBe(true); + expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fnU8SafeA.ts`)).toContain("fnU8SafeA"); + expect(() => git(rootDir, "git show main:packages/engine/src/fnU8SafeA.ts")).toThrow(); + + // Corrupt the row as if a retry-exhausted failure stranded it in-review. + await store.updateTask(task.id, { + column: "in-review", + status: "failed", + error: "retry exhausted", + mergeRetries: 999, + mergeDetails: undefined, + } as any); + + // Self-healing finalize must re-anchor to the GROUP branch, not main/sibling. + const manager = new SelfHealingManager(store, { rootDir, getExecutingTaskIds: () => new Set() }); + await manager.recoverAlreadyMergedReviewTasks(); + const recovered = await store.getTask(task.id); + expect(recovered?.column).toBe("done"); + expect(recovered?.mergeDetails?.mergeConfirmed).toBe(true); + expect(recovered?.mergeDetails?.mergeTargetSource).toBe("branch-group-integration"); + expect(recovered?.mergeDetails?.mergeTargetBranch).toBe(group.branchName); + // Still not on main after recovery. + expect(() => git(rootDir, "git show main:packages/engine/src/fnU8SafeA.ts")).toThrow(); + + // After self-heal, the group still promotes to exactly ONE PR. + const createGroupPr: CreateGroupPrFn = vi.fn(async () => ({ + prNumber: 909, + prUrl: "https://github.com/o/r/pull/909", + prState: "open" as const, + })); + const promote = makePromoteDriver(store, rootDir, group, [task.id]); + const promoted = await promote({ createGroupPr }); + expect(promoted.reason).toBe("promoted"); + expect(createGroupPr).toHaveBeenCalledTimes(1); + expect(store.getBranchGroup(group.id)?.prNumber).toBe(909); + } finally { + await fixture.cleanup(); + } + }, + 60_000, + ); +}); From 928b14ae1b65369f4b763cb4f1b478bcdd0b8eeb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 11:00:16 -0700 Subject: [PATCH 10/21] refactor(FN-branch-group): remove dead cross-unit group-PR helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplicity pass over the 8-unit diff: delete caller-less closeGroupPrCallback (CLI), dead dashboard createGroupPullRequest/syncGroupPullRequest (+ their builders/types/tests — production uses the CLI callbacks), and merge the two CLI PR-body builders into one parameterized function. ~140 LOC of parallel-but-unused code from isolated unit implementation. --- .../cli/src/commands/__tests__/daemon.test.ts | 1 - .../cli/src/commands/__tests__/serve.test.ts | 1 - .../commands/__tests__/task-lifecycle.test.ts | 26 --- packages/cli/src/commands/task-lifecycle.ts | 77 ++++---- .../__tests__/github-close-group-pr.test.ts | 74 +++++++ .../__tests__/github-create-group-pr.test.ts | 131 ------------- .../__tests__/github-sync-group-pr.test.ts | 180 ------------------ packages/dashboard/src/github.ts | 123 +----------- packages/dashboard/src/index.ts | 2 +- 9 files changed, 110 insertions(+), 505 deletions(-) create mode 100644 packages/dashboard/src/__tests__/github-close-group-pr.test.ts delete mode 100644 packages/dashboard/src/__tests__/github-create-group-pr.test.ts delete mode 100644 packages/dashboard/src/__tests__/github-sync-group-pr.test.ts diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 3ed047db2f..1d5aeb5e2f 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -642,7 +642,6 @@ vi.mock("../task-lifecycle.js", () => ({ processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()), - closeGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index b2898816e3..a490c51324 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -696,7 +696,6 @@ vi.mock("../task-lifecycle.js", () => ({ processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()), - closeGroupPrCallback: vi.fn(() => vi.fn()), })); vi.mock("../project-context.js", () => ({ diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index b812ef9908..685a1449f2 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -37,7 +37,6 @@ import { processPullRequestMergeTask, getTaskBranchName, syncGroupPrCallback, - closeGroupPrCallback, } from "../task-lifecycle.js"; interface MockTask { @@ -1374,28 +1373,3 @@ describe("syncGroupPrCallback (U6)", () => { }); }); -describe("closeGroupPrCallback (U6)", () => { - const group = { id: "BG-1", prNumber: 42 }; - - it("closes an open PR and returns closed state", async () => { - const github = { - getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "open", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), - closePr: vi.fn(async () => ({ number: 42, url: "u", status: "closed", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), - }; - const close = closeGroupPrCallback(github as never); - const result = await close({ group: group as never }); - expect(result.prState).toBe("closed"); - expect(github.closePr).toHaveBeenCalledWith({ number: 42 }); - }); - - it("reconciles (does not close) when already merged out-of-band", async () => { - const github = { - getPrStatus: vi.fn(async () => ({ number: 42, url: "u", status: "merged", title: "T", headBranch: "h", baseBranch: "main", commentCount: 0 })), - closePr: vi.fn(), - }; - const close = closeGroupPrCallback(github as never); - const result = await close({ group: group as never }); - expect(result.prState).toBe("merged"); - expect(github.closePr).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 609d62df8d..4f9fccf619 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -20,7 +20,7 @@ import type { TaskStore } from "@fusion/core"; import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine"; -import type { CreateGroupPrFn, SyncGroupPrFn, CloseGroupPrFn, WorktreePool } from "@fusion/engine"; +import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine"; /** * Minimal interface for GitHub operations needed by the PR merge workflow. @@ -144,15 +144,37 @@ function buildGroupPullRequestTitle(group: Pick, members: Array & { branchName: string }>, + options?: { checklist?: boolean; landed?: (member: Pick & { branchName: string }) => boolean }, ): string { - const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"} — \`${member.branchName}\``); - return [ + const checklist = options?.checklist ?? false; + const isLanded = options?.landed ?? (() => false); + const lines = members.map((member) => { + const title = member.title || "(untitled)"; + if (checklist) { + return `- [${isLanded(member) ? "x" : " "}] ${member.id}: ${title} — \`${member.branchName}\``; + } + return `- ${member.id}: ${title} — \`${member.branchName}\``; + }); + const header = [ `Automated group PR for ${group.id}.`, `Source: ${group.sourceType}/${group.sourceId}`, `Integration branch: \`${group.branchName}\``, + ]; + if (checklist) { + const landedCount = members.filter((member) => isLanded(member)).length; + header.push(`Completion: ${landedCount}/${members.length} landed`); + } + return [ + ...header, "", "Included tasks:", ...(lines.length > 0 ? lines : ["- (none)"]), @@ -208,20 +230,16 @@ export function createGroupPrCallback( * every sync, so repeated pushes are idempotent and coalesce naturally. */ function buildGroupPrSyncBody(group: BranchGroup, members: Task[]): string { - const landedCount = members.filter((member) => isBranchGroupMemberLanded(member, group)).length; - const lines = members.map((member) => { - const landed = isBranchGroupMemberLanded(member, group); - return `- [${landed ? "x" : " "}] ${member.id}: ${member.title || "(untitled)"} — \`${getTaskBranchName(member.id)}\``; + const membersWithBranch = members.map((member) => ({ + id: member.id, + title: member.title, + branchName: getTaskBranchName(member.id), + })); + const landedById = new Map(members.map((member) => [member.id, isBranchGroupMemberLanded(member, group)])); + return buildGroupPullRequestBody(group, membersWithBranch, { + checklist: true, + landed: (member) => landedById.get(member.id) ?? false, }); - return [ - `Automated group PR for ${group.id}.`, - `Source: ${group.sourceType}/${group.sourceId}`, - `Integration branch: \`${group.branchName}\``, - `Completion: ${landedCount}/${members.length} landed`, - "", - "Included tasks:", - ...(lines.length > 0 ? lines : ["- (none)"]), - ].join("\n"); } /** @@ -259,33 +277,6 @@ export function syncGroupPrCallback( }; } -/** - * Build the `closeGroupPr` engine callback (KTD7, U6). Best-effort closes the - * single managed group PR during terminal reconciliation when a group is - * abandoned. If the PR is already closed/merged out-of-band, returns the - * reconciled state rather than erroring. - */ -export function closeGroupPrCallback( - github: Pick, -): CloseGroupPrFn { - return async ({ group }) => { - if (group.prNumber == null) { - throw new Error(`closeGroupPr: group ${group.id} has no persisted prNumber`); - } - const repo = getCurrentRepo(); - if (!repo) { - throw new Error("closeGroupPr: could not determine repository"); - } - const current = await github.getPrStatus(repo.owner, repo.repo, group.prNumber); - const currentState = toBranchGroupPrState(current); - if (currentState !== "open") { - return { prNumber: current.number, prUrl: current.url, prState: currentState }; - } - const closed = await github.closePr({ number: group.prNumber }); - return { prNumber: closed.number, prUrl: closed.url, prState: toBranchGroupPrState(closed) }; - }; -} - async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise { try { const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 }); diff --git a/packages/dashboard/src/__tests__/github-close-group-pr.test.ts b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts new file mode 100644 index 0000000000..5920708646 --- /dev/null +++ b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual("@fusion/core"); + return { + ...actual, + isGhAvailable: vi.fn(() => true), + isGhAuthenticated: vi.fn(() => true), + runGh: vi.fn(), + runGhAsync: vi.fn(), + runGhJson: vi.fn(), + runGhJsonAsync: vi.fn(), + getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))), + getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), + }; +}); + +import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core"; +import { GitHubClient, closeGroupPullRequest } from "../github.js"; + +const mockRunGh = vi.mocked(runGh); +const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); +const mockIsGhAvailable = vi.mocked(isGhAvailable); +const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); + +const group = { + id: "BG-1", + branchName: "fusion/groups/planning-x", + sourceType: "planning" as const, + sourceId: "PS-1", + prNumber: 42, +}; + +const ghPrViewOpen = { + number: 42, + url: "https://github.com/owner/repo/pull/42", + title: "T", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: group.branchName, +}; + +describe("closeGroupPullRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsGhAvailable.mockReturnValue(true); + mockIsGhAuthenticated.mockReturnValue(true); + }); + + it("closes an open PR via the gh-CLI backend", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any); + // First getPrStatus returns open, then close, then getPrStatus returns closed. + mockRunGhJsonAsync + .mockResolvedValueOnce(ghPrViewOpen as any) + .mockResolvedValueOnce({ ...ghPrViewOpen, state: "CLOSED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + + expect(result.prState).toBe("closed"); + const closeArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "close")?.[0]; + expect(closeArgs).toEqual(expect.arrayContaining(["pr", "close", "42"])); + }); + + it("reconciles (no close) when the PR is already merged out-of-band", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + expect(result.prState).toBe("merged"); + expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined(); + }); +}); diff --git a/packages/dashboard/src/__tests__/github-create-group-pr.test.ts b/packages/dashboard/src/__tests__/github-create-group-pr.test.ts deleted file mode 100644 index fdc1c0f11f..0000000000 --- a/packages/dashboard/src/__tests__/github-create-group-pr.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@fusion/core", async () => { - const actual = await vi.importActual("@fusion/core"); - return { - ...actual, - isGhAvailable: vi.fn(() => true), - isGhAuthenticated: vi.fn(() => true), - runGh: vi.fn(), - runGhAsync: vi.fn(), - runGhJson: vi.fn(), - runGhJsonAsync: vi.fn(), - getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))), - getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), - }; -}); - -import { runGh, runGhJsonAsync } from "@fusion/core"; -import { GitHubClient, createGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody } from "../github.js"; - -const mockRunGh = vi.mocked(runGh); -const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); - -const group = { - id: "BG-1", - branchName: "fusion/groups/planning-x", - sourceType: "planning" as const, - sourceId: "PS-1", -}; -const members = [ - { id: "FN-A", title: "Alpha" }, - { id: "FN-B", title: "Beta" }, -]; - -describe("createGroupPullRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("creates a PR via the gh-CLI backend and returns persisted shape", async () => { - // findPrForBranch (gh): no existing PR. - mockRunGhJsonAsync.mockResolvedValueOnce([] as any); - // createPr (gh): returns the PR url on stdout. - mockRunGh.mockReturnValue("https://github.com/owner/repo/pull/55\n"); - const client = new GitHubClient({ forceMode: "gh-cli" }); - - const result = await createGroupPullRequest(client, { - group, - members, - headBranch: group.branchName, - baseBranch: "main", - }); - - expect(result).toEqual({ - prNumber: 55, - prUrl: "https://github.com/owner/repo/pull/55", - prState: "open", - }); - const createArgs = mockRunGh.mock.calls[0][0]; - expect(createArgs).toEqual(expect.arrayContaining(["pr", "create", "--head", group.branchName, "--base", "main"])); - }); - - it("creates a PR via the REST API backend and returns persisted shape", async () => { - const client = new GitHubClient({ token: "ghp_token", forceMode: "token" }); - const fetchSpy = vi.spyOn(global, "fetch" as any) - // findPrForBranch (API): empty list. - .mockResolvedValueOnce({ ok: true, json: async () => [] } as any) - // createPr (API). - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - number: 77, - html_url: "https://github.com/owner/repo/pull/77", - title: "T", - state: "open", - head: { ref: group.branchName }, - base: { ref: "main" }, - comments: 0, - }), - } as any); - - const result = await createGroupPullRequest(client, { - group, - members, - headBranch: group.branchName, - baseBranch: "main", - }); - - expect(result).toEqual({ - prNumber: 77, - prUrl: "https://github.com/owner/repo/pull/77", - prState: "open", - }); - fetchSpy.mockRestore(); - }); - - it("reuses an existing open PR instead of creating a second one (idempotent)", async () => { - mockRunGhJsonAsync.mockResolvedValueOnce([ - { number: 12, url: "https://github.com/owner/repo/pull/12", title: "T", state: "OPEN", baseRefName: "main", headRefName: group.branchName, mergedAt: null }, - ] as any); - const client = new GitHubClient({ forceMode: "gh-cli" }); - - const result = await createGroupPullRequest(client, { - group, - members, - headBranch: group.branchName, - baseBranch: "main", - }); - - expect(result).toEqual({ - prNumber: 12, - prUrl: "https://github.com/owner/repo/pull/12", - prState: "open", - }); - // createPr must NOT have been called. - expect(mockRunGh).not.toHaveBeenCalled(); - }); -}); - -describe("group PR title/body builders", () => { - it("title includes the group id, source, and member count", () => { - expect(buildGroupPullRequestTitle(group, members)).toBe("BG-1: planning/PS-1 (2 tasks)"); - }); - - it("body lists every member task", () => { - const body = buildGroupPullRequestBody(group, members); - expect(body).toContain("Automated group PR for BG-1."); - expect(body).toContain("- FN-A: Alpha"); - expect(body).toContain("- FN-B: Beta"); - }); -}); diff --git a/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts b/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts deleted file mode 100644 index 2ec6403a7c..0000000000 --- a/packages/dashboard/src/__tests__/github-sync-group-pr.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@fusion/core", async () => { - const actual = await vi.importActual("@fusion/core"); - return { - ...actual, - isGhAvailable: vi.fn(() => true), - isGhAuthenticated: vi.fn(() => true), - runGh: vi.fn(), - runGhAsync: vi.fn(), - runGhJson: vi.fn(), - runGhJsonAsync: vi.fn(), - getGhErrorMessage: vi.fn((err) => (err instanceof Error ? err.message : String(err))), - getCurrentRepo: vi.fn(() => ({ owner: "owner", repo: "repo" })), - }; -}); - -import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core"; -import { GitHubClient, syncGroupPullRequest, closeGroupPullRequest } from "../github.js"; - -const mockRunGh = vi.mocked(runGh); -const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); -const mockIsGhAvailable = vi.mocked(isGhAvailable); -const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); - -const group = { - id: "BG-1", - branchName: "fusion/groups/planning-x", - sourceType: "planning" as const, - sourceId: "PS-1", - prNumber: 42, -}; -const members = [ - { id: "FN-A", title: "Alpha" }, - { id: "FN-B", title: "Beta" }, -]; - -const ghPrViewOpen = { - number: 42, - url: "https://github.com/owner/repo/pull/42", - title: "T", - state: "OPEN", - isDraft: false, - baseRefName: "main", - headRefName: group.branchName, -}; - -describe("syncGroupPullRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockIsGhAvailable.mockReturnValue(true); - mockIsGhAuthenticated.mockReturnValue(true); - }); - - it("edits the PR body via the gh-CLI backend when the PR is open", async () => { - // getPrStatus (gh view): open. updatePr→getPrStatus (gh view): open again. - mockRunGhJsonAsync.mockResolvedValue(ghPrViewOpen as any); - const client = new GitHubClient({ forceMode: undefined as never }); - // Force gh-auth path by relying on mocked isGhAvailable/isGhAuthenticated. - - const result = await syncGroupPullRequest(client, { group, members }); - - expect(result).toEqual({ - prNumber: 42, - prUrl: "https://github.com/owner/repo/pull/42", - prState: "open", - }); - // pr edit was invoked with the group's PR number and a body. - const editArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "edit")?.[0]; - expect(editArgs).toBeDefined(); - expect(editArgs).toEqual(expect.arrayContaining(["pr", "edit", "42", "--body"])); - }); - - it("edits the PR body via the REST API backend when the PR is open", async () => { - // Force the API path: gh CLI unavailable so getPrStatus/updatePr use REST. - mockIsGhAvailable.mockReturnValue(false); - mockIsGhAuthenticated.mockReturnValue(false); - const client = new GitHubClient({ token: "ghp_token", forceMode: "token" }); - const fetchSpy = vi.spyOn(global, "fetch" as any) - // getPrStatus (API): open. - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - number: 42, - html_url: "https://github.com/owner/repo/pull/42", - title: "T", - state: "open", - merged: false, - head: { ref: group.branchName }, - base: { ref: "main" }, - comments: 0, - updated_at: "2026-06-03T00:00:00Z", - }), - } as any) - // updatePr (API PATCH). - .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any) - // updatePr→getPrStatus (API): open. - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - number: 42, - html_url: "https://github.com/owner/repo/pull/42", - title: "T2", - state: "open", - merged: false, - head: { ref: group.branchName }, - base: { ref: "main" }, - comments: 0, - updated_at: "2026-06-03T00:00:01Z", - }), - } as any); - - const result = await syncGroupPullRequest(client, { group, members }); - expect(result.prState).toBe("open"); - expect(result.prNumber).toBe(42); - // PATCH was sent with a body containing the completion checklist. - const patchCall = fetchSpy.mock.calls.find((c) => (c[1] as any)?.method === "PATCH"); - expect(patchCall).toBeDefined(); - fetchSpy.mockRestore(); - }); - - it("reconciles (no edit) when the PR is closed out-of-band on GitHub", async () => { - mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any); - const client = new GitHubClient({ forceMode: undefined as never }); - - const result = await syncGroupPullRequest(client, { group, members }); - - expect(result.prState).toBe("closed"); - // pr edit must NOT be invoked when the PR is already terminal. - expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined(); - }); - - it("reconciles to merged (no edit) when the PR is merged out-of-band", async () => { - mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); - const client = new GitHubClient({ forceMode: undefined as never }); - - const result = await syncGroupPullRequest(client, { group, members }); - expect(result.prState).toBe("merged"); - expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "edit")).toBeUndefined(); - }); - - it("throws when the group has no persisted prNumber", async () => { - const client = new GitHubClient({ forceMode: undefined as never }); - await expect( - syncGroupPullRequest(client, { group: { ...group, prNumber: null as never }, members }), - ).rejects.toThrow(/no persisted prNumber/); - }); -}); - -describe("closeGroupPullRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockIsGhAvailable.mockReturnValue(true); - mockIsGhAuthenticated.mockReturnValue(true); - }); - - it("closes an open PR via the gh-CLI backend", async () => { - mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "CLOSED" } as any); - // First getPrStatus returns open, then close, then getPrStatus returns closed. - mockRunGhJsonAsync - .mockResolvedValueOnce(ghPrViewOpen as any) - .mockResolvedValueOnce({ ...ghPrViewOpen, state: "CLOSED" } as any); - const client = new GitHubClient({ forceMode: undefined as never }); - - const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); - - expect(result.prState).toBe("closed"); - const closeArgs = mockRunGh.mock.calls.find((c) => c[0]?.[0] === "pr" && c[0]?.[1] === "close")?.[0]; - expect(closeArgs).toEqual(expect.arrayContaining(["pr", "close", "42"])); - }); - - it("reconciles (no close) when the PR is already merged out-of-band", async () => { - mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); - const client = new GitHubClient({ forceMode: undefined as never }); - - const result = await closeGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); - expect(result.prState).toBe("merged"); - expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined(); - }); -}); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index 451926b3a4..f18af7865d 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import type { BranchGroup, BranchGroupPrState, DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, Task, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core"; +import type { BranchGroup, BranchGroupPrState, DirectMergeCommitStrategy, IssueInfo, PrConflictDiagnostics, PrConflictState, PrInfo, TaskReviewData, TaskReviewItem, TaskReviewSummary } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, @@ -3841,133 +3841,12 @@ function prInfoToBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState { return "open"; } -/** Build the title for a single managed group PR. */ -export function buildGroupPullRequestTitle( - group: Pick, - members: Pick[], -): string { - return `${group.id}: ${group.sourceType}/${group.sourceId} (${members.length} tasks)`; -} - -/** Build the body for a single managed group PR (member checklist + completion). */ -export function buildGroupPullRequestBody( - group: Pick, - members: Pick[], -): string { - const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"}`); - return [ - `Automated group PR for ${group.id}.`, - `Source: ${group.sourceType}/${group.sourceId}`, - `Integration branch: \`${group.branchName}\``, - "", - "Included tasks:", - ...(lines.length > 0 ? lines : ["- (none)"]), - ].join("\n"); -} - -export interface CreateGroupPrInput { - group: Pick; - members: Pick[]; - /** Head branch — the group integration branch. */ - headBranch: string; - /** Base branch — the project default / integration target. */ - baseBranch: string; -} - export interface CreateGroupPrResult { prNumber: number; prUrl: string; prState: BranchGroupPrState; } -/** - * Create (or reuse) the single managed GitHub PR for a branch group. - * - * Idempotency: if an existing PR is already open for the group head branch on - * GitHub, it is reused rather than opening a second one. This is the GitHub-side - * idempotency guard; the coordinator additionally checks the persisted - * `prNumber` before ever calling this helper. - * - * Backend parity: dispatches through `GitHubClient.findPrForBranch` / - * `GitHubClient.createPr`, which transparently use the `gh` CLI when available - * and fall back to the REST API, so both paths produce the same result shape. - */ -export async function createGroupPullRequest( - github: Pick, - input: CreateGroupPrInput, -): Promise { - const existing = await github.findPrForBranch({ head: input.headBranch, state: "all" }); - if (existing) { - return { - prNumber: existing.number, - prUrl: existing.url, - prState: prInfoToBranchGroupPrState(existing), - }; - } - - const created = await github.createPr({ - title: buildGroupPullRequestTitle(input.group, input.members), - body: buildGroupPullRequestBody(input.group, input.members), - head: input.headBranch, - base: input.baseBranch, - }); - return { - prNumber: created.number, - prUrl: created.url, - prState: prInfoToBranchGroupPrState(created), - }; -} - -export interface SyncGroupPrInput { - group: Pick; - members: Pick[]; -} - -/** - * Push an updated title/body onto the single managed group PR (U6, R6). - * - * The body always reflects the *full* current member state (checklist + - * completion summary), so repeated calls are idempotent body rewrites — each - * landing pushes the latest state and naturally coalesces with the previous one; - * no queue is needed (KTD4: idempotency anchors on the persisted `prNumber`). - * - * Out-of-band reconciliation: if the persisted PR is no longer open on GitHub - * (closed/merged out-of-band), this does NOT re-open or edit it — it returns the - * reconciled `prState` so the caller can persist it instead of erroring. - * - * Backend parity: dispatches through `GitHubClient.getPrStatus` / `updatePr`, - * which use the `gh` CLI when available and fall back to the REST API. - */ -export async function syncGroupPullRequest( - github: Pick, - input: SyncGroupPrInput, -): Promise { - const prNumber = input.group.prNumber; - if (prNumber == null) { - throw new Error(`syncGroupPullRequest: group ${input.group.id} has no persisted prNumber`); - } - - const { owner, repo } = getCurrentRepoOrThrow(); - const current = await github.getPrStatus(owner, repo, prNumber); - const currentState = prInfoToBranchGroupPrState(current); - - // Out-of-band terminal state: do not re-open or edit a closed/merged PR. - if (currentState !== "open") { - return { prNumber: current.number, prUrl: current.url, prState: currentState }; - } - - const updated = await github.updatePr({ - number: prNumber, - title: buildGroupPullRequestTitle(input.group, input.members), - body: buildGroupPullRequestBody(input.group, input.members), - }); - return { - prNumber: updated.number, - prUrl: updated.url, - prState: prInfoToBranchGroupPrState(updated), - }; -} - /** * Close the single managed group PR (U6, R7) — best-effort terminal * reconciliation when a branch group is abandoned. If the PR is already diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 14754a1292..91098aad06 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -11,7 +11,7 @@ export { type RuntimeLogSink, } from "./runtime-logger.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; -export { GitHubClient, isPrMergeReady, createGroupPullRequest, syncGroupPullRequest, closeGroupPullRequest, buildGroupPullRequestTitle, buildGroupPullRequestBody, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrInput, type CreateGroupPrResult, type SyncGroupPrInput } from "./github.js"; +export { GitHubClient, isPrMergeReady, closeGroupPullRequest, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { From bde7bdf766bd903f5f07bacb03051bdc83edb24a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 11:23:14 -0700 Subject: [PATCH 11/21] =?UTF-8?q?fix(FN-branch-group):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20fast-path=20mergeTargetSource=20+=20open-PR=20reuse?= =?UTF-8?q?=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (Tier 2) found two P1s: (1) the early no-op fast-path persisted mergeConfirmed/mergeTargetBranch without mergeTargetSource, so a shared-group member landing via it could never satisfy the strict completion predicate — promotion permanently blocked; thread mergeTarget.source through like the standard landing sites. (2) createGroupPrCallback's findPrForBranch used state:'all' and could reuse a closed/merged PR from a prior group, persisting a terminal prState onto a fresh promotion; create path now matches open PRs only. --- .../commands/__tests__/task-lifecycle.test.ts | 74 ++++++++++++++++ packages/cli/src/commands/task-lifecycle.ts | 2 +- .../merger-finalize-unproven.real-git.test.ts | 86 ++++++++++++++++++- packages/engine/src/merger.ts | 6 +- 4 files changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 685a1449f2..ec2f4643cb 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -34,6 +34,7 @@ vi.mock("@fusion/core", async () => { import { activeSessionRegistry } from "@fusion/engine"; import { cleanupMergedTaskArtifacts, + createGroupPrCallback, processPullRequestMergeTask, getTaskBranchName, syncGroupPrCallback, @@ -1373,3 +1374,76 @@ describe("syncGroupPrCallback (U6)", () => { }); }); +describe("createGroupPrCallback", () => { + beforeEach(() => { + execMock.mockReset(); + execMock.mockImplementation(() => ""); + }); + + const group = { + id: "BG-1", + sourceType: "planning" as const, + sourceId: "P-1", + branchName: "fusion/groups/p-1", + autoMerge: false, + prState: "none" as const, + status: "open" as const, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const members = [{ id: "FN-A", title: "Alpha", description: "a", column: "in-review" } as never]; + + it("queries only OPEN PRs for the head branch (does not reuse terminal PRs)", async () => { + const github = { + findPrForBranch: vi.fn(async () => null), + createPr: vi.fn(async () => ({ + number: 99, + url: "https://github.com/owner/repo/pull/99", + status: "open" as const, + })), + }; + + const callback = createGroupPrCallback(github as never); + await callback({ + cwd: "/repo", + group: group as never, + members, + headBranch: group.branchName, + baseBranch: "main", + }); + + expect(github.findPrForBranch).toHaveBeenCalledWith({ head: group.branchName, state: "open" }); + }); + + it("does not reuse a closed PR from a prior group — creates a fresh one", async () => { + // With state:"open", findPrForBranch returns null for a head whose only PR + // is closed/merged, so the create path runs instead of resurrecting the + // terminal PR (which would poison the newly promoted group's prState). + const github = { + findPrForBranch: vi.fn(async () => null), + createPr: vi.fn(async () => ({ + number: 123, + url: "https://github.com/owner/repo/pull/123", + status: "open" as const, + })), + }; + + const callback = createGroupPrCallback(github as never); + const result = await callback({ + cwd: "/repo", + group: group as never, + members, + headBranch: group.branchName, + baseBranch: "main", + }); + + expect(github.findPrForBranch).toHaveBeenCalledWith({ head: group.branchName, state: "open" }); + expect(github.createPr).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + prNumber: 123, + prUrl: "https://github.com/owner/repo/pull/123", + prState: "open", + }); + }); +}); + diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 4f9fccf619..3e01ebfc69 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -203,7 +203,7 @@ export function createGroupPrCallback( github: Pick, ): CreateGroupPrFn { return async ({ cwd, group, members, headBranch, baseBranch }) => { - const existing = await github.findPrForBranch({ head: headBranch, state: "all" }); + const existing = await github.findPrForBranch({ head: headBranch, state: "open" }); if (existing) { return { prNumber: existing.number, prUrl: existing.url, prState: toBranchGroupPrState(existing) }; } diff --git a/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts b/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts index 6924df6ffc..2589cbc5fd 100644 --- a/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts +++ b/packages/engine/src/__tests__/merger-finalize-unproven.real-git.test.ts @@ -3,8 +3,8 @@ import { execSync, spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { Settings, Task, TaskStore } from "@fusion/core"; -import { DEFAULT_SETTINGS } from "@fusion/core"; +import type { BranchGroup, Settings, Task, TaskStore } from "@fusion/core"; +import { DEFAULT_SETTINGS, isBranchGroupMemberLanded } from "@fusion/core"; vi.mock("../pi.js", () => ({ createFnAgent: vi.fn(async () => ({ session: { prompt: vi.fn(async () => undefined), dispose: vi.fn() } })), @@ -28,7 +28,11 @@ function git(repo: string, command: string): string { return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); } -function createStore(task: Task, settings: Partial = {}): TaskStore { +function createStore( + task: Task, + settings: Partial = {}, + branchGroup?: BranchGroup, +): TaskStore { let currentTask = { ...task }; const mergedSettings: Settings = { ...DEFAULT_SETTINGS, @@ -69,6 +73,9 @@ function createStore(task: Task, settings: Partial = {}): TaskStore { getVerificationCacheHit: vi.fn(() => null), recordVerificationCachePass: vi.fn(() => undefined), upsertTaskCommitAssociation: vi.fn(async () => undefined), + getBranchGroup: vi.fn(() => branchGroup ?? null), + recordBranchGroupMemberLanded: vi.fn(async () => undefined), + recordRunAuditEvent: vi.fn(async () => undefined), } as unknown as TaskStore; } @@ -251,4 +258,77 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", () expect((store.moveTask as ReturnType).mock.calls.some(([, column]) => column === "done")).toBe(false); expect((store.moveTask as ReturnType).mock.calls.some(([, column]) => column === "todo")).toBe(true); }, 20_000); + + // FN-5345/FN-5377 + branch-group completion regression: a shared-group member + // landing via the early empty-own-diff fast-path MUST stamp + // mergeTargetSource === "branch-group-integration" on the persisted + // mergeDetails (mirroring the standard landing paths), otherwise + // isBranchGroupMemberLanded can never match and group promotion is + // permanently blocked. + it("stamps mergeTargetSource on early no-op fast-path so a shared-group member counts as landed", async () => { + const repo = mkdtempSync(join(tmpdir(), "fusion-merger-group-noop-")); + repos.push(repo); + git(repo, "git init -b main"); + git(repo, 'git config user.email "test@example.com"'); + git(repo, 'git config user.name "Test User"'); + git(repo, "git commit --allow-empty -m 'init'"); + const baseSha = git(repo, "git rev-parse HEAD"); + + // Shared group integration branch (NOT a fusion/fn-* sibling) that the + // member's own commits net to zero against → early fast-path territory. + const groupBranch = "group/shared-integration"; + git(repo, `git checkout -b ${groupBranch}`); + git(repo, "git checkout main"); + + const memberBranch = "fusion/fn-grp-member"; + git(repo, `git checkout -b ${memberBranch} ${groupBranch}`); + // 1 own commit with zero net tree change vs the group merge-base. + git(repo, "git commit --allow-empty -m 'test(FN-GRP): handoff'"); + expect(git(repo, "git rev-parse HEAD")).not.toBe(baseSha); // aheadCount >= 1 + git(repo, "git checkout main"); + + const group: BranchGroup = { + id: "grp-1", + sourceType: "planning" as BranchGroup["sourceType"], + sourceId: "src-1", + branchName: groupBranch, + autoMerge: true, + prState: "none" as BranchGroup["prState"], + status: "open" as BranchGroup["status"], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const task = { + id: "FN-GRP", + title: "FN-GRP", + description: "FN-GRP", + column: "in-review", + branch: memberBranch, + branchContext: { assignmentMode: "shared", groupId: group.id } as Task["branchContext"], + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + prompt: "# FN-GRP", + } as unknown as Task; + + const store = createStore(task, {}, group); + const result = await aiMergeTask(store, repo, "FN-GRP"); + + // Early no-op fast-path fired and finalized as a branch-group landing. + expect(result.noOp).toBe(true); + expect(result.merged).toBe(true); + expect(result.mergeTargetBranch).toBe(groupBranch); + expect(result.mergeTargetSource).toBe("branch-group-integration"); + + // Persisted mergeDetails carry the source so the completion predicate matches. + const persisted = await store.getTask("FN-GRP"); + expect(persisted.mergeDetails?.mergeConfirmed).toBe(true); + expect(persisted.mergeDetails?.mergeTargetBranch).toBe(groupBranch); + expect(persisted.mergeDetails?.mergeTargetSource).toBe("branch-group-integration"); + expect(isBranchGroupMemberLanded(persisted, group)).toBe(true); + }, 20_000); }); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index c4af61163e..c5e9613d24 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -7223,9 +7223,10 @@ async function tryEarlyEmptyOwnDiffFinalize(input: { log: { warn: (m: string) => void; log: (m: string) => void }; projectRootDir: string; mergeTargetBranch: string; + mergeTargetSource: MergeDetails["mergeTargetSource"]; completeTask: (result: MergeResult) => Promise; }): Promise { - const { task, taskId, store, audit, log, projectRootDir, mergeTargetBranch } = input; + const { task, taskId, store, audit, log, projectRootDir, mergeTargetBranch, mergeTargetSource } = input; const branch = resolveTaskWorkingBranch(task); // 1. Branch exists? @@ -7287,6 +7288,7 @@ async function tryEarlyEmptyOwnDiffFinalize(input: { mergedAt, prNumber: task.prInfo?.number, mergeTargetBranch, + mergeTargetSource, }; await store.updateTask(taskId, { mergeDetails, modifiedFiles: [] }); await store.logEntry( @@ -7426,6 +7428,7 @@ async function tryEarlyEmptyOwnDiffFinalize(input: { noOpReason, mergedAt, mergeTargetBranch, + mergeTargetSource, }; await input.completeTask(result); return result; @@ -7687,6 +7690,7 @@ export async function aiMergeTask( log: mergerLog, projectRootDir, mergeTargetBranch: mergeTarget.branch, + mergeTargetSource: mergeTarget.source, completeTask: (result) => completeTask(store, taskId, result), }); if (earlyResult) return earlyResult; From d9272abd0f9f2fa27800f006b47144f123461fe3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 12:25:11 -0700 Subject: [PATCH 12/21] fix(FN-branch-group): promotion lock, PR repair, audit on failure, typed sync block Review residuals #3/#4/#6/#10: per-group in-process promotion lock (concurrent route+auto promotion could double-create PRs), finalized-but-PR-less groups can be repaired by re-promotion without re-merging, auto-promotion failures emit merge:branch-group-promotion-failed instead of silent swallow, exported reconcileBranchGroupPr for out-of-band merged reconciliation, and the merger sync block drops its (store as any) casts (TaskStore already carries the methods). --- .../__tests__/group-merge-coordinator.test.ts | 305 ++++++++++++++++++ .../src/__tests__/project-engine.test.ts | 67 ++++ .../engine/src/group-merge-coordinator.ts | 224 ++++++++++--- packages/engine/src/index.ts | 3 + packages/engine/src/merger.ts | 25 +- packages/engine/src/project-engine.ts | 26 +- 6 files changed, 584 insertions(+), 66 deletions(-) diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index 8889e630db..35bd67d7d2 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -9,6 +9,7 @@ import { evaluateBranchGroupCompletion, evaluateBranchGroupPromotion, promoteBranchGroup, + reconcileBranchGroupPr, resolveBranchGroupMergeRouting, } from "../group-merge-coordinator.js"; import { ProjectEngine } from "../project-engine.js"; @@ -639,6 +640,310 @@ describe("ProjectEngine.promoteBranchGroup (U4 bridge method)", () => { }); }); +describe("promoteBranchGroup concurrency lock (Fix #10)", () => { + function makeGroup(overrides?: Partial): any { + return { + id: "BG-LOCK-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/planning-x", + autoMerge: true, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + const landedMember = (id: string, branchName: string) => ({ + id, + title: `${id} title`, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + + function makePrRepo(): string { + 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 }); + return rootDir; + } + + const prSettings = { + autoMerge: true, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request" as const, + baseBranch: "main", + }; + + it("serializes two concurrent promotions: createGroupPr runs exactly once, one PR persisted", async () => { + const rootDir = makePrRepo(); + let group = makeGroup(); + let createCalls = 0; + const store = { + getBranchGroup: () => group, + getBranchGroupByBranchName: () => null, + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], + updateBranchGroup: (_id: string, patch: Record) => { + group = { ...group, ...patch }; + return group; + }, + } as any; + + // The injected creator yields (await a macrotask) so that, WITHOUT the lock, + // a second concurrent call would slip past the prState/status gate (which is + // read at the top, before the first call has persisted "open") and create a + // second PR. With the per-group lock the second call only begins after the + // first persisted its result and short-circuits as already-finalized. + const createGroupPr = async () => { + createCalls += 1; + const n = createCalls; + await new Promise((resolve) => setTimeout(resolve, 25)); + return { prNumber: 40 + n, prUrl: `https://github.com/x/y/pull/${40 + n}`, prState: "open" as const }; + }; + + const [a, b] = await Promise.all([ + promoteBranchGroup({ rootDir, groupId: group.id, settings: prSettings, store, createGroupPr }), + promoteBranchGroup({ rootDir, groupId: group.id, settings: prSettings, store, createGroupPr }), + ]); + + expect(createCalls).toBe(1); + expect(group.prNumber).toBe(41); + expect(group.prState).toBe("open"); + expect(group.status).toBe("finalized"); + + // Exactly one call reports a fresh promotion; the other sees already-finalized. + const reasons = [a.reason, b.reason].sort(); + expect(reasons).toEqual(["already-finalized", "promoted"]); + const promoted = [a, b].filter((r) => r.reason === "promoted"); + expect(promoted).toHaveLength(1); + expect(promoted[0].prNumber).toBe(41); + }); +}); + +describe("promoteBranchGroup finalized-but-PR-less repair (Fix #4 part 2)", () => { + function makeGroup(overrides?: Partial): any { + return { + id: "BG-REPAIR-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/planning-x", + autoMerge: true, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + const landedMember = (id: string, branchName: string) => ({ + id, + title: `${id} title`, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + + function makePrRepo(): string { + 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 }); + return rootDir; + } + + const prSettings = { + autoMerge: true, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request" as const, + baseBranch: "main", + }; + + it("re-promotion creates the PR for a finalized PR-less group WITHOUT re-running the integration merge", async () => { + const rootDir = makePrRepo(); + // Simulate a crash AFTER the integration merge + finalize but BEFORE the PR + // was created: group is finalized, prState none, prNumber null. + let group = makeGroup({ status: "finalized", prState: "none", prNumber: null, prUrl: null }); + let createCalls = 0; + let mergeCalls = 0; + const store = { + getBranchGroup: () => group, + getBranchGroupByBranchName: () => null, + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], + updateBranchGroup: (_id: string, patch: Record) => { + group = { ...group, ...patch }; + return group; + }, + } as any; + + // Detect whether the integration merge ran by recording the merge commit on + // main before re-promotion. The repair path must NOT advance main again. + const mainBefore = execSync("git rev-parse main", { cwd: rootDir, encoding: "utf8" }).trim(); + void mergeCalls; + + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store, + createGroupPr: async ({ members }) => { + createCalls += 1; + expect(members.map((m: any) => m.id)).toEqual(["FN-A"]); + return { prNumber: 77, prUrl: "https://github.com/x/y/pull/77", prState: "open" as const }; + }, + }); + + expect(result.reason).toBe("promoted"); + expect(createCalls).toBe(1); + expect(group.prNumber).toBe(77); + expect(group.prState).toBe("open"); + expect(group.status).toBe("finalized"); + + // The merge step was skipped: main is unchanged from before the repair. + const mainAfter = execSync("git rev-parse main", { cwd: rootDir, encoding: "utf8" }).trim(); + expect(mainAfter).toBe(mainBefore); + }); + + it("a finalized group that already has a prNumber is still short-circuited (no repair, no PR re-create)", async () => { + const rootDir = makePrRepo(); + let group = makeGroup({ status: "finalized", prState: "open", prNumber: 5, prUrl: "https://github.com/x/y/pull/5" }); + let createCalls = 0; + const store = { + getBranchGroup: () => group, + getBranchGroupByBranchName: () => null, + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], + updateBranchGroup: () => { + throw new Error("should not update an already-PR'd finalized group"); + }, + } as any; + + const result = await promoteBranchGroup({ + rootDir, + groupId: group.id, + settings: prSettings, + store, + createGroupPr: async () => { + createCalls += 1; + return { prNumber: 1, prUrl: "x", prState: "open" as const }; + }, + }); + + expect(result.reason).toBe("already-finalized"); + expect(createCalls).toBe(0); + }); +}); + +describe("reconcileBranchGroupPr (Fix #3 engine primitive)", () => { + function makeGroup(overrides?: Partial): any { + return { + id: "BG-RECON-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/planning-x", + autoMerge: true, + prState: "open", + prNumber: 12, + prUrl: "https://github.com/x/y/pull/12", + status: "finalized", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + it("persists merged state when syncGroupPr reports the PR merged", async () => { + let group = makeGroup(); + const updates: Array> = []; + const store = { + listTasksByBranchGroup: async () => [{ id: "FN-A" }], + updateBranchGroup: (_id: string, patch: Record) => { + updates.push(patch); + group = { ...group, ...patch }; + return group; + }, + } as any; + + const result = await reconcileBranchGroupPr({ + store, + group, + syncGroupPr: async () => ({ + prNumber: 12, + prUrl: "https://github.com/x/y/pull/12", + prState: "merged", + }), + }); + + expect(result.reconciled).toBe(true); + expect(result.prState).toBe("merged"); + expect(group.prState).toBe("merged"); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ prState: "merged", prNumber: 12 }); + }); + + it("is a no-op (no persist) when the PR is still open", async () => { + const group = makeGroup(); + let updateCalls = 0; + const store = { + listTasksByBranchGroup: async () => [{ id: "FN-A" }], + updateBranchGroup: () => { + updateCalls += 1; + return group; + }, + } as any; + + const result = await reconcileBranchGroupPr({ + store, + group, + syncGroupPr: async () => ({ + prNumber: 12, + prUrl: "https://github.com/x/y/pull/12", + prState: "open", + }), + }); + + expect(result.reconciled).toBe(false); + expect(result.prState).toBe("open"); + expect(updateCalls).toBe(0); + }); + + it("is a no-op when the group has no persisted prNumber", async () => { + const group = makeGroup({ prNumber: null, prState: "none" }); + let syncCalls = 0; + const store = { + listTasksByBranchGroup: async () => [{ id: "FN-A" }], + updateBranchGroup: () => { + throw new Error("should not update"); + }, + } as any; + + const result = await reconcileBranchGroupPr({ + store, + group, + syncGroupPr: async () => { + syncCalls += 1; + return { prNumber: 0, prUrl: "", prState: "open" as const }; + }, + }); + + expect(result.reconciled).toBe(false); + expect(syncCalls).toBe(0); + }); +}); + describe("resolveBranchGroupMergeRouting", () => { it("returns null for non-shared tasks", async () => { const routing = await resolveBranchGroupMergeRouting({ diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 4d9774959a..e7b7a98de1 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1782,6 +1782,73 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => { await engine.stop(); }); + it("records an audit event (not silent) when auto-promotion of a branch-group member fails (Fix #4)", async () => { + const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); + // The dequeued + merged task is a shared branch-group member, so the engine + // attempts branch-group promotion after the PR merges. + const mergedMember = { + id: "FN-bgfail", + column: "done", + paused: false, + mergeRetries: 0, + status: null, + branch: "fusion/fn-bgfail", + branchContext: { groupId: "BG-FAIL-1", source: "planning", assignmentMode: "shared" }, + mergeDetails: { mergeConfirmed: true, mergedAt: "2026-06-03T00:00:00.000Z", mergeTargetBranch: "fusion/groups/x" }, + }; + mockStore.store.getTask + .mockResolvedValueOnce({ + id: "FN-bgfail", + column: "in-review", + paused: false, + mergeRetries: 0, + status: null, + branch: "fusion/fn-bgfail", + branchContext: { groupId: "BG-FAIL-1", source: "planning", assignmentMode: "shared" }, + }) + .mockResolvedValue(mergedMember); + + const recordRunAuditEvent = vi.fn(async () => undefined); + // Drive promoteBranchGroup into throwing: getBranchGroup returns a complete- + // looking group, but listTasksByBranchGroup rejects, so promotion throws and + // the engine's catch must record the failure audit instead of swallowing it. + (mockStore.store as any).getBranchGroup = vi.fn(() => ({ + id: "BG-FAIL-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/x", + autoMerge: true, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + })); + (mockStore.store as any).getBranchGroupByBranchName = vi.fn(() => null); + (mockStore.store as any).listTasksByBranchGroup = vi.fn(async () => { + throw new Error("boom: store unavailable"); + }); + (mockStore.store as any).updateBranchGroup = vi.fn(); + (mockStore.store as any).recordRunAuditEvent = recordRunAuditEvent; + mocks.currentStore = mockStore.store; + + const processPullRequestMerge = vi.fn(async () => "merged" as const); + const engine = createEngine({ processPullRequestMerge, getMergeStrategy: () => "pull-request" }); + await engine.start(); + engine.enqueueMerge("FN-bgfail"); + + await vi.waitFor(() => { + expect(recordRunAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + mutationType: "merge:branch-group-promotion-failed", + target: "BG-FAIL-1", + metadata: expect.objectContaining({ groupId: "BG-FAIL-1", taskId: "FN-bgfail" }), + }), + ); + }); + + await engine.stop(); + }); + it("logs and skips paused tasks dequeued for auto-merge", async () => { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); mockStore.store.getTask.mockResolvedValueOnce({ diff --git a/packages/engine/src/group-merge-coordinator.ts b/packages/engine/src/group-merge-coordinator.ts index f0a9452e85..a77adbda3d 100644 --- a/packages/engine/src/group-merge-coordinator.ts +++ b/packages/engine/src/group-merge-coordinator.ts @@ -173,11 +173,30 @@ async function ensureGroupBranchExists(rootDir: string, branchName: string, star } } +/** + * Per-`groupId` in-process promotion lock (Fix #10). `promoteBranchGroup` can be + * invoked concurrently — e.g. the dashboard route bridge and the auto-promotion + * hook firing on the final member landing — and its body runs a long await chain + * (git checkout/merge on the shared working tree + PR creation) with no atomicity. + * Interleaving two runs can double-create the managed PR and corrupt HEAD. + * + * We serialize per group by chaining each call onto a promise stored in this map; + * each call only begins after the previous one for the same group settles, and it + * RE-READS the group state inside the lock (the inner function's first action is + * `store.getBranchGroup`), so a second waiter observes the first's persisted + * `prState`/`status` and short-circuits instead of re-doing the work. + * + * In-process only: a cross-node lease (FN-4820) is explicitly deferred. + */ +const promotionLocks = new Map>(); + /** * The only entrypoint allowed to perform shared-branch-group → default-branch promotion. * Promotion is intentionally idempotent and must never run inline in aiMergeTask. + * + * Serialized per `groupId` via {@link promotionLocks}; see that comment for why. */ -export async function promoteBranchGroup(input: { +export interface PromoteBranchGroupInput { store: Pick; rootDir: string; groupId: string; @@ -194,7 +213,32 @@ export async function promoteBranchGroup(input: { target: string; metadata?: Record; }) => Promise | void; -}): Promise { +} + +export async function promoteBranchGroup(input: PromoteBranchGroupInput): Promise { + // Chain onto any in-flight promotion for this group so two concurrent callers + // (route bridge + auto-promotion on final landing) never run the merge/PR-create + // sequence at the same time. The continuation re-reads group state inside the + // lock, so the second caller observes the first's persisted result. + const prior = promotionLocks.get(input.groupId) ?? Promise.resolve(); + const run = prior + .catch(() => { + // A failed prior promotion must not poison the chain; the next caller still + // gets a fresh, serialized attempt (re-merge is a no-op; PR-create idempotent). + }) + .then(() => promoteBranchGroupInner(input)); + promotionLocks.set(input.groupId, run); + try { + return await run; + } finally { + // Only clear if no newer call has chained on top of us. + if (promotionLocks.get(input.groupId) === run) { + promotionLocks.delete(input.groupId); + } + } +} + +async function promoteBranchGroupInner(input: PromoteBranchGroupInput): Promise { const group = input.store.getBranchGroup(input.groupId); if (!group) { return { @@ -207,7 +251,21 @@ export async function promoteBranchGroup(input: { }; } - if (group.status === "finalized" || group.prState === "merged") { + const isPrMode = input.settings.mergeStrategy === "pull-request"; + + // Fix #4 (2): a group that finalized but never gained its PR — e.g. a crash + // between the local integration merge and a successful createGroupPr — would be + // permanently stranded by the already-finalized short-circuit below. When in PR + // mode and the finalized group has no persisted PR number, fall through to the + // PR-creation step ONLY (the integration merge already happened, so we skip it) + // so a re-promotion can repair it. + const needsPrRepair = + isPrMode && + group.status === "finalized" && + group.prState !== "merged" && + (group.prNumber === null || group.prNumber === undefined); + + if (!needsPrRepair && (group.status === "finalized" || group.prState === "merged")) { return { groupId: group.id, promoted: false, @@ -234,58 +292,64 @@ export async function promoteBranchGroup(input: { } const members = await input.store.listTasksByBranchGroup(group.id); - const completion = evaluateBranchGroupCompletion({ members, group }); - 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: { + // On the PR-repair path the group is already finalized — completion and + // eligibility were satisfied at finalization, and the integration merge already + // landed. Re-gating/re-merging would be wrong, so we skip straight to PR-create. + if (!needsPrRepair) { + const completion = evaluateBranchGroupCompletion({ members, group }); + if (!completion.complete) { + return { 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, - }; + 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 }); + if (!needsPrRepair) { + 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"; - let prNumber: number | undefined = group.prNumber; let prUrl: string | undefined = group.prUrl; let prState: BranchGroupPrState = isPrMode ? "open" : "merged"; @@ -342,7 +406,7 @@ export async function promoteBranchGroup(input: { groupId: group.id, branchName: group.branchName, integrationBranch, - memberIds: completion.landedMemberIds, + memberIds: evaluateBranchGroupCompletion({ members, group }).landedMemberIds, ...(updatedGroup.prNumber ? { prNumber: updatedGroup.prNumber } : {}), ...(updatedGroup.prUrl ? { prUrl: updatedGroup.prUrl } : {}), }, @@ -360,6 +424,68 @@ export async function promoteBranchGroup(input: { }; } +export interface ReconcileBranchGroupPrResult { + reconciled: boolean; + prState: BranchGroupPrState; + prNumber: number | null; + prUrl: string | null; +} + +/** + * Fix #3 (engine side): out-of-band PR reconciliation primitive. + * + * Once a branch group finalizes, the member-landing sync stops firing, so nothing + * flips `prState` → "merged" after the managed GitHub PR is merged out-of-band. + * This helper, given a group carrying a persisted `prNumber` and `prState` "open", + * invokes the injected {@link SyncGroupPrFn} (which reconciles against GitHub via + * `getPrStatus`) and persists `prState`/`prUrl`/`prNumber` when GitHub reports a + * changed state. It mirrors the merger's U6 reconcile block. + * + * No-op (no write) when the group has no `prNumber`, is not "open", or GitHub still + * reports it open. The dashboard route that calls this on a schedule/refresh is + * wired in a separate batch; this is just the cleanly exported engine primitive. + */ +export async function reconcileBranchGroupPr(input: { + store: Pick; + group: BranchGroup; + syncGroupPr: SyncGroupPrFn; +}): Promise { + const { group } = input; + if (group.prNumber == null || group.prState !== "open") { + return { + reconciled: false, + prState: group.prState, + prNumber: group.prNumber ?? null, + prUrl: group.prUrl ?? null, + }; + } + + const members = await input.store.listTasksByBranchGroup(group.id); + const reconciled = await input.syncGroupPr({ group, members }); + + if (reconciled.prState === group.prState) { + return { + reconciled: false, + prState: group.prState, + prNumber: group.prNumber ?? null, + prUrl: group.prUrl ?? null, + }; + } + + const updated = input.store.updateBranchGroup(group.id, { + prState: reconciled.prState, + prNumber: reconciled.prNumber, + prUrl: reconciled.prUrl, + }); + + return { + reconciled: true, + prState: updated.prState, + prNumber: updated.prNumber ?? null, + prUrl: updated.prUrl ?? null, + }; +} + export async function resolveBranchGroupMergeRouting(input: { task: Pick; store: Pick; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7e19eaf2cc..fa187576c7 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -61,10 +61,13 @@ export { evaluateBranchGroupPromotion, evaluateBranchGroupCompletion, promoteBranchGroup, + reconcileBranchGroupPr, type BranchGroupMergeRouting, type BranchGroupPromotionDecision, type BranchGroupCompletionStatus, type BranchGroupPromotionResult, + type PromoteBranchGroupInput, + type ReconcileBranchGroupPrResult, type CreateGroupPrFn, type SyncGroupPrFn, type CloseGroupPrFn, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index c5e9613d24..20beb9d7f9 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -82,7 +82,6 @@ import { type PostMergeAuditMode, type TaskSourceIssue, type Task, - type BranchGroup, type AutostashOrphanRecord, normalizeMergeAdvanceAutoSyncMode, isMergeRequestContractShadowEnabled, @@ -7529,34 +7528,28 @@ export async function aiMergeTask( // non-fatal and retryable on the next landing / explicit refresh. if (options.syncGroupPr) { try { - const latestGroup = await Promise.resolve( - (store as any).getBranchGroup?.(groupRouting.branchGroup.id), - ) as BranchGroup | null | undefined; + const latestGroup = store.getBranchGroup(groupRouting.branchGroup.id); if (latestGroup && latestGroup.prNumber != null && latestGroup.prState === "open") { - const members = (await Promise.resolve( - (store as any).listTasksByBranchGroup?.(latestGroup.id), - )) as Task[] | undefined; + const members = await store.listTasksByBranchGroup(latestGroup.id); const reconciled = await options.syncGroupPr({ group: latestGroup, - members: members ?? [], + members, }); // Out-of-band reconciliation: if GitHub reports the PR is no longer // open (closed/merged), persist the corrected prState rather than // leaving a stale "open". if (reconciled.prState !== latestGroup.prState) { - await Promise.resolve( - (store as any).updateBranchGroup?.(latestGroup.id, { - prState: reconciled.prState, - prNumber: reconciled.prNumber, - prUrl: reconciled.prUrl, - }), - ); + store.updateBranchGroup(latestGroup.id, { + prState: reconciled.prState, + prNumber: reconciled.prNumber, + prUrl: reconciled.prUrl, + }); } } } catch (err) { // Non-fatal: never fail the merge/landing because PR sync failed. try { - await (store as any).recordRunAuditEvent?.({ + store.recordRunAuditEvent({ taskId, agentId: "merger", runId: `merge-${taskId}`, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 1f1d3a5365..3918bd19e0 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -1921,9 +1921,33 @@ export class ProjectEngine { }, }); } catch (promotionError) { + const message = + promotionError instanceof Error ? promotionError.message : String(promotionError); runtimeLog.warn( - `Branch-group promotion evaluation failed for ${taskId}: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}`, + `Branch-group promotion evaluation failed for ${taskId}: ${message}`, ); + // Fix #4 (1): a promotion failure here (e.g. createGroupPr throwing + // after the local integration merge) must NOT be swallowed silently — + // the group stays active/prState:none and is only recoverable via an + // explicit re-promote. Record an audit event so the failure is + // observable and operators/the dashboard can drive recovery. + try { + await store.recordRunAuditEvent({ + taskId, + agentId: "merger", + runId: `merge-${taskId}`, + domain: "git", + mutationType: "merge:branch-group-promotion-failed", + target: taskForPromotion.branchContext!.groupId, + metadata: { + groupId: taskForPromotion.branchContext!.groupId, + taskId, + error: message, + }, + }); + } catch { + // best-effort audit + } } }; From e54417c9874606bc9b2597aa433749476380b422 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 12:43:25 -0700 Subject: [PATCH 13/21] fix(FN-branch-group): security, parity, reconcile-on-read, N+1 review residuals Review residuals #5/#7/#8/#11/#12 + #3 wiring: forward the configured GitHub token to the abandon route's client; guard abandon against finalized/merged groups; reconcile an open group PR's state from GitHub on single-group reads (merged out-of-band now flips prState); add fn branch-group abandon for agent-native parity; block branchName shell injection (execFile argv push + core-side branch-name validation at group creation); and collapse the branch-groups list N+1 to a single task fetch via a shared filterTasksByBranchGroup helper. --- packages/cli/src/bin.ts | 17 +- .../commands/__tests__/branch-group.test.ts | 88 +++++++- .../commands/__tests__/task-lifecycle.test.ts | 17 +- packages/cli/src/commands/branch-group.ts | 45 ++++- packages/cli/src/commands/task-lifecycle.ts | 7 +- .../src/__tests__/branch-assignment.test.ts | 66 ++++++ .../src/__tests__/branch-group-store.test.ts | 14 ++ packages/core/src/branch-assignment.ts | 62 ++++++ packages/core/src/index.ts | 3 + packages/core/src/store.ts | 26 +-- .../__tests__/github-close-group-pr.test.ts | 28 ++- .../integrated-routers-group-pr-token.test.ts | 87 ++++++++ .../__tests__/routes-branch-groups.test.ts | 189 +++++++++++++++++- packages/dashboard/src/github.ts | 25 +++ packages/dashboard/src/index.ts | 2 +- .../routes/register-branch-groups-routes.ts | 60 +++++- .../src/routes/register-integrated-routers.ts | 20 +- 17 files changed, 717 insertions(+), 39 deletions(-) create mode 100644 packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index f4c6edd21b..4ade1a74a9 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -124,7 +124,7 @@ async function loadCommandHandlers() { const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js"); - const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote } = await import("./commands/branch-group.js"); + const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js"); const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice, runMissionLinkGoal, runMissionUnlinkGoal, runMissionGoals } = await import("./commands/mission.js"); @@ -188,6 +188,7 @@ async function loadCommandHandlers() { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, + runBranchGroupAbandon, runBackupCreate, runBackupList, runBackupRestore, @@ -373,6 +374,8 @@ PR: fn branch-group show Show a branch group's members and completion gate fn branch-group promote Promote a complete group (opens/links the single managed PR) + fn branch-group abandon + Abandon a group (best-effort closes the managed PR) fn agent stop Stop a running agent (pause execution) fn agent start Start a stopped agent (resume execution) fn agent import [--dry-run] [--skip-existing] @@ -634,6 +637,7 @@ async function main() { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, + runBranchGroupAbandon, runBackupCreate, runBackupList, runBackupRestore, @@ -1591,9 +1595,18 @@ async function main() { await runBranchGroupPromote(id, projectName); break; } + case "abandon": { + const id = args[2]; + if (!id) { + console.error("Usage: fn branch-group abandon "); + process.exit(1); + } + await runBranchGroupAbandon(id, projectName); + break; + } default: console.error(`Unknown subcommand: branch-group ${subcommand || ""}`); - console.log("Try: fn branch-group list | show | promote "); + console.log("Try: fn branch-group list | show | promote | abandon "); process.exit(1); } break; diff --git a/packages/cli/src/commands/__tests__/branch-group.test.ts b/packages/cli/src/commands/__tests__/branch-group.test.ts index 580a5fcb20..a9404d0ac5 100644 --- a/packages/cli/src/commands/__tests__/branch-group.test.ts +++ b/packages/cli/src/commands/__tests__/branch-group.test.ts @@ -14,8 +14,10 @@ vi.mock("@fusion/engine", () => ({ // The canonical completion predicate lives in @fusion/core; keep its real // behavior so the CLI gate matches the dashboard route gate (parity). +const closeGroupPullRequestMock = vi.fn(async () => ({ prNumber: 55, prUrl: "https://example/pr/55", prState: "closed" as const })); vi.mock("@fusion/dashboard", () => ({ GitHubClient: vi.fn(function GitHubClient() {}), + closeGroupPullRequest: (...args: unknown[]) => closeGroupPullRequestMock(...args), })); const createGroupPrCallbackMock = vi.fn(() => async () => ({ prNumber: 1, prUrl: "x", prState: "open" as const })); @@ -24,7 +26,7 @@ vi.mock("../task-lifecycle.js", () => ({ })); import { resolveProject } from "../../project-context.js"; -import { runBranchGroupPromote, runBranchGroupList } from "../branch-group.js"; +import { runBranchGroupPromote, runBranchGroupList, runBranchGroupAbandon } from "../branch-group.js"; const LANDED_TASK = { id: "FN-1", @@ -51,6 +53,7 @@ function makeStore(group: Record, members: unknown[]) { getBranchGroup: vi.fn(() => group), listBranchGroups: vi.fn(() => [group]), listTasksByBranchGroup: vi.fn(async () => members), + updateBranchGroup: vi.fn((_id: string, patch: Record) => ({ ...group, ...patch })), getSettings: vi.fn(async () => ({ autoMerge: false, globalPause: false, @@ -174,3 +177,86 @@ describe("branch-group CLI promote (agent-native parity)", () => { expect(out).toContain("PR open"); }); }); + +describe("branch-group CLI abandon (agent-native parity, Fix #7)", () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + let errSpy: ReturnType; + + beforeEach(() => { + closeGroupPullRequestMock.mockClear(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + vi.mocked(resolveProject).mockReset(); + }); + + function mountStore(group: Record) { + const store = makeStore(group, []); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never, + }); + return store; + } + + it("closes the managed PR and marks the group abandoned/closed", async () => { + const store = mountStore({ ...BASE_GROUP, prState: "open", prNumber: 55, prUrl: "https://example/pr/55" }); + + await runBranchGroupAbandon("BG-1"); + + expect(closeGroupPullRequestMock).toHaveBeenCalledTimes(1); + expect(store.updateBranchGroup).toHaveBeenCalledWith( + "BG-1", + expect.objectContaining({ status: "abandoned", prState: "closed" }), + ); + expect(logSpy.mock.calls.flat().join("\n")).toContain("abandoned"); + }); + + it("abandons without touching GitHub when there is no open PR", async () => { + const store = mountStore({ ...BASE_GROUP, prState: "none", prNumber: undefined }); + + await runBranchGroupAbandon("BG-1"); + + expect(closeGroupPullRequestMock).not.toHaveBeenCalled(); + expect(store.updateBranchGroup).toHaveBeenCalledWith( + "BG-1", + expect.objectContaining({ status: "abandoned", prState: "closed" }), + ); + }); + + it("still marks abandoned when the PR close fails (best-effort)", async () => { + const store = mountStore({ ...BASE_GROUP, prState: "open", prNumber: 55 }); + closeGroupPullRequestMock.mockRejectedValueOnce(new Error("github down")); + + await runBranchGroupAbandon("BG-1"); + + expect(store.updateBranchGroup).toHaveBeenCalledWith( + "BG-1", + expect.objectContaining({ status: "abandoned", prState: "closed" }), + ); + }); + + it("rejects abandon of an already-merged group (terminal-state guard)", async () => { + const store = mountStore({ ...BASE_GROUP, prState: "merged", status: "open" }); + + await expect(runBranchGroupAbandon("BG-1")).rejects.toThrow(/process.exit/); + expect(closeGroupPullRequestMock).not.toHaveBeenCalled(); + expect(store.updateBranchGroup).not.toHaveBeenCalled(); + expect(errSpy.mock.calls.flat().join("\n")).toMatch(/finalized\/merged/); + }); + + it("rejects abandon of an already-abandoned group", async () => { + const store = mountStore({ ...BASE_GROUP, status: "abandoned", prState: "closed" }); + + await expect(runBranchGroupAbandon("BG-1")).rejects.toThrow(/process.exit/); + expect(store.updateBranchGroup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index ec2f4643cb..7f6c82b4ff 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -4,6 +4,10 @@ import { EventEmitter } from "node:events"; // Mock child_process so we can intercept the `git push -u origin ` // call that processPullRequestMergeTask issues before createPr. const execMock = vi.hoisted(() => vi.fn()); +// Records raw (file, args[]) tuples for execFile so tests can assert a no-shell +// invocation (Fix #11) — i.e. the branch is a discrete argv entry, not shell- +// interpolated. +const execFileCalls = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>); vi.mock("node:child_process", () => ({ exec: (cmd: string, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { try { @@ -15,6 +19,7 @@ vi.mock("node:child_process", () => ({ }, execFile: (file: string, args: string[] | undefined, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { try { + execFileCalls.push({ file, args: args ?? [] }); const result = execMock(`${file} ${(args ?? []).join(" ")}`.trim(), opts); cb(null, typeof result === "string" ? result : "", ""); } catch (err) { @@ -114,6 +119,7 @@ function makeStatefulStore(task: MockTask, settings: Record = { describe("processPullRequestMergeTask", () => { beforeEach(() => { execMock.mockReset(); + execFileCalls.length = 0; }); it("pushes the per-task branch to origin before creating a new PR", async () => { @@ -169,12 +175,21 @@ describe("processPullRequestMergeTask", () => { expect(github.findPrForBranch).toHaveBeenCalled(); // The git push must happen after findPrForBranch and before createPr. - const pushIdx = callOrder.findIndex((c) => c === `exec:git push -u origin "${branch}"`); + // No-shell invocation (Fix #11): the branch is now a discrete execFile arg, so + // there are no surrounding quotes in the recorded command string. + const pushIdx = callOrder.findIndex((c) => c === `exec:git push -u origin ${branch}`); const findIdx = callOrder.indexOf("findPrForBranch"); const createIdx = callOrder.indexOf("createPr"); expect(pushIdx).toBeGreaterThan(-1); expect(pushIdx).toBeGreaterThan(findIdx); expect(pushIdx).toBeLessThan(createIdx); + + // The push goes through execFile with the branch as a separate argv entry — + // never interpolated into a shell command — so a crafted branch name can't + // execute a subshell. + const pushCall = execFileCalls.find((c) => c.file === "git" && c.args[0] === "push"); + expect(pushCall).toBeDefined(); + expect(pushCall!.args).toEqual(["push", "-u", "origin", branch]); }); it("creates shared-group PR from integration branch into default branch", async () => { diff --git a/packages/cli/src/commands/branch-group.ts b/packages/cli/src/commands/branch-group.ts index dc940a8eb6..fe0f3a1312 100644 --- a/packages/cli/src/commands/branch-group.ts +++ b/packages/cli/src/commands/branch-group.ts @@ -1,6 +1,6 @@ import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, type BranchGroup, type Settings } from "@fusion/core"; import { promoteBranchGroup, resolveIntegrationBranch } from "@fusion/engine"; -import { GitHubClient } from "@fusion/dashboard"; +import { GitHubClient, closeGroupPullRequest } from "@fusion/dashboard"; import { resolveProject } from "../project-context.js"; import { createGroupPrCallback } from "./task-lifecycle.js"; @@ -108,6 +108,49 @@ export async function runBranchGroupShow(id: string, projectName?: string) { console.log(); } +export async function runBranchGroupAbandon(id: string, projectName?: string) { + const { store } = await getBranchGroupContext(projectName); + const group = store.getBranchGroup(id); + if (!group) { + console.error(`\n ✗ Branch group ${id} not found\n`); + process.exit(1); + } + + // Terminal-state guard — same semantics as the dashboard abandon route (Fix #2): + // a finalized/merged or already-abandoned group cannot be abandoned. + if (group.status === "abandoned" || group.status === "finalized" || group.prState === "merged") { + console.error(`\n ✗ Branch group ${id} is already ${group.status === "abandoned" ? "abandoned" : "finalized/merged"} and cannot be abandoned\n`); + process.exit(1); + } + + let prState: BranchGroup["prState"] = "closed"; + let prNumber = group.prNumber; + let prUrl = group.prUrl; + + // Best-effort close of the single managed GitHub PR (R7). If it fails, still + // mark the row abandoned/closed and leave the PR for out-of-band reconciliation. + if (group.prState === "open" && group.prNumber != null) { + try { + const github = new GitHubClient(process.env.GITHUB_TOKEN); + const reconciled = await closeGroupPullRequest(github, group); + prState = reconciled.prState; + prNumber = reconciled.prNumber; + prUrl = reconciled.prUrl; + } catch (err) { + console.error(` ! Could not close GitHub PR (left for out-of-band reconciliation): ${err instanceof Error ? err.message : String(err)}`); + } + } + + const updated = store.updateBranchGroup(id, { + status: "abandoned", + prState, + prNumber: prNumber ?? null, + prUrl: prUrl ?? null, + }); + + console.log(`\n ✓ Branch group ${updated.id} abandoned (status: ${updated.status}, prState: ${updated.prState})\n`); +} + export async function runBranchGroupPromote(id: string, projectName?: string) { const { store, projectPath } = await getBranchGroupContext(projectName); const group = store.getBranchGroup(id); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index 3e01ebfc69..d218a80842 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -13,9 +13,10 @@ * - Full PR lifecycle orchestration (create → status check → merge) */ -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); import type { TaskStore } from "@fusion/core"; import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core"; import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core"; @@ -107,7 +108,9 @@ async function pushTaskBranchToOrigin(cwd: string, branch: string): Promise { + it("accepts legitimate branch names", () => { + for (const name of ["feature/auth-shared", "fusion/fn-123", "main", "release/v1.2.3", "fn/shared", "a"]) { + expect(isValidBranchGroupBranchName(name)).toBe(true); + } + }); + + it("rejects injection-shaped and unsafe names", () => { + for (const name of [ + "$(touch /tmp/x)", + "`whoami`", + "feature; rm -rf /", + "a|b", + "a&b", + "branch with spaces", + "-leading-dash", + 'has"quote', + "has'quote", + "back\\slash", + "a..b", + "a~b", + "a^b", + "a:b", + "trailing/", + "/leading", + "", + " ", + "tail.lock", + ]) { + expect(isValidBranchGroupBranchName(name)).toBe(false); + } + }); + + it("validateBranchGroupBranchName throws on invalid and returns valid", () => { + expect(validateBranchGroupBranchName("feature/ok")).toBe("feature/ok"); + expect(() => validateBranchGroupBranchName("$(touch /tmp/x)")).toThrow(/Invalid branch group branch name/); + }); +}); + +describe("filterTasksByBranchGroup (Fix #8/#9)", () => { + const tasks = [ + { id: "T1", branchContext: { groupId: "BG-1" } }, + { id: "T2", branchContext: { groupId: "planning:PS-1" } }, + { id: "T3", branchContext: { groupId: "BG-2" } }, + { id: "T4", branchContext: undefined }, + ]; + + it("matches the real BG id", () => { + const group = { id: "BG-2", sourceType: "planning", sourceId: "PS-2" }; + expect(filterTasksByBranchGroup(tasks, group, "BG-2").map((t) => t.id)).toEqual(["T3"]); + }); + + it("also matches the legacy synthetic groupId for planning/mission groups", () => { + const group = { id: "BG-1", sourceType: "planning", sourceId: "PS-1" }; + expect(filterTasksByBranchGroup(tasks, group, "BG-1").map((t) => t.id).sort()).toEqual(["T1", "T2"]); + }); + + it("does not apply the legacy fallback for non-planning/mission sources", () => { + const group = { id: "BG-1", sourceType: "task", sourceId: "PS-1" }; + expect(filterTasksByBranchGroup(tasks, group, "BG-1").map((t) => t.id)).toEqual(["T1"]); + }); +}); + describe("branch-assignment", () => { it("sanitizes branch segments", () => { expect(sanitizeBranchSegment(" FN-123 add parser!!! ")).toBe("fn-123-add-parser"); diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index 601fc7045b..e216fe6652 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -84,6 +84,20 @@ describe("TaskStore branch groups", () => { ).toThrow(); }); + it("rejects injection-shaped branch names at createBranchGroup (Fix #11)", () => { + for (const bad of ["$(touch /tmp/x)", "`cmd`", "feature; rm -rf /", "has space", "a|b"]) { + expect(() => + store.createBranchGroup({ sourceType: "planning", sourceId: `bad-${bad}`, branchName: bad }), + ).toThrow(/Invalid branch group branch name/); + } + // ensureBranchGroupForSource shares the createBranchGroup path → also rejected. + expect(() => + store.ensureBranchGroupForSource("planning", "PS-inj", { branchName: "$(evil)", autoMerge: false }), + ).toThrow(/Invalid branch group branch name/); + // Legitimate names still pass. + expect(store.createBranchGroup({ sourceType: "planning", sourceId: "PS-good", branchName: "feature/auth-shared" }).branchName).toBe("feature/auth-shared"); + }); + it("finds open branch groups by branch name and ignores closed groups", () => { expect(store.getBranchGroupByBranchName("fn/missing")).toBeNull(); diff --git a/packages/core/src/branch-assignment.ts b/packages/core/src/branch-assignment.ts index c72d0dda5b..78323b5b61 100644 --- a/packages/core/src/branch-assignment.ts +++ b/packages/core/src/branch-assignment.ts @@ -11,6 +11,68 @@ export interface EntryPointBranchAssignment { mergeTargetBranch?: string; } +/** + * Conservative git-ref-safe validation for a branch-group branch name, enforced + * at the persistence boundary (Fix #11). Branch names flow into shell-adjacent + * git invocations across the coordinator/merger; rejecting injection-shaped names + * at group creation blocks the shell-injection path at the source for every + * downstream sink. Legitimate names (slashes, dots, dashes — e.g. `feature/auth`, + * `fusion/fn-123`) must still pass; only names that could break out of an arg + * (whitespace, `$`, backtick, `;`, `|`, `&`, quotes, parens/braces/brackets, + * angle brackets, leading dash, refspec specials) are rejected. + */ +export function isValidBranchGroupBranchName(name: string): boolean { + if (typeof name !== "string") return false; + const trimmed = name.trim(); + if (trimmed.length === 0) return false; + if (trimmed !== name) return false; // surrounding whitespace + if (name.length > 255) return false; + if (name.startsWith("-")) return false; + if (/\s/.test(name)) return false; + // Shell / refspec metacharacters that could escape a single git arg. + if (/[$`;|&<>(){}\[\]"'\\!*?~^:]/.test(name)) return false; + if (name.includes("..")) return false; + if (name.includes("@{")) return false; + if (name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.endsWith(".lock")) return false; + const reserved = ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"]; + if (reserved.includes(name)) return false; + return true; +} + +/** Throwing wrapper used at the store persistence boundary. */ +export function validateBranchGroupBranchName(name: string): string { + if (!isValidBranchGroupBranchName(name)) { + throw new Error(`Invalid branch group branch name: ${JSON.stringify(name)}`); + } + return name; +} + +/** + * Pure membership filter shared by `TaskStore.listTasksByBranchGroup` and the + * dashboard list route (Fix #8/#9) so the legacy synthetic-groupId fallback + * semantics can't drift between the two call sites. Groups created before the + * membership-identity fix stamped `branchContext.groupId` with a synthetic + * `:` string instead of the real `BG-` id; this matches + * both forms. Caller is responsible for sorting. + */ +export function filterTasksByBranchGroup< + T extends { branchContext?: { groupId?: string } | null }, +>( + tasks: T[], + group: { id: string; sourceType?: string; sourceId?: string } | null | undefined, + groupId: string, +): T[] { + const legacyGroupId = + group && (group.sourceType === "planning" || group.sourceType === "mission") + ? `${group.sourceType}:${group.sourceId}` + : undefined; + return tasks.filter( + (task) => + task.branchContext?.groupId === groupId || + (legacyGroupId !== undefined && task.branchContext?.groupId === legacyGroupId), + ); +} + export function sanitizeBranchSegment(input: string): string { return input .trim() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 10717c2115..8d0aa646c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,9 @@ export { sanitizeBranchSegment, derivePerTaskBranchName, deriveAutoTaskBranchName, + isValidBranchGroupBranchName, + validateBranchGroupBranchName, + filterTasksByBranchGroup, } from "./branch-assignment.js"; export type { EntryPointAssignmentMode, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 7bc69d99f0..00e58a708c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -9,6 +9,7 @@ import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_ import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js"; import { normalizeTaskPriority } from "./task-priority.js"; +import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; import { GlobalSettingsStore } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; @@ -4336,6 +4337,9 @@ export class TaskStore extends EventEmitter { } createBranchGroup(input: BranchGroupCreateInput): BranchGroup { + // Fix #11: reject injection-shaped branch names at the persistence boundary + // so they can never reach a downstream git/shell sink (coordinator, merger). + validateBranchGroupBranchName(input.branchName); const now = Date.now(); const id = this.generateBranchGroupId(); this.db.prepare(` @@ -4474,23 +4478,13 @@ export class TaskStore extends EventEmitter { async listTasksByBranchGroup(groupId: string): Promise { const tasks = await this.listTasks({ includeArchived: false, slim: true }); - // LEGACY SHIM (removable): groups created before the membership-identity fix - // stamped branchContext.groupId with a synthetic string (`planning:` / - // `mission:`) instead of the real `BG-` id. Derive that synthetic form - // from the group's source so those old rows still enumerate. New rows match on the - // real id directly; this fallback can be deleted once no legacy groups remain. + // Membership filter (incl. legacy synthetic-groupId fallback) is shared with + // the dashboard list route via `filterTasksByBranchGroup` so semantics can't + // drift between the two call sites (Fix #8/#9). const group = this.getBranchGroup(groupId); - const legacyGroupId = - group && (group.sourceType === "planning" || group.sourceType === "mission") - ? `${group.sourceType}:${group.sourceId}` - : undefined; - return tasks - .filter( - (task) => - task.branchContext?.groupId === groupId || - (legacyGroupId !== undefined && task.branchContext?.groupId === legacyGroupId), - ) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return filterTasksByBranchGroup(tasks, group, groupId).sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } recordBranchGroupMemberLanded( diff --git a/packages/dashboard/src/__tests__/github-close-group-pr.test.ts b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts index 5920708646..3fdc241980 100644 --- a/packages/dashboard/src/__tests__/github-close-group-pr.test.ts +++ b/packages/dashboard/src/__tests__/github-close-group-pr.test.ts @@ -16,7 +16,7 @@ vi.mock("@fusion/core", async () => { }); import { runGh, runGhJsonAsync, isGhAvailable, isGhAuthenticated } from "@fusion/core"; -import { GitHubClient, closeGroupPullRequest } from "../github.js"; +import { GitHubClient, closeGroupPullRequest, reconcileGroupPullRequest } from "../github.js"; const mockRunGh = vi.mocked(runGh); const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync); @@ -72,3 +72,29 @@ describe("closeGroupPullRequest", () => { expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close")).toBeUndefined(); }); }); + +describe("reconcileGroupPullRequest (Fix #3)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsGhAvailable.mockReturnValue(true); + mockIsGhAuthenticated.mockReturnValue(true); + }); + + it("maps a merged GitHub PR to prState=merged without mutating it", async () => { + mockRunGhJsonAsync.mockResolvedValue({ ...ghPrViewOpen, state: "MERGED" } as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await reconcileGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + expect(result.prState).toBe("merged"); + // Pure read — never edits or closes. + expect(mockRunGh.mock.calls.find((c) => c[0]?.[1] === "close" || c[0]?.[1] === "edit")).toBeUndefined(); + }); + + it("returns prState=open for a still-open PR", async () => { + mockRunGhJsonAsync.mockResolvedValue(ghPrViewOpen as any); + const client = new GitHubClient({ forceMode: undefined as never }); + + const result = await reconcileGroupPullRequest(client, { id: group.id, prNumber: group.prNumber }); + expect(result.prState).toBe("open"); + }); +}); diff --git a/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts b/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts new file mode 100644 index 0000000000..92e29293a2 --- /dev/null +++ b/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import express from "express"; +import type { BranchGroup, Task, TaskStore } from "@fusion/core"; +import { request as REQUEST } from "../test-request.js"; + +// Capture how GitHubClient is constructed so we can assert the configured token +// is forwarded (Fix #1) into the abandon/reconcile close path. +const ctorCalls: Array = []; + +vi.mock("../github.js", () => { + class GitHubClient { + constructor(tokenOrOptions?: unknown) { + ctorCalls.push(tokenOrOptions); + } + } + return { + GitHubClient, + closeGroupPullRequest: vi.fn(async (_client: unknown, group: { prNumber: number; prUrl?: string }) => ({ + prNumber: group.prNumber, + prUrl: group.prUrl ?? "https://example/pr", + prState: "closed" as const, + })), + reconcileGroupPullRequest: vi.fn(async () => ({ prNumber: 0, prUrl: "", prState: "open" as const })), + }; +}); + +// reconcileBranchGroupPr is real-ish but harmless here; stub to avoid GitHub. +vi.mock("@fusion/engine", async () => { + const actual = await vi.importActual("@fusion/engine"); + return { ...actual, reconcileBranchGroupPr: vi.fn(async () => ({ reconciled: false, prState: "open", prNumber: null, prUrl: null })) }; +}); + +import { registerIntegratedRouters } from "../routes/register-integrated-routers.js"; + +function buildGroup(): BranchGroup { + return { + id: "BG-TOK", + sourceType: "planning", + sourceId: "PS-TOK", + branchName: "feature/tok", + autoMerge: false, + prState: "open", + prNumber: 99, + prUrl: "https://example/pr/99", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + }; +} + +function buildStore(group: BranchGroup): TaskStore { + let current = { ...group }; + return { + getRootDir: vi.fn(() => "/tmp/project"), + getBranchGroup: vi.fn(() => current), + listBranchGroups: vi.fn(() => [current]), + listTasks: vi.fn(async () => [] as Task[]), + listTasksByBranchGroup: vi.fn(async () => [] as Task[]), + updateBranchGroup: vi.fn((_id: string, patch: Partial) => { + current = { ...current, ...patch }; + return current; + }), + } as unknown as TaskStore; +} + +describe("integrated branch-groups router — GitHub token wiring (Fix #1)", () => { + beforeEach(() => { + ctorCalls.length = 0; + }); + + it("forwards options.githubToken into GitHubClient for the abandon close path", async () => { + const store = buildStore(buildGroup()); + const router = express.Router(); + registerIntegratedRouters({ router, store, options: { githubToken: "ghp_test_secret" } as any }); + + const app = express(); + app.use(express.json()); + app.use("/api", router); + + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-TOK/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(200); + // The closeGroupPr callback constructed a GitHubClient with the configured token. + expect(ctorCalls).toContain("ghp_test_secret"); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index b35a196092..92497246d2 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -6,8 +6,22 @@ import type { BranchGroup, Task, TaskStore } from "@fusion/core"; import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js"; +import { ApiError, sendErrorResponse } from "../api-error.js"; import { request as REQUEST } from "../test-request.js"; +// Standalone routers (mounted without createApiRoutes) need the same error +// middleware createApiRoutes provides, so thrown ApiErrors become HTTP responses +// instead of hanging the request. +function attachErrorHandler(app: express.Express) { + app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + if (err instanceof ApiError) { + sendErrorResponse(res, err.statusCode, err.message, { details: err.details }); + return; + } + sendErrorResponse(res, 500, err instanceof Error ? err.message : "Internal server error"); + }); +} + function buildTask(id: string, groupId: string, landed: boolean): Task { return { id, @@ -30,6 +44,7 @@ function createStore(group: BranchGroup, tasks: Task[]): TaskStore { getRootDir: vi.fn(() => "/tmp/project"), listBranchGroups: vi.fn(() => [group]), getBranchGroup: vi.fn((id: string) => (id === group.id ? group : null)), + listTasks: vi.fn(async () => tasks), listTasksByBranchGroup: vi.fn(async () => tasks), setTaskBranchGroup: vi.fn(async () => {}), ensureBranchGroupForSource: vi.fn(() => group), @@ -237,6 +252,7 @@ describe("branch group abandon (U6, R7)", () => { const app = express(); app.use(express.json()); app.use("/branch-groups", createBranchGroupsRouter(store, { closeGroupPr })); + attachErrorHandler(app); return app; } @@ -276,16 +292,179 @@ describe("branch group abandon (U6, R7)", () => { expect(res.body.group.status).toBe("abandoned"); }); - it("preserves prState=merged on abandon if the group was already merged", async () => { + it("rejects abandon of an already-merged group with 400 (Fix #2)", async () => { const merged = { ...buildOpenGroup(), prState: "merged" as const }; - const { store } = buildAbandonStore(merged); + const { store, updateBranchGroup } = buildAbandonStore(merged); const closeGroupPr = vi.fn(); const app = mount(store, closeGroupPr as unknown as ReturnType); const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); - expect(res.status).toBe(200); - // Already merged → do not close; keep merged terminal state. + // Terminal state — must not flip to abandoned/closed. + expect(res.status).toBe(400); expect(closeGroupPr).not.toHaveBeenCalled(); - expect(res.body.group.prState).toBe("merged"); + expect(updateBranchGroup).not.toHaveBeenCalled(); + }); + + it("rejects abandon of a finalized group with 400 (Fix #2)", async () => { + const finalized = { ...buildOpenGroup(), status: "finalized" as const }; + const { store, updateBranchGroup } = buildAbandonStore(finalized); + const closeGroupPr = vi.fn(); + const app = mount(store, closeGroupPr as unknown as ReturnType); + + const res = await REQUEST(app, "POST", "/branch-groups/BG-AB/abandon", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBe(400); + expect(closeGroupPr).not.toHaveBeenCalled(); + expect(updateBranchGroup).not.toHaveBeenCalled(); + }); +}); + +describe("branch group reconcile-on-read (Fix #3)", () => { + function buildOpenGroup(): BranchGroup { + return { + id: "BG-RC", + sourceType: "planning", + sourceId: "PS-RC", + branchName: "feature/shared-rc", + autoMerge: false, + prState: "open", + prNumber: 77, + prUrl: "https://example/pr/77", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } + + function buildStore(initial: BranchGroup) { + let current = { ...initial }; + const store = { + getRootDir: vi.fn(() => "/tmp/project"), + getBranchGroup: vi.fn(() => current), + listTasksByBranchGroup: vi.fn(async () => [] as Task[]), + updateBranchGroup: vi.fn((_id: string, patch: Partial) => { + current = { ...current, ...patch }; + return current; + }), + } as unknown as TaskStore; + return { store, getCurrent: () => current }; + } + + function mount(store: TaskStore, reconcileGroupPr?: ReturnType) { + const app = express(); + app.use(express.json()); + app.use("/branch-groups", createBranchGroupsRouter(store, { reconcileGroupPr })); + attachErrorHandler(app); + return app; + } + + it("flips prState to merged and persists when the injected reconcile reports merged", async () => { + const { store, getCurrent } = buildStore(buildOpenGroup()); + const reconcileGroupPr = vi.fn(async ({ group }: { group: BranchGroup }) => { + // Mirror the wired callback: persist via the store, then return fresh row. + store.updateBranchGroup(group.id, { prState: "merged", prNumber: 77, prUrl: group.prUrl ?? null }); + return getCurrent(); + }); + const app = mount(store, reconcileGroupPr); + + const res = await REQUEST(app, "GET", "/branch-groups/BG-RC"); + expect(res.status).toBe(200); + expect(reconcileGroupPr).toHaveBeenCalledTimes(1); + expect(res.body.group.prState).toBe("merged"); + expect(getCurrent().prState).toBe("merged"); + }); + + it("returns 200 with stale state when the reconcile callback throws", async () => { + const { store } = buildStore(buildOpenGroup()); + const reconcileGroupPr = vi.fn(async () => { throw new Error("github down"); }); + const app = mount(store, reconcileGroupPr); + + const res = await REQUEST(app, "GET", "/branch-groups/BG-RC"); + expect(res.status).toBe(200); + expect(reconcileGroupPr).toHaveBeenCalledTimes(1); + expect(res.body.group.prState).toBe("open"); + }); + + it("does not reconcile when the group has no open PR", async () => { + const noPr = { ...buildOpenGroup(), prState: "none" as const, prNumber: undefined }; + const { store } = buildStore(noPr); + const reconcileGroupPr = vi.fn(); + const app = mount(store, reconcileGroupPr as unknown as ReturnType); + + const res = await REQUEST(app, "GET", "/branch-groups/BG-RC"); + expect(res.status).toBe(200); + expect(reconcileGroupPr).not.toHaveBeenCalled(); + }); +}); + +describe("branch group list N+1 elimination (Fix #6)", () => { + function buildGroups(): BranchGroup[] { + const base = { + sourceType: "planning" as const, + autoMerge: false, + prState: "open" as const, + status: "open" as const, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + return [ + { ...base, id: "BG-A", sourceId: "PS-A", branchName: "feature/a" }, + { ...base, id: "BG-B", sourceId: "PS-B", branchName: "feature/b" }, + { ...base, id: "BG-C", sourceId: "PS-C", branchName: "feature/c" }, + ]; + } + + // Landed requires mergeTargetBranch === the group's branchName, so build tasks + // with a branch that matches their group. + function memberTask(id: string, groupId: string, branchName: string, landed: boolean): Task { + return { + id, + description: id, + column: landed ? "done" : "in-progress", + dependencies: [], + steps: [], + currentStep: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + branchContext: { groupId, source: "planning", assignmentMode: "shared" }, + mergeDetails: landed + ? { mergeConfirmed: true, mergeTargetSource: "branch-group-integration", mergeTargetBranch: branchName } + : undefined, + } as Task; + } + + it("issues exactly ONE listTasks call regardless of group count, with identical results", async () => { + const groups = buildGroups(); + const tasks: Task[] = [ + memberTask("FN-A1", "BG-A", "feature/a", true), + memberTask("FN-A2", "BG-A", "feature/a", false), + memberTask("FN-B1", "BG-B", "feature/b", true), + ]; + const listTasks = vi.fn(async () => tasks); + // listTasksByBranchGroup must NOT be used by the list route anymore. + const listTasksByBranchGroup = vi.fn(async (groupId: string) => + tasks.filter((t) => t.branchContext?.groupId === groupId), + ); + const store = { + getRootDir: vi.fn(() => "/tmp/project"), + listBranchGroups: vi.fn(() => groups), + getBranchGroup: vi.fn((id: string) => groups.find((g) => g.id === id) ?? null), + listTasks, + listTasksByBranchGroup, + } 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); + expect(listTasks).toHaveBeenCalledTimes(1); + expect(listTasksByBranchGroup).not.toHaveBeenCalled(); + + const byId = Object.fromEntries(res.body.groups.map((g: { id: string }) => [g.id, g])); + expect(byId["BG-A"].completion).toEqual({ landed: 1, total: 2, complete: false }); + expect(byId["BG-B"].completion).toEqual({ landed: 1, total: 1, complete: true }); + expect(byId["BG-C"].completion).toEqual({ landed: 0, total: 0, complete: false }); }); }); diff --git a/packages/dashboard/src/github.ts b/packages/dashboard/src/github.ts index f18af7865d..ca5d5feb12 100644 --- a/packages/dashboard/src/github.ts +++ b/packages/dashboard/src/github.ts @@ -3847,6 +3847,31 @@ export interface CreateGroupPrResult { prState: BranchGroupPrState; } +/** + * Read-only reconciliation of the single managed group PR against GitHub (Fix + * #3). Reads the current PR status and maps it to the persisted `prState`. Used + * by the dashboard's single-group read path (`GET /branch-groups/:id`) to flip + * `prState` → merged/closed when the PR was merged/closed out-of-band. Does not + * mutate the PR; if GitHub still reports it open, returns the open state so the + * caller writes nothing. + */ +export async function reconcileGroupPullRequest( + github: Pick, + group: Pick, +): Promise { + const prNumber = group.prNumber; + if (prNumber == null) { + throw new Error(`reconcileGroupPullRequest: group ${group.id} has no persisted prNumber`); + } + const { owner, repo } = getCurrentRepoOrThrow(); + const current = await github.getPrStatus(owner, repo, prNumber); + return { + prNumber: current.number, + prUrl: current.url, + prState: prInfoToBranchGroupPrState(current), + }; +} + /** * Close the single managed group PR (U6, R7) — best-effort terminal * reconciliation when a branch group is abandoned. If the PR is already diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 91098aad06..8ec1c3e229 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -11,7 +11,7 @@ export { type RuntimeLogSink, } from "./runtime-logger.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; -export { GitHubClient, isPrMergeReady, closeGroupPullRequest, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js"; +export { GitHubClient, isPrMergeReady, closeGroupPullRequest, reconcileGroupPullRequest, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type UpdatePrParams, type ClosePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue, type CreateGroupPrResult } from "./github.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { diff --git a/packages/dashboard/src/routes/register-branch-groups-routes.ts b/packages/dashboard/src/routes/register-branch-groups-routes.ts index 49fddd8149..900ba4c2a5 100644 --- a/packages/dashboard/src/routes/register-branch-groups-routes.ts +++ b/packages/dashboard/src/routes/register-branch-groups-routes.ts @@ -1,6 +1,6 @@ import { Router, type Request } from "express"; -import type { BranchGroup, TaskStore } from "@fusion/core"; -import { isBranchGroupComplete, isBranchGroupMemberLanded } from "@fusion/core"; +import type { BranchGroup, Task, TaskStore } from "@fusion/core"; +import { isBranchGroupComplete, isBranchGroupMemberLanded, filterTasksByBranchGroup } from "@fusion/core"; import { badRequest, notFound } from "../api-error.js"; export interface BranchGroupsRouterOptions { @@ -16,6 +16,18 @@ export interface BranchGroupsRouterOptions { group: BranchGroup; projectId?: string; }) => Promise<{ prNumber: number; prUrl: string; prState: BranchGroup["prState"] } | null>; + /** + * Out-of-band PR reconciliation on single-group read (Fix #3): when a group has + * an open managed PR, this is invoked best-effort before serialization so a PR + * merged/closed directly on GitHub flips `prState` accordingly. Wired over the + * engine's `reconcileBranchGroupPr` + a GitHub-backed `SyncGroupPrFn`. Omitted + * (or throwing) leaves the persisted state untouched. Only the single-group + * GET path calls this — the list stays cheap. + */ + reconcileGroupPr?: (input: { + group: BranchGroup; + projectId?: string; + }) => Promise; } function parseProjectId(req: Request): string | undefined { @@ -23,8 +35,18 @@ function parseProjectId(req: Request): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } -async function serializeGroup(store: TaskStore, group: BranchGroup) { - const members = await store.listTasksByBranchGroup(group.id); +/** + * Serialize a single group. Pass `allTasks` to filter membership in memory from a + * single up-front `listTasks` call (list route, Fix #8/#9 — avoids the N+1 scan); + * omit it to fall back to a per-group `listTasksByBranchGroup` scan (single-group + * read / abandon, where one scan is fine). + */ +async function serializeGroup(store: TaskStore, group: BranchGroup, allTasks?: Task[]) { + const members = allTasks + ? filterTasksByBranchGroup(allTasks, group, group.id).sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + ) + : await store.listTasksByBranchGroup(group.id); const memberRows = members.map((task) => ({ taskId: task.id, title: task.title ?? task.description, @@ -54,15 +76,31 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup } const groups = store.listBranchGroups(status ? { status: status as BranchGroup["status"] } : undefined); - const data = await Promise.all(groups.map((group) => serializeGroup(store, group))); + // Fix #8/#9: fetch tasks ONCE and filter per group in memory rather than one + // full scan per group (the old N+1). Membership semantics (incl. legacy + // synthetic-groupId fallback) come from the shared `filterTasksByBranchGroup`. + const allTasks = await store.listTasks({ includeArchived: false, slim: true }); + const data = await Promise.all(groups.map((group) => serializeGroup(store, group, allTasks))); res.json({ groups: data }); }); router.get("/:id", async (req, res) => { const id = String(req.params.id ?? "").trim(); if (!id) throw badRequest("id is required"); - const group = store.getBranchGroup(id); + let group = store.getBranchGroup(id); if (!group) throw notFound("Branch group not found"); + + // Fix #3: reconcile an out-of-band merged/closed PR before serializing so the + // response reflects the real GitHub state. Best-effort — a reconcile failure + // must not break the read; we serialize the (possibly stale) persisted state. + if (group.prNumber != null && group.prState === "open" && options?.reconcileGroupPr) { + try { + group = await options.reconcileGroupPr({ group, projectId: parseProjectId(req) }); + } catch { + group = store.getBranchGroup(id) ?? group; + } + } + res.json({ group: await serializeGroup(store, group) }); }); @@ -130,7 +168,15 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup const group = store.getBranchGroup(id); if (!group) throw notFound("Branch group not found"); - let prState: BranchGroup["prState"] = group.prState === "merged" ? "merged" : "closed"; + // Fix #2: a finalized or already-merged group is terminal and must not be + // flipped to abandoned/closed (mirrors the promote route's gate style). + if (group.status === "finalized" || group.prState === "merged") { + throw badRequest("Branch group is already finalized or merged and cannot be abandoned"); + } + + // The guard above already rejected `prState === "merged"`, so abandon always + // resolves to "closed" unless the GitHub reconcile below reports otherwise. + let prState: BranchGroup["prState"] = "closed"; let prNumber = group.prNumber; let prUrl = group.prUrl; diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index 08c6d6c9a4..e14b8eef83 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -13,7 +13,8 @@ import { createDevServerRouter } from "../dev-server-routes.js"; import type { AiSessionStore } from "../ai-session-store.js"; import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js"; import { createBranchGroupsRouter } from "./register-branch-groups-routes.js"; -import { GitHubClient, closeGroupPullRequest } from "../github.js"; +import { GitHubClient, closeGroupPullRequest, reconcileGroupPullRequest } from "../github.js"; +import { reconcileBranchGroupPr } from "@fusion/engine"; interface IntegratedRoutersOptions { router: Router; @@ -64,10 +65,25 @@ export function registerIntegratedRouters({ if (group.prNumber == null) { return null; } - const client = new GitHubClient(); + // Fix #1: forward the configured token so token-only environments (no gh + // CLI) can still close the PR. + const client = new GitHubClient(options?.githubToken); const result = await closeGroupPullRequest(client, group); return { prNumber: result.prNumber, prUrl: result.prUrl, prState: result.prState }; }, + reconcileGroupPr: async ({ group }) => { + // Fix #3: flip prState when the managed PR was merged/closed out-of-band. + // Build a read-only SyncGroupPrFn over the GitHub client (mirrors the CLI's + // syncGroupPrCallback shape) and delegate persistence to the engine's + // reconcileBranchGroupPr primitive. + const client = new GitHubClient(options?.githubToken); + await reconcileBranchGroupPr({ + store, + group, + syncGroupPr: async ({ group: g }) => reconcileGroupPullRequest(client, g), + }); + return store.getBranchGroup(group.id) ?? group; + }, })); } From e926cac038eea0e9a21e33866a0bc625ef70b0db Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 12:43:59 -0700 Subject: [PATCH 14/21] docs(plan): mark branch-group single-PR plan completed --- .../2026-06-03-001-feat-branch-group-single-pr-flow-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md b/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md index 770571a4fb..f0a23740df 100644 --- a/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md +++ b/docs/plans/2026-06-03-001-feat-branch-group-single-pr-flow-plan.md @@ -1,7 +1,7 @@ --- title: "feat: End-to-end branch-group single managed PR flow (planning + missions)" type: feat -status: active +status: completed date: 2026-06-03 depth: deep --- From f3bc757d227fdbd393e8ed632b17c9ae24484ce4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 13:51:13 -0700 Subject: [PATCH 15/21] fix(FN-branch-group): address PR review feedback (#1357) - abandon route: guard already-abandoned groups (matches CLI) - CLI branch-group list: single task fetch via filterTasksByBranchGroup (N+1) - branch-name validator: git check-ref-format parity (//, dot-segments, .lock, @, @{, trailing /.) - updateBranchGroup: validate renamed branchName too - already-merged-detector: escape regex metachars in git log --grep; non-vacuous prose-mention assertion - group PR callbacks: thread per-project cwd through SyncGroupPrFn/reconcile/github helpers (multi-project correctness) - merger: group-PR sync is fire-and-forget (never blocks merge completion); deterministic test handle - coordinator: sibling PR reuse only when open; reconcile skips member fetch on read-only path; argv-based git calls (no shell) - task-lifecycle: legacy group-PR path links open PRs only; branch probes via execFile argv (injection hardening) - mission/planning: branchContext.groupId only stamped for actual shared-mode members (groupId now optional) - UI: Abandon reachable whenever PR is open (decoupled from completion); promote stays completion-gated - tests: deterministic concurrency gate, real reconcile path in e2e, Surface Enumeration sections --- .../commands/__tests__/branch-group.test.ts | 18 ++++ .../commands/__tests__/task-lifecycle.test.ts | 6 +- packages/cli/src/commands/branch-group.ts | 22 ++++- packages/cli/src/commands/task-lifecycle.ts | 38 +++++-- .../src/__tests__/branch-assignment.test.ts | 20 +++- .../__tests__/branch-group-completion.test.ts | 18 ++++ .../branch-group-entry-point-e2e.test.ts | 16 +++ .../src/__tests__/branch-group-store.test.ts | 13 +++ .../core/src/__tests__/mission-store.test.ts | 6 ++ packages/core/src/branch-assignment.ts | 14 ++- packages/core/src/mission-store.ts | 9 +- packages/core/src/store.ts | 17 +++- packages/core/src/types.ts | 9 +- .../app/components/BranchGroupCard.tsx | 9 +- .../app/components/GroupTaskModal.tsx | 8 +- .../__tests__/BranchGroupCard.test.tsx | 22 +++++ .../__tests__/GroupTaskModal.test.tsx | 23 +++++ .../__tests__/routes-branch-groups.test.ts | 14 +++ .../shared-branch-group-entry-points.test.ts | 23 +++++ packages/dashboard/src/github.ts | 28 ++++-- .../routes/register-branch-groups-routes.ts | 10 +- .../src/routes/register-integrated-routers.ts | 38 ++++++- .../register-planning-subtask-routes.ts | 18 ++-- .../already-merged-detector.real-git.test.ts | 8 ++ .../__tests__/group-merge-coordinator.test.ts | 98 +++++++++++++++++-- .../branch-group-pr-sync.test.ts | 29 +++++- .../branch-group-single-pr-e2e.test.ts | 38 ++++++- .../engine/src/already-merged-detector.ts | 7 +- .../engine/src/group-merge-coordinator.ts | 61 +++++++++--- packages/engine/src/merger.ts | 64 +++++++----- packages/engine/src/project-engine.ts | 12 ++- 31 files changed, 610 insertions(+), 106 deletions(-) diff --git a/packages/cli/src/commands/__tests__/branch-group.test.ts b/packages/cli/src/commands/__tests__/branch-group.test.ts index a9404d0ac5..ea99db76de 100644 --- a/packages/cli/src/commands/__tests__/branch-group.test.ts +++ b/packages/cli/src/commands/__tests__/branch-group.test.ts @@ -52,6 +52,9 @@ function makeStore(group: Record, members: unknown[]) { return { getBranchGroup: vi.fn(() => group), listBranchGroups: vi.fn(() => [group]), + // The list command pre-fetches all tasks once and filters in memory (N+1 fix); + // show/abandon still go through the per-group scan. + listTasks: vi.fn(async () => members), listTasksByBranchGroup: vi.fn(async () => members), updateBranchGroup: vi.fn((_id: string, patch: Record) => ({ ...group, ...patch })), getSettings: vi.fn(async () => ({ @@ -176,6 +179,21 @@ describe("branch-group CLI promote (agent-native parity)", () => { expect(out).toContain("feature/shared"); expect(out).toContain("PR open"); }); + + it("fetches tasks once for the whole list instead of one scan per group (N+1 fix)", async () => { + const groupA = { ...BASE_GROUP, id: "BG-1" }; + const groupB = { ...BASE_GROUP, id: "BG-2", branchName: "feature/other" }; + const store = makeStore(groupA, [LANDED_TASK]); + store.listBranchGroups = vi.fn(() => [groupA, groupB]); + vi.mocked(resolveProject).mockResolvedValue({ + projectId: "p", projectPath: "/tmp/p", projectName: "p", isRegistered: true, store: store as never, + }); + + await runBranchGroupList(); + + expect(store.listTasks).toHaveBeenCalledTimes(1); + expect(store.listTasksByBranchGroup).not.toHaveBeenCalled(); + }); }); describe("branch-group CLI abandon (agent-native parity, Fix #7)", () => { diff --git a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts index 7f6c82b4ff..1941c8ec85 100644 --- a/packages/cli/src/commands/__tests__/task-lifecycle.test.ts +++ b/packages/cli/src/commands/__tests__/task-lifecycle.test.ts @@ -1363,7 +1363,7 @@ describe("syncGroupPrCallback (U6)", () => { 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); - const result = await sync({ group: group as never, members }); + const result = await sync({ cwd: "/tmp/project", group: group as never, members }); expect(result).toEqual({ prNumber: 42, prUrl: "https://github.com/owner/repo/pull/42", prState: "open" }); expect(github.updatePr).toHaveBeenCalledTimes(1); const body = (github.updatePr.mock.calls[0][0] as { body: string }).body; @@ -1377,7 +1377,7 @@ describe("syncGroupPrCallback (U6)", () => { updatePr: vi.fn(), }; const sync = syncGroupPrCallback(github as never); - const result = await sync({ group: group as never, members }); + const result = await sync({ cwd: "/tmp/project", group: group as never, members }); expect(result.prState).toBe("closed"); expect(github.updatePr).not.toHaveBeenCalled(); }); @@ -1385,7 +1385,7 @@ describe("syncGroupPrCallback (U6)", () => { it("throws when the group has no persisted prNumber", async () => { const github = { getPrStatus: vi.fn(), updatePr: vi.fn() }; const sync = syncGroupPrCallback(github as never); - await expect(sync({ 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/); }); }); diff --git a/packages/cli/src/commands/branch-group.ts b/packages/cli/src/commands/branch-group.ts index fe0f3a1312..208cdbf081 100644 --- a/packages/cli/src/commands/branch-group.ts +++ b/packages/cli/src/commands/branch-group.ts @@ -1,4 +1,4 @@ -import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, type BranchGroup, type Settings } from "@fusion/core"; +import { TaskStore, isBranchGroupComplete, isBranchGroupMemberLanded, filterTasksByBranchGroup, type BranchGroup, type Settings, type Task } from "@fusion/core"; import { promoteBranchGroup, resolveIntegrationBranch } from "@fusion/engine"; import { GitHubClient, closeGroupPullRequest } from "@fusion/dashboard"; import { resolveProject } from "../project-context.js"; @@ -43,8 +43,18 @@ async function getBranchGroupContext(projectName?: string): Promise + a.createdAt.localeCompare(b.createdAt), + ) + : await store.listTasksByBranchGroup(group.id); const memberRows = members.map((task) => ({ taskId: task.id, title: task.title ?? task.description, @@ -69,9 +79,13 @@ export async function runBranchGroupList(projectName?: string) { return; } + // Fix #8/#9 parity with the dashboard list route: fetch tasks ONCE and filter + // per group in memory rather than one full scan per group (the old N+1). + const allTasks = await store.listTasks({ includeArchived: false, slim: true }); + console.log(); for (const group of groups) { - const completion = await serializeCompletion(store, group); + const completion = await serializeCompletion(store, group, allTasks); const prState = group.prState === "none" ? "no PR" : `PR ${group.prState}`; const gate = completion.complete ? "complete" : `${completion.landed}/${completion.total}`; console.log(` ${group.id} ${group.branchName} [${group.status}] (${gate}) ${prState}`); diff --git a/packages/cli/src/commands/task-lifecycle.ts b/packages/cli/src/commands/task-lifecycle.ts index d218a80842..dc89f87f4d 100644 --- a/packages/cli/src/commands/task-lifecycle.ts +++ b/packages/cli/src/commands/task-lifecycle.ts @@ -73,9 +73,16 @@ function commandExitCode(err: unknown): number | undefined { return undefined; } -async function gitCommandSucceeds(cwd: string, command: string, missingExitCode: number): Promise { +async function gitCommandSucceeds( + cwd: string, + file: string, + args: string[], + missingExitCode: number, +): Promise { try { - await execAsync(command, { cwd, timeout: 30_000 }); + // No-shell invocation (Fix #11): pass git args as discrete argv entries so a + // crafted branch name (e.g. `$(...)`) can never trigger shell interpretation. + await execFileAsync(file, args, { cwd, timeout: 30_000 }); return true; } catch (err: unknown) { if (commandExitCode(err) === missingExitCode) return false; @@ -87,14 +94,16 @@ async function pushTaskBranchToOrigin(cwd: string, branch: string): Promise, ): SyncGroupPrFn { - return async ({ group, members }) => { + return async ({ cwd, group, members }) => { if (group.prNumber == null) { throw new Error(`syncGroupPr: group ${group.id} has no persisted prNumber`); } - const repo = getCurrentRepo(); + // T4: resolve the repo from the PROJECT cwd, not the process cwd. In a + // multi-project daemon the process cwd is not the project dir, so + // `getCurrentRepo()` (no arg) would resolve the wrong repository. + const repo = getCurrentRepo(cwd); if (!repo) { throw new Error("syncGroupPr: could not determine repository"); } @@ -431,9 +446,10 @@ export async function processPullRequestMergeTask( // FN-5782 contract: shared group members promote via branch_groups.branchName // integration branch, while non-shared tasks keep per-task PR behavior. const isSharedBranchGroupMember = task.branchContext?.assignmentMode === "shared"; + const sharedGroupId = task.branchContext?.groupId; const branchGroup = - isSharedBranchGroupMember && task.branchContext - ? store.getBranchGroup(task.branchContext.groupId) + isSharedBranchGroupMember && sharedGroupId + ? store.getBranchGroup(sharedGroupId) : null; if (isSharedBranchGroupMember && branchGroup) { @@ -460,7 +476,11 @@ export async function processPullRequestMergeTask( commentCount: 0, }; } else { - groupPrInfo = await github.findPrForBranch({ head: branchGroup.branchName, state: "all" }); + // RB#2: only relink an OPEN PR as the live group PR. A closed/merged + // terminal PR for this head branch must NOT be reattached (that reintroduces + // the terminal-PR reuse bug createGroupPrCallback fixed); treat it as + // not-found and fall through to push + createPr for a fresh open PR. + groupPrInfo = await github.findPrForBranch({ head: branchGroup.branchName, state: "open" }); if (!groupPrInfo) { await pushTaskBranchToOrigin(cwd, branchGroup.branchName); try { diff --git a/packages/core/src/__tests__/branch-assignment.test.ts b/packages/core/src/__tests__/branch-assignment.test.ts index 98808c79b8..602f822e9e 100644 --- a/packages/core/src/__tests__/branch-assignment.test.ts +++ b/packages/core/src/__tests__/branch-assignment.test.ts @@ -11,7 +11,15 @@ import { describe("isValidBranchGroupBranchName (Fix #11)", () => { it("accepts legitimate branch names", () => { - for (const name of ["feature/auth-shared", "fusion/fn-123", "main", "release/v1.2.3", "fn/shared", "a"]) { + for (const name of [ + "feature/auth-shared", + "fusion/fn-123", + "main", + "release/v1.2.3", + "release-1.2.3", + "fn/shared", + "a", + ]) { expect(isValidBranchGroupBranchName(name)).toBe(true); } }); @@ -37,6 +45,16 @@ describe("isValidBranchGroupBranchName (Fix #11)", () => { "", " ", "tail.lock", + // git check-ref-format --branch parity (rejected by git): + "foo//bar", // consecutive slashes / empty segment + "foo/.tmp", // segment starting with '.' + ".hidden", // top-level segment starting with '.' + "foo.lock/bar", // segment ending in '.lock' + "@", // the lone '@' + "foo@{bar", // '@{' sequence + "foo/", // trailing slash + "foo.", // trailing dot + "foo..bar", // '..' anywhere ]) { expect(isValidBranchGroupBranchName(name)).toBe(false); } diff --git a/packages/core/src/__tests__/branch-group-completion.test.ts b/packages/core/src/__tests__/branch-group-completion.test.ts index 43634f6e5f..c2cdd311d1 100644 --- a/packages/core/src/__tests__/branch-group-completion.test.ts +++ b/packages/core/src/__tests__/branch-group-completion.test.ts @@ -3,6 +3,24 @@ import { describe, expect, it } from "vitest"; import { isBranchGroupComplete, isBranchGroupMemberLanded } from "../branch-group-completion.js"; import type { BranchGroup, Task } from "../types.js"; +/** + * ## Surface Enumeration + * Surfaces over which this regression spec proves the completion invariant + * (a group is complete iff every member landed onto the group branch via + * branch-group integration): + * - Providers / execution paths: the shared `isBranchGroupMemberLanded` and + * `isBranchGroupComplete` helpers — the single source of truth consumed by the + * branch-group completion gate, the engine merge/promote path, and the + * dashboard/CLI rollup surfaces that decide when the managed PR may promote. + * - Data states: confirmed-vs-unconfirmed merge, matching vs non-matching + * `mergeTargetBranch`, wrong `mergeTargetSource`, missing `mergeDetails`, the + * all-landed group, a partially-landed group, and the empty membership. + * - Shared modules/helpers reusing the logic: any caller routing membership + * through these two helpers inherits the same invariant rather than + * re-deriving "landed" semantics. + * - Breakpoints/platforms: N/A — pure core logic with no UI surface. + */ + const GROUP_BRANCH = "fusion/groups/planning-x"; const group = { branchName: GROUP_BRANCH } as Pick; diff --git a/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts b/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts index 1747a4c405..15ba129464 100644 --- a/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts +++ b/packages/core/src/__tests__/branch-group-entry-point-e2e.test.ts @@ -29,6 +29,22 @@ import { isBranchGroupComplete } from "../branch-group-completion.js"; * * No network and no GitHub: PR creation is the engine-side concern; here we only * assert the membership identity the PR flow consumes. + * + * ## Surface Enumeration + * Surfaces this regression spec asserts the membership-identity invariant across: + * - Providers / execution paths: mission triage entry point (MissionStore → + * TaskStore) stamping the real `BG-` group id into `branchContext.groupId`; + * `listTasksByBranchGroup(group.id)` membership enumeration consumed by + * completion gating and PR rollup. The dashboard planning-route entry point is + * covered by the route-level planning tests; the engine land→PR→sync→abandon + * half is covered by branch-group-single-pr-e2e.test.ts. + * - Data states: members that have/have not landed (drives + * `isBranchGroupComplete`), and the empty-group case before triage. + * - Shared modules/helpers reusing the logic: `branchContext.groupId` + * propagation, `filterTasksByBranchGroup` semantics behind + * `listTasksByBranchGroup`, and per-task working-branch derivation (members + * never adopt the shared branch as their own working branch). + * - Breakpoints/platforms: N/A — this is a core/persistence invariant with no UI. */ function makeTmpDir(): string { diff --git a/packages/core/src/__tests__/branch-group-store.test.ts b/packages/core/src/__tests__/branch-group-store.test.ts index e216fe6652..2779985922 100644 --- a/packages/core/src/__tests__/branch-group-store.test.ts +++ b/packages/core/src/__tests__/branch-group-store.test.ts @@ -98,6 +98,19 @@ describe("TaskStore branch groups", () => { expect(store.createBranchGroup({ sourceType: "planning", sourceId: "PS-good", branchName: "feature/auth-shared" }).branchName).toBe("feature/auth-shared"); }); + it("rejects injection-shaped branch names on updateBranchGroup rename (Fix #11)", () => { + const group = store.createBranchGroup({ sourceType: "planning", sourceId: "PS-rename", branchName: "feature/safe" }); + for (const bad of ["$(touch /tmp/x)", "`cmd`", "feature; rm -rf /", "has space", "a|b"]) { + expect(() => store.updateBranchGroup(group.id, { branchName: bad })).toThrow( + /Invalid branch group branch name/, + ); + } + // The original branch name is left intact after a rejected rename. + expect(store.getBranchGroup(group.id)?.branchName).toBe("feature/safe"); + // A legitimate rename still succeeds. + expect(store.updateBranchGroup(group.id, { branchName: "feature/renamed" }).branchName).toBe("feature/renamed"); + }); + it("finds open branch groups by branch name and ignores closed groups", () => { expect(store.getBranchGroupByBranchName("fn/missing")).toBeNull(); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index abff8b872e..654561f072 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -2216,6 +2216,12 @@ describe("MissionStore", () => { const task = await ts.getTask(triaged.taskId!); expect(task?.branchContext?.assignmentMode).toBe("per-task-derived"); + // Non-shared members must NOT carry a groupId: stamping a synthetic + // `mission:` would let the legacy membership fallback sweep them into a + // shared group later created for the same mission. + expect(task?.branchContext?.groupId).toBeUndefined(); + // And no branch group is ensured for a non-shared mission triage. + expect(ts.getBranchGroupBySource("mission", mission.id)).toBeNull(); }); it("uses mission branchStrategy existing branch when branch options are omitted", async () => { diff --git a/packages/core/src/branch-assignment.ts b/packages/core/src/branch-assignment.ts index 78323b5b61..f85a6ac0c0 100644 --- a/packages/core/src/branch-assignment.ts +++ b/packages/core/src/branch-assignment.ts @@ -30,10 +30,20 @@ export function isValidBranchGroupBranchName(name: string): boolean { if (name.startsWith("-")) return false; if (/\s/.test(name)) return false; // Shell / refspec metacharacters that could escape a single git arg. - if (/[$`;|&<>(){}\[\]"'\\!*?~^:]/.test(name)) return false; + if (/[$`;|&<>(){}[\]"'\\!*?~^:]/.test(name)) return false; if (name.includes("..")) return false; if (name.includes("@{")) return false; - if (name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.endsWith(".lock")) return false; + if (name === "@") return false; // git check-ref-format rejects the lone `@` + if (name.startsWith("/") || name.endsWith("/")) return false; + if (name.endsWith(".") || name.endsWith(".lock")) return false; + if (name.includes("//")) return false; // empty path segments + // Per-segment git-ref rules: no segment may start with `.` or end with + // `.lock`, matching `git check-ref-format --branch`. + for (const segment of name.split("/")) { + if (segment.length === 0) return false; + if (segment.startsWith(".")) return false; + if (segment.endsWith(".lock")) return false; + } const reserved = ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"]; if (reserved.includes(name)) return false; return true; diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index ac2a040912..67393291c7 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -3813,8 +3813,11 @@ export class MissionStore extends EventEmitter { } else { let sharedBranchBaseForMission: string | undefined; // Stamp the real BranchGroup id (BG-…) so listTasksByBranchGroup(group.id) - // resolves members. The group is only created in shared mode below. - let missionGroupId = `mission:${missionId}`; + // resolves members. The group is only ensured (and the id set) in shared + // mode below. Non-shared members get NO groupId — stamping a synthetic + // `mission:` here would let the legacy membership fallback sweep them + // into a shared group later created for the same mission. + let missionGroupId: string | undefined; if (missionId && resolvedAssignmentMode === "shared") { const settings = await this.taskStore.getSettings(); const settingsDefaultBranch = @@ -3845,7 +3848,7 @@ export class MissionStore extends EventEmitter { ...(missionId ? { branchContext: { - groupId: missionGroupId, + ...(missionGroupId ? { groupId: missionGroupId } : {}), source: "mission" as const, assignmentMode: resolvedAssignmentMode, inheritedBaseBranch: resolvedBaseBranch, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 00e58a708c..f2081ecfcd 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -199,14 +199,19 @@ function parseTaskBranchContextFromSourceMetadata(sourceMetadata: Record; - if (typeof candidate.groupId !== "string" || !candidate.groupId.trim()) return undefined; + // groupId is optional: only shared-mode members carry one. A non-shared + // member persists source/assignmentMode without a groupId, so a missing or + // empty groupId must NOT discard the whole context. + const groupId = typeof candidate.groupId === "string" && candidate.groupId.trim() + ? candidate.groupId + : undefined; if (candidate.source !== "planning" && candidate.source !== "mission" && candidate.source !== "new-task") return undefined; if (candidate.assignmentMode !== "shared" && candidate.assignmentMode !== "per-task-derived") return undefined; const inheritedBaseBranch = typeof candidate.inheritedBaseBranch === "string" && candidate.inheritedBaseBranch.trim().length > 0 ? candidate.inheritedBaseBranch.trim() : undefined; return { - groupId: candidate.groupId, + ...(groupId ? { groupId } : {}), source: candidate.source, assignmentMode: candidate.assignmentMode, inheritedBaseBranch, @@ -221,7 +226,7 @@ function withTaskBranchContextInSourceMetadata( return { ...(sourceMetadata ?? {}), [TASK_BRANCH_CONTEXT_METADATA_KEY]: { - groupId: branchContext.groupId, + ...(branchContext.groupId ? { groupId: branchContext.groupId } : {}), source: branchContext.source, assignmentMode: branchContext.assignmentMode, ...(branchContext.inheritedBaseBranch ? { inheritedBaseBranch: branchContext.inheritedBaseBranch } : {}), @@ -4408,6 +4413,12 @@ export class TaskStore extends EventEmitter { if (!current) { throw new Error(`Branch group ${id} not found`); } + // Fix #11: a rename must reject injection-shaped branch names at the same + // persistence boundary as createBranchGroup, otherwise a crafted ref could + // still reach the downstream git/PR flow via an update. + if (patch.branchName !== undefined) { + validateBranchGroupBranchName(patch.branchName); + } const nextStatus = patch.status ?? current.status; const now = Date.now(); const nextClosedAt = patch.closedAt === null diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a0ffed9bb0..6756bb6ff1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1686,7 +1686,14 @@ export type TaskBranchGroupSource = "planning" | "mission" | "new-task"; export type TaskBranchAssignmentMode = "shared" | "per-task-derived"; export interface TaskBranchContext { - groupId: string; + /** + * The owning BranchGroup id (`BG-…`). Only set for shared-mode members that + * were actually assigned to an ensured branch group. Non-shared members + * (per-task-derived) carry branch context (source/assignmentMode) without a + * groupId so they are never swept into a shared group by the legacy + * synthetic-groupId membership fallback (see filterTasksByBranchGroup). + */ + groupId?: string; source: TaskBranchGroupSource; assignmentMode: TaskBranchAssignmentMode; inheritedBaseBranch?: string; diff --git a/packages/dashboard/app/components/BranchGroupCard.tsx b/packages/dashboard/app/components/BranchGroupCard.tsx index 81d618ba8f..b92c0e1314 100644 --- a/packages/dashboard/app/components/BranchGroupCard.tsx +++ b/packages/dashboard/app/components/BranchGroupCard.tsx @@ -160,7 +160,7 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { )} - {!collapsed && complete && group.prState !== "merged" && group.prState !== "closed" && ( + {!collapsed && (complete || group.prState === "open") && group.prState !== "merged" && group.prState !== "closed" && (
{group.prUrl && ( @@ -168,7 +168,10 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) { )} - {group.autoMerge ? ( + {/* Promote (Open PR / Merge group) stays gated on completion: a group + can only be promoted once every member has landed. Abandon below is + reachable whenever the PR is open, even if completion later reverts. */} + {complete && (group.autoMerge ? ( Auto-merge enabled ) : group.prState === "none" ? ( - )} + ))} {group.prState === "open" && ( - )} + ))} {group.prState === "open" && (