feat: implement auto-merge for approved PRs and update workflow documentation

This commit is contained in:
Lauren ten Hoor
2026-02-16 14:34:08 +08:00
parent f7aa47102f
commit 25ce06e14f
15 changed files with 208 additions and 163 deletions

View File

@@ -370,10 +370,10 @@ describe("E2E pipeline", () => {
h = await createTestHarness();
});
it("should transition In Review → To Test when PR is merged", async () => {
it("should auto-merge and transition In Review → To Test when PR is approved", async () => {
// Seed issue in "In Review" state
h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] });
h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" });
h.provider.setPrStatus(60, { state: "approved", url: "https://example.com/pr/10" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
@@ -390,6 +390,11 @@ describe("E2E pipeline", () => {
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
// mergePr action should have been called
const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 1);
assert.strictEqual(mergeCalls[0].args.issueId, 60);
// gitPull action should have been attempted
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull");
@@ -417,8 +422,8 @@ describe("E2E pipeline", () => {
it("should handle multiple review issues in one pass", async () => {
h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] });
h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] });
h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" });
h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" });
h.provider.setPrStatus(70, { state: "approved", url: "https://example.com/pr/20" });
h.provider.setPrStatus(71, { state: "approved", url: "https://example.com/pr/21" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
@@ -434,6 +439,40 @@ describe("E2E pipeline", () => {
const issue71 = await h.provider.getIssue(71);
assert.ok(issue70.labels.includes("To Test"));
assert.ok(issue71.labels.includes("To Test"));
// Both should have had mergePr called
const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 2);
});
it("should transition In Review → To Improve when merge fails (conflicts)", async () => {
h.provider.seedIssue({ iid: 65, title: "Conflicting PR", labels: ["In Review"] });
h.provider.setPrStatus(65, { state: "approved", url: "https://example.com/pr/15" });
h.provider.mergePrFailures.add(65);
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
workflow: DEFAULT_WORKFLOW,
provider: h.provider,
repoPath: "/tmp/test-repo",
});
assert.strictEqual(transitions, 1);
// Issue should have moved to "To Improve" (not "To Test")
const issue = await h.provider.getIssue(65);
assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test");
// mergePr should have been attempted
const mergeCalls = h.provider.callsTo("mergePr");
assert.strictEqual(mergeCalls.length, 1);
// gitPull should NOT have run (aborted before git pull)
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.strictEqual(gitCmds.length, 0, "Should NOT have run git pull after merge failure");
});
});
@@ -551,8 +590,8 @@ describe("E2E pipeline", () => {
let issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`);
// 4. PR gets merged — review pass picks it up
h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" });
// 4. PR gets approved — review pass picks it up and auto-merges
h.provider.setPrStatus(200, { state: "approved", url: "https://example.com/pr/50" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,

View File

@@ -91,6 +91,11 @@ export async function executeCompletion(opts: {
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
} }
break;
case Action.MERGE_PR:
try { await provider.mergePr(issueId); } catch (err) {
auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
}
break;
}
}

View File

@@ -50,8 +50,10 @@ export async function reviewPass(opts: {
if (!conditionMet) continue;
// Find the success transition (first non-BLOCKED event)
const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED);
// Find the success transition (first event that isn't BLOCKED or MERGE_FAILED)
const successEvent = Object.keys(state.on).find(
(e) => e !== WorkflowEvent.BLOCKED && e !== WorkflowEvent.MERGE_FAILED,
);
if (!successEvent) continue;
const transition = state.on[successEvent];
@@ -60,10 +62,41 @@ export async function reviewPass(opts: {
const targetState = workflow.states[targetKey];
if (!targetState) continue;
// Execute transition actions
// Execute transition actions — mergePr is critical (aborts on failure)
let aborted = false;
if (actions) {
for (const action of actions) {
switch (action) {
case Action.MERGE_PR:
try {
await provider.mergePr(issue.iid);
} catch (err) {
// Merge failed → fire MERGE_FAILED transition (developer fixes conflicts)
await auditLog(workspaceDir, "review_merge_failed", {
groupId,
issueId: issue.iid,
from: state.label,
error: (err as Error).message ?? String(err),
});
const failedTransition = state.on[WorkflowEvent.MERGE_FAILED];
if (failedTransition) {
const failedKey = typeof failedTransition === "string" ? failedTransition : failedTransition.target;
const failedState = workflow.states[failedKey];
if (failedState) {
await provider.transitionLabel(issue.iid, state.label, failedState.label);
await auditLog(workspaceDir, "review_transition", {
groupId,
issueId: issue.iid,
from: state.label,
to: failedState.label,
reason: "merge_failed",
});
transitions++;
}
}
aborted = true;
}
break;
case Action.GIT_PULL:
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
break;
@@ -74,9 +107,12 @@ export async function reviewPass(opts: {
await provider.reopenIssue(issue.iid);
break;
}
if (aborted) break;
}
}
if (aborted) continue; // skip normal transition, move to next issue
// Transition label
await provider.transitionLabel(issue.iid, state.label, targetState.label);