FN-5785: add group-level pull request support for branch_groups

Add a single group-level pull request path for branch_groups in task lifecycle workflows.

- add CLI task lifecycle logic to create one pull request per branch group
- update task lifecycle tests to cover grouped pull request behavior and edge cases
- document the pull/merge dashboard behavior update
- add a changeset for @runfusion/fusion patch release

Files changed:
 .changeset/fn-5785-group-pr.md                     |   7 +
 docs/dashboard-guide.md                            |   1 +
 .../src/commands/__tests__/task-lifecycle.test.ts  | 294 ++++++++++++++++++++-
 packages/cli/src/commands/task-lifecycle.ts        | 156 ++++++++++-
 4 files changed, 443 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-5785

Fusion-Task-Lineage: 3100dedd-5b41-4cde-a56c-291410563568
This commit is contained in:
gsxdsm
2026-05-31 13:11:15 -07:00
parent 0c425788cb
commit e2101ea721
4 changed files with 443 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Add single group-level pull request behavior for shared `branch_groups` in PR merge mode.
When tasks share a `branchContext.groupId`, Fusion now opens and tracks one PR for the group's integration branch instead of creating one PR per task. The group PR metadata is written back to `branch_groups` and refreshed from merge-status polling.

View File

@@ -593,6 +593,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass.
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).
- For shared `branch_groups` (tasks with `branchContext.groupId`), PR merge mode opens and tracks one group-level PR from the group integration branch to the project default branch; member tasks share that PR state.
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
### Identifying high-impact blockers

View File

