feat: implement auto-merge for approved PRs and update workflow documentation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user