Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ After linking/installing the package bin, you can also use `adhd-ai ...` directl
2. ADHD.ai plans the task.
3. ADHD.ai implements code changes and updates PR context.
4. ADHD.ai runs review/testing and loops on failures until `done` or `blocked`.
5. Review-only automations approve completed PRs with `COMPLEXITY_SCORE < 5`; scores `>= 5` trigger a human approval email.
5. Review-only automations squash-merge completed PRs with `COMPLEXITY_SCORE < 5`; scores `>= 5` trigger a human approval email.

## Configuration Notes

Expand Down
2 changes: 1 addition & 1 deletion docs/PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Planning output should remain concise and implementation-focused, including:

## Hourly Review Automation Example

Use an hourly review-only automation job to re-run PR review/testing in parallel across resumable runs and approve completed PRs whose complexity score is below the human approval threshold:
Use an hourly review-only automation job to re-run PR review/testing in parallel across resumable runs and squash-merge completed PRs whose complexity score is below the human approval threshold:

```ts
export default {
Expand Down
2 changes: 1 addition & 1 deletion skills/piv-plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You are the planning agent.
- Required routing contract for all plans:
- `COMPLEXITY: SIMPLE|COMPLEX`
- `COMPLEXITY_SCORE: 0..10` (integer)
- `< 5`: completed PR can be approved by the review cron
- `< 5`: completed PR can be squash-merged by the review automation
- `>= 5`: completed PR requires human approval by email
- `ISSUE_REFINEMENT_JSON: {"title":"...","description":"..."}`
- Both fields must be non-empty strings.
Expand Down
25 changes: 14 additions & 11 deletions src/core/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
approvePullRequest,
commentOnPr,
createDraftPrFromWorktree,
findOpenPullRequestForIssue,
issueBranchName,
markPrReadyForReview,
prepareImplementationBranch,
squashMergePullRequest,
updateDraftPrFromWorktree,
} from "../services/github";
import { LinearClient, sortIssuesByPriority } from "../services/linear";
Expand Down Expand Up @@ -984,7 +984,7 @@ async function executeIssue(
const agent = createAgentAdapter(config);

if (options.reviewOnly && state.stage === "done") {
await handleDoneReviewApprovalStage(config, notifications, linear, state);
await handleDoneReviewMergeStage(config, notifications, linear, state);
return;
}

Expand Down Expand Up @@ -1342,7 +1342,7 @@ async function handleReviewTestingStage(
);
}

async function handleDoneReviewApprovalStage(
async function handleDoneReviewMergeStage(
config: ResolvedProjectConfig,
notifications: ResolvedNotificationConfig,
linear: LinearClient,
Expand All @@ -1353,7 +1353,7 @@ async function handleDoneReviewApprovalStage(
}

const score = state.complexityScore ?? DEFAULT_PLANNER_COMPLEXITY_SCORE;
if (!shouldApprovePullRequestForComplexityScore(score)) {
if (!shouldSquashMergePullRequestForComplexityScore(score)) {
const reason = `Planning complexity score ${score}/10 requires human PR approval (threshold >= ${HUMAN_REVIEW_COMPLEXITY_THRESHOLD}).`;
if (!state.humanReviewNotifiedAt) {
await linear.comment(
Expand All @@ -1373,14 +1373,17 @@ async function handleDoneReviewApprovalStage(
return;
}

const approved = await safeApprovePullRequest(config, state);
if (!approved) {
const merged = await safeSquashMergePullRequest(config, state);
if (!merged) {
return;
}

state.pullRequestApprovedAt = new Date().toISOString();
await saveRunState(config.workspacePath, state);
await linear.comment(state.issue.id, "PR approved after completed review.");
await linear.comment(
state.issue.id,
"PR squash-merged after completed review.",
);
}

export function normalizeFailedReviewBugs(
Expand Down Expand Up @@ -1674,7 +1677,7 @@ export function resolveReviewModeForComplexityScore(
return complexityScore < HUMAN_REVIEW_COMPLEXITY_THRESHOLD ? "bot" : "human";
}

export function shouldApprovePullRequestForComplexityScore(
export function shouldSquashMergePullRequestForComplexityScore(
complexityScore: number,
): boolean {
return complexityScore < HUMAN_REVIEW_COMPLEXITY_THRESHOLD;
Expand Down Expand Up @@ -2000,7 +2003,7 @@ async function safePrComment(
}
}

async function safeApprovePullRequest(
async function safeSquashMergePullRequest(
config: ResolvedProjectConfig,
state: RunState,
): Promise<boolean> {
Expand All @@ -2013,11 +2016,11 @@ async function safeApprovePullRequest(
pr: state.pullRequest.url ?? state.pullRequest.number,
});
try {
return await approvePullRequest(config, state.pullRequest);
return await squashMergePullRequest(config, state.pullRequest);
} catch (error) {
runLogger.error(
{ err: normalizeError(error) },
"Failed to approve GitHub PR",
"Failed to squash-merge GitHub PR",
);
return false;
}
Expand Down
21 changes: 15 additions & 6 deletions src/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,10 @@ export async function markPrReadyForReview(
return true;
}

export async function approvePullRequest(
export async function squashMergePullRequest(
config: ResolvedProjectConfig,
pr: PullRequestRef,
body = "ADHD.ai review/testing passed for this PR.",
body = "ADHD.ai review/testing passed; squash merging this PR.",
deps?: {
runCommand?: typeof runCommand;
assertCommandOk?: typeof assertCommandOk;
Expand All @@ -330,20 +330,29 @@ export async function approvePullRequest(
return false;
}
if (!pr.url && !pr.number) {
throw new Error("PR URL or number is required to approve PR");
throw new Error("PR URL or number is required to merge PR");
}
const target = pr.url ?? String(pr.number);

await ensureAuth(config);
await withRetries("gh pr review --approve", async () => {
await withRetries("gh pr merge --squash", async () => {
const result = await commandRunner(
"gh",
["pr", "review", target, "--approve", "--body", body],
[
"pr",
"merge",
target,
"--squash",
"--subject",
pr.title,
"--body",
body,
],
{
cwd: config.executionPath,
},
);
assertOk("gh", ["pr", "review", target, "--approve"], result);
assertOk("gh", ["pr", "merge", target, "--squash"], result);
});
return true;
}
Expand Down
24 changes: 13 additions & 11 deletions tests/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { describe, expect, it, mock } from "bun:test";
import type { ResolvedProjectConfig } from "../src/core/types";
import {
approvePullRequest,
buildBugIssueBody,
commentOnPr,
createDraftPrFromWorktree,
ensureGhAuth,
findOpenPullRequestForIssue,
issueBranchName,
squashMergePullRequest,
} from "../src/services/github";
import type { CommandResult } from "../src/utils/shell";

Expand Down Expand Up @@ -129,8 +129,8 @@ describe("commentOnPr", () => {
});
});

describe("approvePullRequest", () => {
it("approves a pull request through gh", async () => {
describe("squashMergePullRequest", () => {
it("squash-merges a pull request through gh", async () => {
const calls: string[][] = [];
const runCommand = mock(
async (_command: string, args: string[]): Promise<CommandResult> => {
Expand All @@ -139,30 +139,32 @@ describe("approvePullRequest", () => {
},
);

const approved = await approvePullRequest(
const merged = await squashMergePullRequest(
createProjectConfig(),
{
url: "https://github.com/acme/repo/pull/77",
branch: "codex/eng-42",
title: "ENG-42",
},
"Approved by ADHD.ai.",
"Merged by ADHD.ai.",
{
runCommand,
assertCommandOk: assertOk,
ensureGhAuth: async () => {},
},
);

expect(approved).toBe(true);
expect(merged).toBe(true);
expect(calls).toEqual([
[
"pr",
"review",
"merge",
"https://github.com/acme/repo/pull/77",
"--approve",
"--squash",
"--subject",
"ENG-42",
"--body",
"Approved by ADHD.ai.",
"Merged by ADHD.ai.",
],
]);
});
Expand All @@ -174,7 +176,7 @@ describe("approvePullRequest", () => {
const config = createProjectConfig();
config.dryRun = true;

const approved = await approvePullRequest(
const merged = await squashMergePullRequest(
config,
{
number: 77,
Expand All @@ -189,7 +191,7 @@ describe("approvePullRequest", () => {
},
);

expect(approved).toBe(false);
expect(merged).toBe(false);
expect(runCommand).not.toHaveBeenCalled();
});
});
Expand Down
26 changes: 13 additions & 13 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ import {
selectIssueQueueForCycle,
selectReviewOnlyIssueKeys,
selectStaleRunIssueKeys,
shouldApprovePullRequestForComplexityScore,
shouldRetryRunStage,
shouldSkipReviewOnlyRunState,
shouldSquashMergePullRequestForComplexityScore,
shouldStopPolling,
withExecutionPathLock,
} from "../src/core/workflow";
Expand Down Expand Up @@ -375,7 +375,7 @@ describe("isReviewOnlyExecutableStage", () => {
});

describe("isReviewOnlyEligibleRunState", () => {
it("includes done states with unapproved PRs", () => {
it("includes done states with unmerged PRs", () => {
const state = createRunState("ENG-90", "done", Date.now());
state.pullRequest = {
branch: "codex/eng-90",
Expand All @@ -386,14 +386,14 @@ describe("isReviewOnlyEligibleRunState", () => {
expect(isReviewOnlyEligibleRunState(state)).toBe(true);
});

it("excludes done states after approval or human notification", () => {
const approved = createRunState("ENG-91", "done", Date.now());
approved.pullRequest = {
it("excludes done states after automated PR action or human notification", () => {
const completed = createRunState("ENG-91", "done", Date.now());
completed.pullRequest = {
branch: "codex/eng-91",
title: "ENG-91",
url: "https://github.com/acme/repo/pull/91",
};
approved.pullRequestApprovedAt = "2026-05-07T12:00:00.000Z";
completed.pullRequestApprovedAt = "2026-05-07T12:00:00.000Z";

const notified = createRunState("ENG-92", "done", Date.now());
notified.pullRequest = {
Expand All @@ -403,7 +403,7 @@ describe("isReviewOnlyEligibleRunState", () => {
};
notified.humanReviewNotifiedAt = "2026-05-07T12:00:00.000Z";

expect(isReviewOnlyEligibleRunState(approved)).toBe(false);
expect(isReviewOnlyEligibleRunState(completed)).toBe(false);
expect(isReviewOnlyEligibleRunState(notified)).toBe(false);
});
});
Expand Down Expand Up @@ -1135,12 +1135,12 @@ describe("resolveReviewModeForComplexityScore", () => {
});
});

describe("shouldApprovePullRequestForComplexityScore", () => {
it("allows PR approval only below the human review threshold", () => {
expect(shouldApprovePullRequestForComplexityScore(0)).toBe(true);
expect(shouldApprovePullRequestForComplexityScore(4)).toBe(true);
expect(shouldApprovePullRequestForComplexityScore(5)).toBe(false);
expect(shouldApprovePullRequestForComplexityScore(10)).toBe(false);
describe("shouldSquashMergePullRequestForComplexityScore", () => {
it("allows automated PR merge only below the human review threshold", () => {
expect(shouldSquashMergePullRequestForComplexityScore(0)).toBe(true);
expect(shouldSquashMergePullRequestForComplexityScore(4)).toBe(true);
expect(shouldSquashMergePullRequestForComplexityScore(5)).toBe(false);
expect(shouldSquashMergePullRequestForComplexityScore(10)).toBe(false);
});
});

Expand Down
Loading