@@ -68,6 +68,8 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
logEntry: vi.fn().mockResolvedValue(undefined),
getActiveMergingTask: vi.fn().mockReturnValue(null),
getBranchGroup: vi.fn().mockReturnValue(null),
updateBranchGroup: vi.fn(),
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
_updates: updates,
});
}
@@ -92,6 +94,8 @@ function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {
logEntry: vi.fn().mockResolvedValue(undefined),
getActiveMergingTask: vi.fn().mockReturnValue(null),
getBranchGroup: vi.fn().mockReturnValue(null),
updateBranchGroup: vi.fn(),
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
_getState: () => state,
});
}
@@ -162,7 +166,7 @@ describe("processPullRequestMergeTask", () => {
expect(pushIdx).toBeLessThan(createIdx);
});
it("uses inherited branch-context merge target when creating a PR", async () => {
it("creates shared-group PR from integration branch into default branch", async () => {
const task: MockTask = {
id: "FN-9002",
title: "test",
@@ -175,7 +179,6 @@ describe("processPullRequestMergeTask", () => {
inheritedBaseBranch: "develop",
},
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task, { baseBranch: "main" });
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
id: "BG-1",
@@ -188,7 +191,11 @@ describe("processPullRequestMergeTask", () => {
createdAt: Date.now(),
updatedAt: Date.now(),
});
execMock.mockImplementation(() => "");
(store.listTasksByBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
execMock.mockImplementation((cmd: string) => {
if (cmd.includes("rev-list --count")) return "1\n";
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
@@ -196,8 +203,6 @@ describe("processPullRequestMergeTask", () => {
number: 7,
url: "https://github.com/x/y/pull/7",
status: "open" as const,
headBranch: branch,
baseBranch: "fusion/groups/planning-abc",
})),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 7, status: "open" as const, url: "https://github.com/x/y/pull/7" },
@@ -209,16 +214,279 @@ describe("processPullRequestMergeTask", () => {
mergePr: vi.fn(),
};
await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({
base: "fusion/groups/planning-abc",
head: "fusion/groups/planning-abc",
base: "main",
}));
expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-1", expect.objectContaining({
prNumber: 7,
prUrl: "https://github.com/x/y/pull/7",
prState: "open",
}));
});
it("routes shared branch-group members through group PR flow", async () => {
const task: MockTask = {
id: "FN-9010",
title: "group member",
description: "desc",
column: "in-review",
branchContext: {
groupId: "BG-1",
source: "planning",
assignmentMode: "shared",
},
};
const store = makeStore(task);
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
id: "BG-1",
sourceType: "planning",
sourceId: "P-1",
branchName: "fusion/groups/p-1",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
});
(store.listTasksByBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
execMock.mockImplementation((cmd: string) => {
if (cmd.includes("rev-list --count")) return "1\n";
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => ({ number: 13, url: "https://github.com/x/y/pull/13", status: "open" as const })),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 13, status: "open" as const, url: "https://github.com/x/y/pull/13" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: "fusion/groups/p-1" }));
expect(store.listTasksByBranchGroup).toHaveBeenCalledWith("BG-1");
});
it("falls back to per-task path when shared group row is missing", async () => {
const task: MockTask = {
id: "FN-9011",
title: "group member",
description: "desc",
column: "in-review",
branchContext: {
groupId: "BG-missing",
source: "planning",
assignmentMode: "shared",
},
};
const store = makeStore(task);
execMock.mockImplementation(() => "");
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => ({
number: 14,
url: "https://github.com/x/y/pull/14",
status: "open" as const,
headBranch: getTaskBranchName(task.id),
})),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 14, status: "open" as const, url: "https://github.com/x/y/pull/14" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: getTaskBranchName(task.id) }));
});
it("does not create duplicate group PR when branch-group PR already exists", async () => {
const task: MockTask = {
id: "FN-9012",
title: "group member",
description: "desc",
column: "in-review",
branchContext: {
groupId: "BG-2",
source: "planning",
assignmentMode: "shared",
},
};
const store = makeStore(task);
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
id: "BG-2",
sourceType: "planning",
sourceId: "P-2",
branchName: "fusion/groups/p-2",
autoMerge: false,
prState: "open",
prNumber: 22,
prUrl: "https://github.com/x/y/pull/22",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
});
(store.listTasksByBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue([task]);
execMock.mockImplementation((cmd: string) => {
if (cmd.includes("rev-list --count")) return "1\n";
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 22, status: "open" as const, url: "https://github.com/x/y/pull/22" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
expect(github.createPr).not.toHaveBeenCalled();
expect(github.getPrMergeStatus).toHaveBeenCalledWith("main", "fusion/groups/p-2", 22);
expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-2", expect.objectContaining({
prNumber: 22,
prUrl: "https://github.com/x/y/pull/22",
prState: "open",
}));
});
it("finalizes branch group and member tasks when shared group PR is already merged", async () => {
const taskA: MockTask = {
id: "FN-9015",
title: "A",
description: "desc A",
column: "in-review",
branchContext: { groupId: "BG-4", source: "planning", assignmentMode: "shared" },
worktree: "/tmp/a",
};
const taskB: MockTask = {
id: "FN-9016",
title: "B",
description: "desc B",
column: "in-review",
branchContext: { groupId: "BG-4", source: "planning", assignmentMode: "shared" },
worktree: "/tmp/b",
};
const store = makeStore(taskA);
(store.getTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => (id === taskB.id ? taskB : taskA));
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
id: "BG-4",
sourceType: "planning",
sourceId: "P-4",
branchName: "fusion/groups/p-4",
autoMerge: false,
prState: "open",
prNumber: 24,
prUrl: "https://github.com/x/y/pull/24",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
});
(store.listTasksByBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
execMock.mockImplementation((cmd: string) => {
if (cmd.includes("rev-list --count")) return "1\n";
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 24, status: "merged" as const, url: "https://github.com/x/y/pull/24" },
reviewDecision: "APPROVED" as const,
checks: [],
mergeReady: true,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(store as never, "/repo", taskA.id, github as never, () => undefined);
expect(result).toBe("merged");
expect(store.moveTask).toHaveBeenCalledWith(taskA.id, "done");
expect(store.moveTask).toHaveBeenCalledWith(taskB.id, "done");
expect(store.updateBranchGroup).toHaveBeenCalledWith("BG-4", expect.objectContaining({
status: "finalized",
prState: "merged",
}));
});
it("excludes empty member branches from group PR body", async () => {
const taskA: MockTask = {
id: "FN-9013",
title: "A",
description: "desc A",
column: "in-review",
branchContext: { groupId: "BG-3", source: "planning", assignmentMode: "shared" },
};
const taskB: MockTask = {
id: "FN-9014",
title: "B",
description: "desc B",
column: "in-review",
branchContext: { groupId: "BG-3", source: "planning", assignmentMode: "shared" },
};
const store = makeStore(taskA);
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
id: "BG-3",
sourceType: "planning",
sourceId: "P-3",
branchName: "fusion/groups/p-3",
autoMerge: false,
prState: "none",
status: "open",
createdAt: Date.now(),
updatedAt: Date.now(),
});
(store.listTasksByBranchGroup as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
execMock.mockImplementation((cmd: string) => {
if (cmd.includes("rev-list --count") && cmd.includes("fn-9014")) return "0\n";
if (cmd.includes("rev-list --count")) return "1\n";
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => ({ number: 23, url: "https://github.com/x/y/pull/23", status: "open" as const })),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 23, status: "open" as const, url: "https://github.com/x/y/pull/23" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(store as never, "/repo", taskA.id, github as never, () => undefined);
expect(github.createPr).toHaveBeenCalledTimes(1);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({
body: expect.stringContaining("FN-9013"),
}));
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({
body: expect.not.stringContaining("FN-9014"),
}));
});

