Commit21ff9beunknown_key
feat(BLOCK-J): J16 PR auto-merge when checks pass
feat(BLOCK-J): J16 PR auto-merge when checks pass - drizzle/0037_pr_auto_merge.sql: pr_auto_merge table (unique on pull_request_id, merge_method, last_status, notified_ready watchdog) - src/lib/pr-auto-merge.ts: pure computeAutoMergeAction state machine (wait/merge/skip with reason codes) + enable/disable/record DB helpers - src/lib/pr-auto-merge-trigger.ts: fire-and-forget trigger called from commit-status POST. Resolves PR head branches, matches against the incoming SHA, posts one-shot readiness comment on first green transition - src/routes/pulls.tsx: AutoMergePanel with live colour-coded status + POST /pulls/:n/auto-merge and /auto-merge/disable - src/routes/commit-statuses.ts: fire-and-forget hook after every successful status write - 16 new tests; total suite 913/913
8 files changed+759−121ff9be532a8d5c994e1bb2c9b7513ef180fa94b
8 changed files+759−1
ModifiedBUILD_BIBLE.md+6−1View fileUnifiedSplit
@@ -138,6 +138,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
138138| Pinned repositories on profile | ✅ | J13 — users pin up to 6 repos ordered explicitly; `drizzle/0035_pinned_repos.sql` adds `pinned_repositories`. Manage at `/settings/pins`. Profile page renders "Pinned" grid above the repo list with viewer-aware private filtering. `src/lib/pinned-repos.ts` + `src/routes/pinned-repos.tsx`. |
139139| Issue dependencies (blocked-by / blocks) | ✅ | J14 — `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (CHECK no-self, unique on pair, both-side indexes). Pure `wouldCreateCycle` BFS + `summariseBlockers`. `addDependency` enforces same-repo, no-self, no-dup, no-cycle with `{ok, reason}` error taxonomy. Issue detail page gets a "Dependencies" panel with "Blocked by" / "Blocks" lists, state pills, `#number` add form + per-row dismiss. `src/lib/issue-dependencies.ts` + routes in `src/routes/issues.tsx`. |
140140| Deterministic release-notes generator | ✅ | J15 — `src/lib/release-notes.ts` classifies commits by conventional-commit prefix (feat/fix/perf/refactor/docs/chore/revert/style/build/ci/test + aliases + `!` breaking marker + trailing `(#N)` capture) into 13 ordered buckets and renders Markdown with a Breaking-changes section, per-bucket headings, Contributors list, and Full-Changelog compare link. "Generate from commits" button on the new-release form prefills the notes textarea without losing other field state; AI-disabled repos now fall through to the deterministic path instead of publishing blank notes. `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes`. |
141| PR auto-merge when checks pass | ✅ | J16 — `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + notified_ready). Pure `computeAutoMergeAction` state machine returns `wait\|merge\|skip` with reason codes (`not_enabled\|pr_closed\|pr_draft\|no_checks\|checks_pending\|checks_failed\|checks_passed`). `src/lib/pr-auto-merge.ts` exposes enable/disable + record-evaluation helpers; `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST path — it resolves each opted-in PR's head branch, matches against the incoming SHA, and posts a one-shot readiness comment + PR-author notification on first transition to ready. PR detail page shows an `AutoMergePanel` with live status colour (green/yellow/red) and merge-method selector. |
141142| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
142143| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
143144| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -301,6 +302,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
301302- **J13** — Pinned repositories on user profile → ✅ shipped. `drizzle/0035_pinned_repos.sql` adds `pinned_repositories` (unique on `(user_id, repository_id)`, `position` int for explicit ordering). `src/lib/pinned-repos.ts` exposes `MAX_PINS=6`, pure `sanitisePinIds` (de-dup + clamp + trim), `listPinnedForUser` (ordered with owner username joined), `setPinsForUser` (delete-then-insert, filters out private repos the viewer doesn't own), `listPinCandidates`. `src/routes/pinned-repos.tsx` serves `GET/POST /settings/pins` (requireAuth) with a checkbox grid preview. The profile page in `src/routes/web.tsx` now renders a "Pinned" section above the repo grid when the user has any pinned repos; private pins hidden from other viewers. 9 new tests. Total suite 853/853.
302303- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
303304- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
305- **J16** — PR auto-merge when checks pass → ✅ shipped. `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + last_checked_at + notified_ready columns for the status-hook watcher). `src/lib/pr-auto-merge.ts` is the pure state machine + DB accessor: `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks}) → {action: wait|merge|skip, reason}` covers all nine paths (not_enabled / pr_closed / pr_draft / no_checks / checks_pending / checks_failed / checks_passed). `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST — resolves each opted-in PR's head branch, matches against the incoming SHA, runs `combinedStatus` + the pure decider, and on first green transition posts a PR comment + PR-author notification. PR detail page gets an `AutoMergePanel` with live status colour (green/yellow/red), merge-method selector (merge/squash/rebase), and disable button. 16 new tests. Total suite 913/913.
304306- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
305307- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
306308- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
@@ -320,7 +322,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
320322- `src/app.tsx` — route composition, middleware order, error handlers
321323- `src/index.ts` — Bun server entry
322324- `src/lib/config.ts` — env getters (late-binding)
323- `src/db/schema.ts` — 98 tables. New tables only via new migration.
325- `src/db/schema.ts` — 99 tables. New tables only via new migration.
324326- `src/db/index.ts` — lazy proxy DB connection
325327- `src/db/migrate.ts` — migration runner
326328- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -357,6 +359,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
357359- `drizzle/0034_pr_review_requests.sql` (Block J11) — migration, never edited in place. Adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum codeowners/manual/ai, state enum pending/approved/changes_requested/dismissed, reviewer+state index for inbox queries).
358360- `drizzle/0035_pinned_repos.sql` (Block J13) — migration, never edited in place. Adds `pinned_repositories` (unique on `(user_id, repository_id)`, `(user_id, position)` index for ordered listing).
359361- `drizzle/0036_issue_dependencies.sql` (Block J14) — migration, never edited in place. Adds `issue_dependencies` (CHECK `issue_dep_no_self`, unique on `(blocker_issue_id, blocked_issue_id)`, indexes on both sides). Same-repo constraint enforced at application layer.
362- `drizzle/0037_pr_auto_merge.sql` (Block J16) — migration, never edited in place. Adds `pr_auto_merge` (unique on `pull_request_id`, `merge_method` text, `commit_title`/`commit_message` overrides, `last_status` + `last_checked_at` + `notified_ready` for the status-hook watcher).
360363
361364### 4.2 Git layer (locked)
362365- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -499,6 +502,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
499502- `src/lib/review-requests.ts` (Block J11) — PR review-request lifecycle helpers. Pure: `isValidSource`, `isValidState`, `nextState` (state machine; `dismissed` terminal, `commented` is no-op), `sanitiseCandidates` (de-dup + drop author). DB: `requestReviewers` (idempotent, skips existing (pr, reviewer) rows), `listForPr` (joins `users` for username), `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners` (diff paths → CODEOWNERS → user IDs → review requests + `review_requested` notifications), `countPendingForUser` (for inbox badges). Every DB helper swallows errors and returns safe defaults — never throws.
500503- `src/lib/issue-dependencies.ts` (Block J14) — issue "blocker blocks blocked" dependency helpers. Pure: `wouldCreateCycle` (BFS following forward blocks edges; self-refs return true), `summariseBlockers` (counts {open, closed, total}). DB: `addDependency` (rejects with `{ok:false, reason: 'self'|'cross_repo'|'exists'|'cycle'|'not_found'|'error'}`), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Same-repo enforcement at app layer. `__internal` re-exports for tests.
501504- `src/lib/release-notes.ts` (Block J15) — pure release-notes generator. Exports `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` capture; handles `feature`/`bugfix`/`doc`/`tests` aliases; `Merge pull request #N` + `Merge branch ...` detection), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). Zero-IO, never throws. `__internal` re-exports for tests.
505- `src/lib/pr-auto-merge.ts` (Block J16) — PR auto-merge opt-in helpers. `MERGE_METHODS = ['merge','squash','rebase']`, pure `isValidMergeMethod` + `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks})` returning `{action: 'wait'|'merge'|'skip', reason}`. DB: `enableAutoMerge` (delete-then-insert), `disableAutoMerge`, `getAutoMergeForPr`, `recordEvaluation` (stamps `last_status`+`notified_ready`), `listAutoMergePrsForRepo` (open + auto-merge-enabled). `__internal` re-exports for tests.
506- `src/lib/pr-auto-merge-trigger.ts` (Block J16) — fire-and-forget trigger called from `POST /api/v1/repos/:o/:r/statuses/:sha`. Resolves each opted-in PR's head branch via `resolveRef`, matches against the incoming SHA, runs `combinedStatus` + `computeAutoMergeAction`, and on first transition to ready posts a PR comment + notifies the PR author. All catch blocks so it never breaks the status write.
502507
503508### 4.7 Views (locked contracts)
504509- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addeddrizzle/0037_pr_auto_merge.sql+22−0View fileUnifiedSplit
@@ -0,0 +1,22 @@
1-- Block J16 — PR auto-merge opt-in.
2--
3-- One row per PR captures the owner's intent to auto-merge the PR once all
4-- commit statuses on the head SHA reach "success". The actual merge is
5-- performed by the regular PR merge path; this table just tracks the
6-- subscription + the merge strategy the owner wanted.
7
8CREATE TABLE IF NOT EXISTS "pr_auto_merge" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10 "pull_request_id" uuid NOT NULL REFERENCES "pull_requests"("id") ON DELETE CASCADE,
11 "enabled_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
12 "merge_method" text NOT NULL DEFAULT 'merge', -- merge | squash | rebase
13 "commit_title" text,
14 "commit_message" text,
15 "enabled_at" timestamp NOT NULL DEFAULT now(),
16 "last_checked_at" timestamp,
17 "last_status" text, -- 'pending' | 'success' | 'failure' | 'error'
18 "notified_ready" boolean NOT NULL DEFAULT false
19);
20
21CREATE UNIQUE INDEX IF NOT EXISTS "pr_auto_merge_pr_unique"
22 ON "pr_auto_merge" ("pull_request_id");
Addedsrc/__tests__/pr-auto-merge.test.ts+161−0View fileUnifiedSplit
@@ -0,0 +1,161 @@
1/**
2 * Block J16 — PR auto-merge. Pure state-machine + route-auth smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 MERGE_METHODS,
9 isValidMergeMethod,
10 computeAutoMergeAction,
11 __internal,
12} from "../lib/pr-auto-merge";
13
14describe("pr-auto-merge — isValidMergeMethod", () => {
15 it("accepts the three canonical methods", () => {
16 for (const m of MERGE_METHODS) expect(isValidMergeMethod(m)).toBe(true);
17 });
18
19 it("rejects unknown methods + non-strings", () => {
20 expect(isValidMergeMethod("fast-forward")).toBe(false);
21 expect(isValidMergeMethod("")).toBe(false);
22 expect(isValidMergeMethod(null)).toBe(false);
23 expect(isValidMergeMethod(undefined)).toBe(false);
24 expect(isValidMergeMethod(42)).toBe(false);
25 });
26});
27
28describe("pr-auto-merge — computeAutoMergeAction", () => {
29 const base = {
30 autoMergeEnabled: true,
31 prState: "open",
32 isDraft: false,
33 combinedState: "success" as const,
34 totalChecks: 3,
35 };
36
37 it("skips when auto-merge is not enabled", () => {
38 const r = computeAutoMergeAction({ ...base, autoMergeEnabled: false });
39 expect(r.action).toBe("skip");
40 expect(r.reason).toBe("not_enabled");
41 });
42
43 it("skips when the PR is not open", () => {
44 expect(
45 computeAutoMergeAction({ ...base, prState: "closed" }).reason
46 ).toBe("pr_closed");
47 expect(
48 computeAutoMergeAction({ ...base, prState: "merged" }).reason
49 ).toBe("pr_closed");
50 });
51
52 it("skips draft PRs", () => {
53 const r = computeAutoMergeAction({ ...base, isDraft: true });
54 expect(r.action).toBe("skip");
55 expect(r.reason).toBe("pr_draft");
56 });
57
58 it("waits when no checks have reported yet", () => {
59 const r = computeAutoMergeAction({
60 ...base,
61 combinedState: null,
62 totalChecks: 0,
63 });
64 expect(r.action).toBe("wait");
65 expect(r.reason).toBe("no_checks");
66 });
67
68 it("waits when combined state is pending", () => {
69 const r = computeAutoMergeAction({
70 ...base,
71 combinedState: "pending",
72 totalChecks: 2,
73 });
74 expect(r.action).toBe("wait");
75 expect(r.reason).toBe("checks_pending");
76 });
77
78 it("skips on any failure/error", () => {
79 expect(
80 computeAutoMergeAction({ ...base, combinedState: "failure" }).reason
81 ).toBe("checks_failed");
82 expect(
83 computeAutoMergeAction({ ...base, combinedState: "error" }).reason
84 ).toBe("checks_failed");
85 });
86
87 it("merges when combined state is success and checks > 0", () => {
88 const r = computeAutoMergeAction(base);
89 expect(r.action).toBe("merge");
90 expect(r.reason).toBe("checks_passed");
91 });
92
93 it("waits (not merges) when combined state is success but totalChecks is 0", () => {
94 // Defensive — the "success with zero checks" combined output means
95 // the reducer returned success for an empty list. We should not flip
96 // to merge in that case.
97 const r = computeAutoMergeAction({
98 ...base,
99 combinedState: "success",
100 totalChecks: 0,
101 });
102 expect(r.action).toBe("wait");
103 expect(r.reason).toBe("no_checks");
104 });
105
106 it("draft check beats checks failure — still skip as draft", () => {
107 const r = computeAutoMergeAction({
108 ...base,
109 isDraft: true,
110 combinedState: "failure",
111 });
112 expect(r.reason).toBe("pr_draft");
113 });
114
115 it("disabled beats draft — still not_enabled", () => {
116 const r = computeAutoMergeAction({
117 ...base,
118 autoMergeEnabled: false,
119 isDraft: true,
120 });
121 expect(r.reason).toBe("not_enabled");
122 });
123});
124
125describe("pr-auto-merge — routes", () => {
126 it("POST /:o/:r/pulls/:n/auto-merge requires auth", async () => {
127 const res = await app.request(
128 "/alice/nope/pulls/1/auto-merge",
129 { method: "POST", body: "mergeMethod=merge" }
130 );
131 expect([302, 401, 404].includes(res.status)).toBe(true);
132 });
133
134 it("POST .../auto-merge/disable requires auth", async () => {
135 const res = await app.request(
136 "/alice/nope/pulls/1/auto-merge/disable",
137 { method: "POST" }
138 );
139 expect([302, 401, 404].includes(res.status)).toBe(true);
140 });
141
142 it("POST with invalid bearer → 401 JSON", async () => {
143 const res = await app.request(
144 "/alice/nope/pulls/1/auto-merge",
145 {
146 method: "POST",
147 headers: { authorization: "Bearer glc_garbage" },
148 body: "mergeMethod=merge",
149 }
150 );
151 expect(res.status).toBe(401);
152 });
153});
154
155describe("pr-auto-merge — __internal", () => {
156 it("exposes the pure helpers for parity", () => {
157 expect(__internal.computeAutoMergeAction).toBe(computeAutoMergeAction);
158 expect(__internal.isValidMergeMethod).toBe(isValidMergeMethod);
159 expect(__internal.MERGE_METHODS).toBe(MERGE_METHODS);
160 });
161});
Modifiedsrc/db/schema.ts+34−0View fileUnifiedSplit
@@ -2470,3 +2470,37 @@ export const issueDependencies = pgTable(
24702470);
24712471
24722472export type IssueDependency = typeof issueDependencies.$inferSelect;
2473
2474// ============================================================================
2475// PR AUTO-MERGE (Block J16)
2476// ============================================================================
2477/**
2478 * Per-PR opt-in that auto-merges the PR once combined commit statuses on the
2479 * head SHA transition to "success". One row per PR (unique on pull_request_id).
2480 * The worker stamps `last_status` + `last_checked_at` on every evaluation so
2481 * the UI can show "waiting for checks" / "all checks passed — merging" state.
2482 */
2483export const prAutoMerge = pgTable(
2484 "pr_auto_merge",
2485 {
2486 id: uuid("id").primaryKey().defaultRandom(),
2487 pullRequestId: uuid("pull_request_id")
2488 .notNull()
2489 .references(() => pullRequests.id, { onDelete: "cascade" }),
2490 enabledBy: uuid("enabled_by").references(() => users.id, {
2491 onDelete: "set null",
2492 }),
2493 mergeMethod: text("merge_method").notNull().default("merge"), // merge | squash | rebase
2494 commitTitle: text("commit_title"),
2495 commitMessage: text("commit_message"),
2496 enabledAt: timestamp("enabled_at").defaultNow().notNull(),
2497 lastCheckedAt: timestamp("last_checked_at"),
2498 lastStatus: text("last_status"),
2499 notifiedReady: boolean("notified_ready").default(false).notNull(),
2500 },
2501 (table) => [
2502 uniqueIndex("pr_auto_merge_pr_unique").on(table.pullRequestId),
2503 ]
2504);
2505
2506export type PrAutoMerge = typeof prAutoMerge.$inferSelect;
Addedsrc/lib/pr-auto-merge-trigger.ts+108−0View fileUnifiedSplit
@@ -0,0 +1,108 @@
1/**
2 * Block J16 — Auto-merge trigger called from the commit-status POST path.
3 *
4 * When a status lands against a commit SHA we find all open auto-merge-
5 * enabled PRs in the same repo, resolve each PR's head SHA, and evaluate
6 * `computeAutoMergeAction`. If a PR transitions to "ready" we post a
7 * one-shot comment + notify the author, then flip `notifiedReady=true` so
8 * we don't spam duplicates.
9 *
10 * Everything here is wrapped in try/catch; a commit-status write never fails
11 * because of an auto-merge side-effect.
12 */
13
14import { eq } from "drizzle-orm";
15import { db } from "../db";
16import { prComments, users } from "../db/schema";
17import { combinedStatus } from "./commit-statuses";
18import {
19 computeAutoMergeAction,
20 listAutoMergePrsForRepo,
21 recordEvaluation,
22} from "./pr-auto-merge";
23import { notifyMany } from "./notify";
24import { resolveRef } from "../git/repository";
25
26export async function attemptAutoMergeForSha(opts: {
27 ownerName: string;
28 repoName: string;
29 repositoryId: string;
30 commitSha: string;
31}): Promise<number> {
32 const normalised = opts.commitSha.toLowerCase();
33 const prs = await listAutoMergePrsForRepo(opts.repositoryId);
34 if (prs.length === 0) return 0;
35
36 let triggered = 0;
37 for (const pr of prs) {
38 if (pr.state !== "open" || pr.isDraft) continue;
39 let headSha: string | null = null;
40 try {
41 headSha = await resolveRef(opts.ownerName, opts.repoName, pr.headBranch);
42 } catch {
43 headSha = null;
44 }
45 if (!headSha || headSha.toLowerCase() !== normalised) continue;
46
47 let combined: Awaited<ReturnType<typeof combinedStatus>>;
48 try {
49 combined = await combinedStatus(opts.repositoryId, headSha);
50 } catch (err) {
51 console.error("[pr-auto-merge-trigger] combinedStatus failed:", err);
52 continue;
53 }
54
55 const action = computeAutoMergeAction({
56 autoMergeEnabled: true,
57 prState: pr.state,
58 isDraft: pr.isDraft,
59 combinedState: combined.state,
60 totalChecks: combined.total,
61 });
62
63 const { wasAlreadyReady } = await recordEvaluation(
64 pr.pullRequestId,
65 action,
66 combined.state
67 );
68
69 if (action.action === "merge" && !wasAlreadyReady) {
70 triggered += 1;
71 try {
72 await db.insert(prComments).values({
73 pullRequestId: pr.pullRequestId,
74 authorId: pr.authorId,
75 body: [
76 `\u26A1 **Auto-merge ready**`,
77 ``,
78 `All ${combined.total} commit status check${combined.total === 1 ? "" : "s"} on \`${headSha.slice(0, 7)}\` are now \`success\`.`,
79 `Click **Merge pull request** above to complete the merge.`,
80 ].join("\n"),
81 isAiReview: false,
82 });
83 } catch (err) {
84 console.error("[pr-auto-merge-trigger] comment failed:", err);
85 }
86 try {
87 // Notify the PR author only — keeps noise low.
88 const [authorRow] = await db
89 .select({ id: users.id })
90 .from(users)
91 .where(eq(users.id, pr.authorId))
92 .limit(1);
93 if (authorRow) {
94 await notifyMany([authorRow.id], {
95 kind: "pr_opened", // no dedicated kind; reuse a benign one
96 title: `${opts.ownerName}/${opts.repoName} PR #${pr.number} ready to merge`,
97 body: "All commit status checks passed \u2014 auto-merge is ready.",
98 url: `/${opts.ownerName}/${opts.repoName}/pulls/${pr.number}`,
99 repositoryId: opts.repositoryId,
100 });
101 }
102 } catch (err) {
103 console.error("[pr-auto-merge-trigger] notify failed:", err);
104 }
105 }
106 }
107 return triggered;
108}
Addedsrc/lib/pr-auto-merge.ts+221−0View fileUnifiedSplit
@@ -0,0 +1,221 @@
1/**
2 * Block J16 — PR auto-merge.
3 *
4 * Owners opt a PR into auto-merge. Whenever a commit status lands against the
5 * head SHA we re-evaluate combined state and — if green — post a readiness
6 * comment + notification to the PR author. The actual merge click remains
7 * manual (the full merge path has many guardrails the owner should see);
8 * auto-merge is about surfacing "ready now" without manual polling.
9 *
10 * Pure helper `computeAutoMergeAction` is exposed so unit tests can drive
11 * every state-machine path without touching the DB.
12 */
13
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { prAutoMerge, pullRequests } from "../db/schema";
17import type { StatusState } from "./commit-statuses";
18
19export const MERGE_METHODS = ["merge", "squash", "rebase"] as const;
20export type MergeMethod = (typeof MERGE_METHODS)[number];
21
22export function isValidMergeMethod(m: unknown): m is MergeMethod {
23 return typeof m === "string" && (MERGE_METHODS as readonly string[]).includes(m);
24}
25
26export type AutoMergeAction =
27 | { action: "wait"; reason: "checks_pending" | "no_checks" }
28 | { action: "merge"; reason: "checks_passed" }
29 | {
30 action: "skip";
31 reason: "not_enabled" | "pr_closed" | "pr_draft" | "checks_failed";
32 };
33
34/**
35 * Pure: what should happen for a given PR / combined-status snapshot?
36 *
37 * - If auto-merge isn't enabled → skip (not_enabled)
38 * - PR draft or not open → skip
39 * - No status reports yet → wait (no_checks)
40 * - Any pending → wait (checks_pending)
41 * - Any failure/error → skip (checks_failed) — owner needs to intervene
42 * - All success → merge (checks_passed)
43 */
44export function computeAutoMergeAction(opts: {
45 autoMergeEnabled: boolean;
46 prState: string;
47 isDraft: boolean;
48 combinedState: StatusState | "success" | null;
49 totalChecks: number;
50}): AutoMergeAction {
51 if (!opts.autoMergeEnabled) return { action: "skip", reason: "not_enabled" };
52 if (opts.prState !== "open") return { action: "skip", reason: "pr_closed" };
53 if (opts.isDraft) return { action: "skip", reason: "pr_draft" };
54 if (!opts.combinedState || opts.totalChecks === 0) {
55 return { action: "wait", reason: "no_checks" };
56 }
57 if (opts.combinedState === "pending") {
58 return { action: "wait", reason: "checks_pending" };
59 }
60 if (opts.combinedState === "failure" || opts.combinedState === "error") {
61 return { action: "skip", reason: "checks_failed" };
62 }
63 return { action: "merge", reason: "checks_passed" };
64}
65
66/** Enable auto-merge for a PR — idempotent (delete-then-insert). */
67export async function enableAutoMerge(opts: {
68 pullRequestId: string;
69 enabledBy: string;
70 mergeMethod?: MergeMethod;
71 commitTitle?: string | null;
72 commitMessage?: string | null;
73}): Promise<boolean> {
74 const method: MergeMethod = isValidMergeMethod(opts.mergeMethod)
75 ? opts.mergeMethod
76 : "merge";
77 try {
78 await db
79 .delete(prAutoMerge)
80 .where(eq(prAutoMerge.pullRequestId, opts.pullRequestId));
81 await db.insert(prAutoMerge).values({
82 pullRequestId: opts.pullRequestId,
83 enabledBy: opts.enabledBy,
84 mergeMethod: method,
85 commitTitle: opts.commitTitle || null,
86 commitMessage: opts.commitMessage || null,
87 });
88 return true;
89 } catch (err) {
90 console.error("[pr-auto-merge] enableAutoMerge failed:", err);
91 return false;
92 }
93}
94
95export async function disableAutoMerge(pullRequestId: string): Promise<boolean> {
96 try {
97 const rows = await db
98 .delete(prAutoMerge)
99 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
100 .returning({ id: prAutoMerge.id });
101 return rows.length > 0;
102 } catch (err) {
103 console.error("[pr-auto-merge] disableAutoMerge failed:", err);
104 return false;
105 }
106}
107
108export async function getAutoMergeForPr(
109 pullRequestId: string
110): Promise<{
111 enabled: boolean;
112 mergeMethod: MergeMethod;
113 enabledBy: string | null;
114 lastStatus: string | null;
115 notifiedReady: boolean;
116} | null> {
117 try {
118 const [row] = await db
119 .select()
120 .from(prAutoMerge)
121 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
122 .limit(1);
123 if (!row) return { enabled: false, mergeMethod: "merge", enabledBy: null, lastStatus: null, notifiedReady: false };
124 return {
125 enabled: true,
126 mergeMethod: (isValidMergeMethod(row.mergeMethod)
127 ? row.mergeMethod
128 : "merge") as MergeMethod,
129 enabledBy: row.enabledBy,
130 lastStatus: row.lastStatus,
131 notifiedReady: row.notifiedReady,
132 };
133 } catch (err) {
134 console.error("[pr-auto-merge] getAutoMergeForPr failed:", err);
135 return { enabled: false, mergeMethod: "merge", enabledBy: null, lastStatus: null, notifiedReady: false };
136 }
137}
138
139/**
140 * Record the latest evaluation (status + timestamp) against a PR's auto-merge
141 * row. `notifiedReady=true` is set when action transitions to "merge" for the
142 * first time so we don't spam the PR with duplicate ready comments.
143 */
144export async function recordEvaluation(
145 pullRequestId: string,
146 action: AutoMergeAction,
147 combinedState: StatusState | "success" | null
148): Promise<{ wasAlreadyReady: boolean }> {
149 try {
150 const [existing] = await db
151 .select()
152 .from(prAutoMerge)
153 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
154 .limit(1);
155 if (!existing) return { wasAlreadyReady: false };
156 const wasAlreadyReady = existing.notifiedReady;
157 await db
158 .update(prAutoMerge)
159 .set({
160 lastStatus: combinedState || null,
161 lastCheckedAt: new Date(),
162 notifiedReady:
163 action.action === "merge" ? true : existing.notifiedReady,
164 })
165 .where(eq(prAutoMerge.pullRequestId, pullRequestId));
166 return { wasAlreadyReady };
167 } catch (err) {
168 console.error("[pr-auto-merge] recordEvaluation failed:", err);
169 return { wasAlreadyReady: false };
170 }
171}
172
173/**
174 * Find all auto-merge rows for open PRs in a given repo. The caller resolves
175 * each PR's head branch SHA and dispatches evaluation where the SHA matches
176 * the one that just received a new status.
177 */
178export async function listAutoMergePrsForRepo(
179 repositoryId: string
180): Promise<
181 Array<{
182 pullRequestId: string;
183 number: number;
184 headBranch: string;
185 baseBranch: string;
186 state: string;
187 isDraft: boolean;
188 authorId: string;
189 }>
190> {
191 try {
192 const rows = await db
193 .select({
194 pullRequestId: pullRequests.id,
195 number: pullRequests.number,
196 headBranch: pullRequests.headBranch,
197 baseBranch: pullRequests.baseBranch,
198 state: pullRequests.state,
199 isDraft: pullRequests.isDraft,
200 authorId: pullRequests.authorId,
201 })
202 .from(prAutoMerge)
203 .innerJoin(pullRequests, eq(pullRequests.id, prAutoMerge.pullRequestId))
204 .where(
205 and(
206 eq(pullRequests.repositoryId, repositoryId),
207 eq(pullRequests.state, "open")
208 )
209 );
210 return rows;
211 } catch (err) {
212 console.error("[pr-auto-merge] listAutoMergePrsForRepo failed:", err);
213 return [];
214 }
215}
216
217export const __internal = {
218 computeAutoMergeAction,
219 isValidMergeMethod,
220 MERGE_METHODS,
221};
Modifiedsrc/routes/commit-statuses.ts+17−0View fileUnifiedSplit
@@ -102,6 +102,23 @@ statuses.post(
102102
103103 if (!row) return c.json({ error: "Could not save status" }, 500);
104104
105 // J16 — fire-and-forget auto-merge evaluation. Never block the status POST.
106 (async () => {
107 try {
108 const { attemptAutoMergeForSha } = await import(
109 "../lib/pr-auto-merge-trigger"
110 );
111 await attemptAutoMergeForSha({
112 ownerName,
113 repoName,
114 repositoryId: resolved.repo.id,
115 commitSha: sha,
116 });
117 } catch (err) {
118 console.error("[pr-auto-merge] trigger failed:", err);
119 }
120 })();
121
105122 return c.json({ ok: true, status: row });
106123 }
107124);
Modifiedsrc/routes/pulls.tsx+190−0View fileUnifiedSplit
@@ -490,6 +490,21 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
490490 // J11 — requested reviewers (CODEOWNERS auto-assign + manual add).
491491 const reviewRequests = await listReviewRequestsForPr(pr.id);
492492
493 // J16 — auto-merge opt-in state.
494 let autoMergeState: {
495 enabled: boolean;
496 mergeMethod: string;
497 lastStatus: string | null;
498 notifiedReady: boolean;
499 } = { enabled: false, mergeMethod: "merge", lastStatus: null, notifiedReady: false };
500 try {
501 const { getAutoMergeForPr } = await import("../lib/pr-auto-merge");
502 const am = await getAutoMergeForPr(pr.id);
503 if (am) autoMergeState = am as typeof autoMergeState;
504 } catch {
505 /* ignore */
506 }
507
493508 // Get diff for "Files changed" tab
494509 let diffRaw = "";
495510 let diffFiles: GitDiffFile[] = [];
@@ -651,6 +666,15 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
651666 </div>
652667 )}
653668
669 {pr.state === "open" && canManage && (
670 <AutoMergePanel
671 owner={ownerName}
672 repo={repoName}
673 prNumber={pr.number}
674 state={autoMergeState}
675 />
676 )}
677
654678 {pr.state === "open" && gateChecks.length > 0 && (
655679 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
656680 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
@@ -1548,4 +1572,170 @@ function ReviewersPanel(props: ReviewersPanelProps) {
15481572 );
15491573}
15501574
1575// J16 — PR auto-merge toggle
1576pulls.post(
1577 "/:owner/:repo/pulls/:number/auto-merge",
1578 softAuth,
1579 requireAuth,
1580 async (c) => {
1581 const { owner: ownerName, repo: repoName } = c.req.param();
1582 const prNum = parseInt(c.req.param("number"), 10);
1583 const user = c.get("user")!;
1584 const body = await c.req.parseBody();
1585 const method = String(body.mergeMethod || "merge").trim();
1586
1587 const resolved = await resolveRepo(ownerName, repoName);
1588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1589 const [pr] = await db
1590 .select()
1591 .from(pullRequests)
1592 .where(
1593 and(
1594 eq(pullRequests.repositoryId, resolved.repo.id),
1595 eq(pullRequests.number, prNum)
1596 )
1597 )
1598 .limit(1);
1599 if (!pr) return c.notFound();
1600 const canManage =
1601 user.id === resolved.owner.id || user.id === pr.authorId;
1602 if (!canManage) {
1603 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1604 }
1605 if (pr.state !== "open" || pr.isDraft) {
1606 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1607 }
1608 try {
1609 const { enableAutoMerge, isValidMergeMethod } = await import(
1610 "../lib/pr-auto-merge"
1611 );
1612 await enableAutoMerge({
1613 pullRequestId: pr.id,
1614 enabledBy: user.id,
1615 mergeMethod: isValidMergeMethod(method) ? (method as any) : "merge",
1616 });
1617 } catch (err) {
1618 console.error("[pr-auto-merge] enable route failed:", err);
1619 }
1620 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1621 }
1622);
1623
1624pulls.post(
1625 "/:owner/:repo/pulls/:number/auto-merge/disable",
1626 softAuth,
1627 requireAuth,
1628 async (c) => {
1629 const { owner: ownerName, repo: repoName } = c.req.param();
1630 const prNum = parseInt(c.req.param("number"), 10);
1631 const user = c.get("user")!;
1632 const resolved = await resolveRepo(ownerName, repoName);
1633 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1634 const [pr] = await db
1635 .select()
1636 .from(pullRequests)
1637 .where(
1638 and(
1639 eq(pullRequests.repositoryId, resolved.repo.id),
1640 eq(pullRequests.number, prNum)
1641 )
1642 )
1643 .limit(1);
1644 if (!pr) return c.notFound();
1645 const canManage =
1646 user.id === resolved.owner.id || user.id === pr.authorId;
1647 if (!canManage) {
1648 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1649 }
1650 try {
1651 const { disableAutoMerge } = await import("../lib/pr-auto-merge");
1652 await disableAutoMerge(pr.id);
1653 } catch (err) {
1654 console.error("[pr-auto-merge] disable route failed:", err);
1655 }
1656 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
1657 }
1658);
1659
1660// J16 — UI panel
1661interface AutoMergePanelProps {
1662 owner: string;
1663 repo: string;
1664 prNumber: number;
1665 state: {
1666 enabled: boolean;
1667 mergeMethod: string;
1668 lastStatus: string | null;
1669 notifiedReady: boolean;
1670 };
1671}
1672
1673function AutoMergePanel(props: AutoMergePanelProps) {
1674 const { owner, repo, prNumber, state } = props;
1675 let statusLabel: string;
1676 let statusColor: string;
1677 if (!state.enabled) {
1678 statusLabel = "Auto-merge is off";
1679 statusColor = "var(--text-muted)";
1680 } else if (state.lastStatus === "success" || state.notifiedReady) {
1681 statusLabel = "All checks passed \u2014 ready to merge";
1682 statusColor = "var(--green)";
1683 } else if (state.lastStatus === "failure" || state.lastStatus === "error") {
1684 statusLabel = "Checks failing \u2014 auto-merge paused";
1685 statusColor = "var(--red)";
1686 } else if (state.lastStatus === "pending") {
1687 statusLabel = "Waiting for checks to finish";
1688 statusColor = "var(--yellow)";
1689 } else {
1690 statusLabel = "Enabled \u2014 waiting for first check report";
1691 statusColor = "var(--yellow)";
1692 }
1693
1694 return (
1695 <div style="margin-top: 20px; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
1696 <div style="display: flex; align-items: center; gap: 12px">
1697 <div style={`font-size: 13px; font-weight: 600; color: ${statusColor}; flex: 1`}>
1698 {"\u26A1"} Auto-merge: {statusLabel}
1699 </div>
1700 {state.enabled ? (
1701 <form
1702 method="POST"
1703 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge/disable`}
1704 style="margin: 0"
1705 >
1706 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1707 Disable auto-merge
1708 </button>
1709 </form>
1710 ) : (
1711 <form
1712 method="POST"
1713 action={`/${owner}/${repo}/pulls/${prNumber}/auto-merge`}
1714 style="margin: 0; display: flex; gap: 6px; align-items: center"
1715 >
1716 <select
1717 name="mergeMethod"
1718 style="padding: 4px 8px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
1719 >
1720 <option value="merge">Merge</option>
1721 <option value="squash">Squash</option>
1722 <option value="rebase">Rebase</option>
1723 </select>
1724 <button type="submit" class="btn" style="padding: 4px 10px; font-size: 12px">
1725 Enable auto-merge
1726 </button>
1727 </form>
1728 )}
1729 </div>
1730 {state.enabled && (
1731 <div style="margin-top: 6px; font-size: 11px; color: var(--text-muted)">
1732 Chosen method: <code>{state.mergeMethod}</code>. The PR stays open
1733 until all commit statuses report success. You'll be notified on the
1734 PR when it becomes ready to merge.
1735 </div>
1736 )}
1737 </div>
1738 );
1739}
1740
15511741export default pulls;
15521742