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:
7
.changeset/fn-5785-group-pr.md
Normal file
7
.changeset/fn-5785-group-pr.md
Normal 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.
|
||||||
@@ -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.
|
- 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.
|
- **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).
|
- 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.
|
- 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
|
### Identifying high-impact blockers
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
|||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||||
getBranchGroup: vi.fn().mockReturnValue(null),
|
getBranchGroup: vi.fn().mockReturnValue(null),
|
||||||
|
updateBranchGroup: vi.fn(),
|
||||||
|
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
|
||||||
_updates: updates,
|
_updates: updates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,8 @@ function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {
|
|||||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||||
getBranchGroup: vi.fn().mockReturnValue(null),
|
getBranchGroup: vi.fn().mockReturnValue(null),
|
||||||
|
updateBranchGroup: vi.fn(),
|
||||||
|
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
|
||||||
_getState: () => state,
|
_getState: () => state,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,7 +166,7 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(pushIdx).toBeLessThan(createIdx);
|
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 = {
|
const task: MockTask = {
|
||||||
id: "FN-9002",
|
id: "FN-9002",
|
||||||
title: "test",
|
title: "test",
|
||||||
@@ -175,7 +179,6 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
inheritedBaseBranch: "develop",
|
inheritedBaseBranch: "develop",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const branch = getTaskBranchName(task.id);
|
|
||||||
const store = makeStore(task, { baseBranch: "main" });
|
const store = makeStore(task, { baseBranch: "main" });
|
||||||
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
|
(store.getBranchGroup as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
id: "BG-1",
|
id: "BG-1",
|
||||||
@@ -188,7 +191,11 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: 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 = {
|
const github = {
|
||||||
findPrForBranch: vi.fn(async () => null),
|
findPrForBranch: vi.fn(async () => null),
|
||||||
@@ -196,8 +203,6 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
number: 7,
|
number: 7,
|
||||||
url: "https://github.com/x/y/pull/7",
|
url: "https://github.com/x/y/pull/7",
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
headBranch: branch,
|
|
||||||
baseBranch: "fusion/groups/planning-abc",
|
|
||||||
})),
|
})),
|
||||||
getPrMergeStatus: vi.fn(async () => ({
|
getPrMergeStatus: vi.fn(async () => ({
|
||||||
prInfo: { number: 7, status: "open" as const, url: "https://github.com/x/y/pull/7" },
|
prInfo: { number: 7, status: "open" as const, url: "https://github.com/x/y/pull/7" },
|
||||||
@@ -209,16 +214,279 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
mergePr: vi.fn(),
|
mergePr: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await processPullRequestMergeTask(
|
await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);
|
||||||
store as never,
|
|
||||||
"/repo",
|
|
||||||
task.id,
|
|
||||||
github as never,
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({
|
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"),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { promisify } from "node:util";
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import { resolveTaskMergeTarget } 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 { resolveIntegrationBranch } from "@fusion/engine";
|
||||||
import type { WorktreePool } 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");
|
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.
|
* Clean up worktree and branch artifacts after a successful merge.
|
||||||
* Both operations are best-effort; errors are logged but don't propagate.
|
* 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 settings = await store.getSettings();
|
||||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
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)
|
? store.getBranchGroup(task.branchContext.groupId)
|
||||||
: null;
|
: 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, {
|
const mergeTarget = resolveTaskMergeTarget(task, {
|
||||||
projectDefaultBranch,
|
projectDefaultBranch,
|
||||||
branchGroup,
|
branchGroup,
|
||||||
|
|||||||
Reference in New Issue
Block a user