View File

@@ -18,7 +18,7 @@ import { promisify } from "node:util";
const execAsync = promisify(exec);
import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
import { resolveIntegrationBranch } from "@fusion/engine";
import type { WorktreePool } from "@fusion/engine";
@@ -137,6 +137,41 @@ function buildPullRequestBody(task: Pick<TaskDetail, "id" | "description">): str
return [`Automated PR for ${task.id}.`, "", task.description].join("\n");
}
function buildGroupPullRequestTitle(group: Pick<BranchGroup, "id" | "sourceType" | "sourceId">, members: Task[]): string {
return `${group.id}: ${group.sourceType}/${group.sourceId} (${members.length} tasks)`;
}
function buildGroupPullRequestBody(
group: Pick<BranchGroup, "id" | "branchName" | "sourceType" | "sourceId">,
members: Array<Pick<Task, "id" | "title"> & { branchName: string }>,
): string {
const lines = members.map((member) => `- ${member.id}: ${member.title || "(untitled)"}\`${member.branchName}\``);
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");
}
function toBranchGroupPrState(prInfo: PrInfo | null): BranchGroupPrState {
if (!prInfo) return "none";
if (prInfo.status === "merged") return "merged";
if (prInfo.status === "closed") return "closed";
return "open";
}
async function hasCommitsRelativeToBranch(cwd: string, branch: string, baseBranch: string): Promise<boolean> {
try {
const { stdout } = await execAsync(`git rev-list --count "${baseBranch}..${branch}"`, { cwd, timeout: 30_000 });
return Number.parseInt(stdout.trim(), 10) > 0;
} catch {
return false;
}
}
/**
* Clean up worktree and branch artifacts after a successful merge.
* Both operations are best-effort; errors are logged but don't propagate.
@@ -269,9 +304,126 @@ export async function processPullRequestMergeTask(
const settings = await store.getSettings();
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
const projectDefaultBranch = resolvedIntegrationBranch;
const branchGroup = task.branchContext?.assignmentMode === "shared"
// 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 branchGroup = isSharedBranchGroupMember
? store.getBranchGroup(task.branchContext.groupId)
: null;
if (isSharedBranchGroupMember && branchGroup) {
const members = await store.listTasksByBranchGroup(branchGroup.id);
const membersWithCommits: Array<Pick<Task, "id" | "title"> & { branchName: string }> = [];
for (const member of members) {
const memberBranch = getTaskBranchName(member.id);
const hasCommits = await hasCommitsRelativeToBranch(cwd, memberBranch, branchGroup.branchName);
if (hasCommits || member.id === task.id) {
membersWithCommits.push({ id: member.id, title: member.title, branchName: memberBranch });
}
}
await store.updateTask(task.id, { status: "creating-pr" });
let groupPrInfo: PrInfo | null = null;
if (branchGroup.prNumber) {
groupPrInfo = {
number: branchGroup.prNumber,
url: branchGroup.prUrl ?? "",
status: branchGroup.prState === "merged" ? "merged" : branchGroup.prState === "closed" ? "closed" : "open",
};
} else {
groupPrInfo = await github.findPrForBranch({ head: branchGroup.branchName, state: "all" });
if (!groupPrInfo) {
await pushTaskBranchToOrigin(cwd, branchGroup.branchName);
try {
groupPrInfo = await github.createPr({
title: buildGroupPullRequestTitle(branchGroup, members),
body: buildGroupPullRequestBody(branchGroup, membersWithCommits),
head: branchGroup.branchName,
base: projectDefaultBranch,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("No commits between")) {
await store.updateBranchGroup(branchGroup.id, { prState: "none", prNumber: null, prUrl: null });
await store.updateTask(task.id, { status: "failed", error: `No pull request created for ${branchGroup.branchName}: no commits relative to ${projectDefaultBranch}.` });
await store.logEntry(task.id, "No group pull request created", message);
return "skipped";
}
throw err;
}
await store.logEntry(task.id, "Created group PR", `PR #${groupPrInfo.number}: ${groupPrInfo.url}`);
} else {
await store.logEntry(task.id, "Linked existing group PR", `PR #${groupPrInfo.number}: ${groupPrInfo.url}`);
}
}
if (!groupPrInfo) {
throw new Error(`Failed to create or resolve pull request for branch group ${branchGroup.id}`);
}
await store.updateBranchGroup(branchGroup.id, {
prNumber: groupPrInfo.number,
prUrl: groupPrInfo.url,
prState: toBranchGroupPrState(groupPrInfo),
});
const mergeStatus = await github.getPrMergeStatus(projectDefaultBranch, branchGroup.branchName, groupPrInfo.number);
const refreshedPrInfo: PrInfo = {
...groupPrInfo,
...mergeStatus.prInfo,
lastCheckedAt: new Date().toISOString(),
};
await store.updateBranchGroup(branchGroup.id, {
prNumber: refreshedPrInfo.number,
prUrl: refreshedPrInfo.url,
prState: toBranchGroupPrState(refreshedPrInfo),
});
if (mergeStatus.prInfo.status === "merged") {
for (const member of members) {
const memberDetail = await store.getTask(member.id);
await finalizePullRequestMerge(store, cwd, memberDetail, refreshedPrInfo, "Group pull request merged", pool);
}
await store.updateBranchGroup(branchGroup.id, { status: "finalized", prState: "merged" });
return "merged";
}
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
return "waiting";
}
if (!mergeStatus.mergeReady) {
await store.updateTask(task.id, { status: mergeStatus.prInfo.status === "open" ? "awaiting-pr-checks" : null });
return "waiting";
}
const activeMerge = store.getActiveMergingTask(task.id);
if (activeMerge) {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
return "waiting";
}
await store.updateTask(task.id, { status: "merging-pr" });
const mergedPr = await github.mergePr({ number: refreshedPrInfo.number, method: "squash" });
await store.updateBranchGroup(branchGroup.id, {
prNumber: mergedPr.number,
prUrl: mergedPr.url,
prState: toBranchGroupPrState(mergedPr),
});
for (const member of members) {
const memberDetail = await store.getTask(member.id);
await finalizePullRequestMerge(store, cwd, memberDetail, mergedPr, "Group pull request merged", pool);
}
await store.updateBranchGroup(branchGroup.id, { status: "finalized", prState: "merged" });
return "merged";
}
if (isSharedBranchGroupMember && !branchGroup) {
await store.logEntry(task.id, "Branch group missing; falling back to per-task PR path", task.branchContext?.groupId);
}
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
branchGroup,