Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit869ab6funknown_key

Merge pull request #65 from ccantynz-alt/claude/review-readme-docs-ulqPK

Merge pull request #65 from ccantynz-alt/claude/review-readme-docs-ulqPK

Claude/review readme docs ulq pk
CC LABS App committed on May 13, 2026Parents: 1f28d38 7c22521
12 files changed+40417869ab6f118529a037d31b0a1a2e9133c0452321c
12 changed files+4041−7
ModifiedBUILD_BIBLE.md+22−3View fileUnifiedSplit
139139| AI Issue triage | ✅ | 2026-04-30 — On every issue create the system fires `triggerIssueTriage` (`src/lib/issue-triage.ts`) which calls `triageIssue()` from `src/lib/ai-generators.ts`. Posts a "## AI Triage" issue comment with one-line summary, priority, suggested labels filtered against the repo's existing labels, and a "Possible duplicate of #N" callout when confidence is high. Idempotent via `ISSUE_TRIAGE_MARKER`. Suggestions only. The `triageIssue` helper was on disk but unused until 2026-04-30. |
140140| AI commit message suggestion | ✅ | 2026-04-30 — "Suggest with AI" button on the file-edit form. `POST /:owner/:repo/ai/commit-message` (write-access gated) reads the on-disk blob via `getBlob`, builds a minimal Old/New diff representation, calls `generateCommitMessage()` from `src/lib/ai-generators.ts`, caps to one line + 100 chars. The helper was on disk but unused until 2026-04-30. |
141141| Workflow secret substitution | ✅ | 2026-04-30 — `${{ secrets.NAME }}` references inside step `run:` are now substituted at execution time. Pure helper `substituteSecrets(template, secrets)` in `src/lib/workflow-secrets.ts` (strict `[A-Z_][A-Z0-9_]*` grammar; missing names left intact as a loud failure signal; uses `hasOwnProperty` to block prototype-pollution). The v1 runner (`src/lib/workflow-runner.ts`) loads the per-run secrets map once via `loadSecretsContext(repositoryId)` and passes it into every `runStep` call. Was a stub until 2026-04-30. |
142| Model Context Protocol server | ✅ | 2026-04-30 — `GET /mcp` discovery + `POST /mcp` JSON-RPC 2.0. Lets Claude Desktop / Code / Cursor drive Gluecron natively. v1 tools (read-only, public-only): `gluecron_repo_search`, `gluecron_repo_read_file`, `gluecron_repo_list_issues`, `gluecron_repo_explain_codebase`. Single + batched + notification envelopes supported. Move #9 from `docs/STRATEGY.md`. Implementation: `src/lib/mcp.ts` + `src/lib/mcp-tools.ts` + `src/routes/mcp.ts`. |
142| Model Context Protocol server | ✅ | 2026-04-30 — `GET /mcp` discovery + `POST /mcp` JSON-RPC 2.0. Lets Claude Desktop / Code / Cursor drive Gluecron natively. v1 tools (read-only, public-only): `gluecron_repo_search`, `gluecron_repo_read_file`, `gluecron_repo_list_issues`, `gluecron_repo_explain_codebase`. Single + batched + notification envelopes supported. Move #9 from `docs/STRATEGY.md`. Implementation: `src/lib/mcp.ts` + `src/lib/mcp-tools.ts` + `src/routes/mcp.ts`. **2026-05-13 (Block K1):** added 10 write tools so Claude can drive PRs/issues end-to-end without GitHub round-trip: `gluecron_create_issue`, `gluecron_comment_issue`, `gluecron_close_issue`, `gluecron_reopen_issue`, `gluecron_create_pr`, `gluecron_get_pr`, `gluecron_list_prs`, `gluecron_comment_pr`, `gluecron_merge_pr`, `gluecron_close_pr`. All gate on `ctx.userId !== null` (rejected via `ERR_INVALID_PARAMS`) + repo write-access via `src/middleware/repo-access.ts` (rejected via `ERR_METHOD_NOT_FOUND` — same shape as read tools so private repos don't leak). `gluecron_merge_pr` re-uses the manual-merge `matchProtection`/`evaluateProtection`/`listRequiredChecks`/`passingCheckNames`/`runAllGateChecks`/`mergeWithAutoResolve` + J7 close-keywords pipeline. HTTP route still uses `softAuth` so original 5 read tools keep working anonymously. |
143| AI auto-merge (gated) | ✅ | K2 — `src/lib/auto-merge.ts` + `drizzle/0040_branch_protection_auto_merge.sql` + settings checkbox on `src/routes/gates.tsx`. Pure `decideAutoMerge` + DB orchestrator `evaluateAutoMerge`. Default-deny: requires `enable_auto_merge=true` on a matching branch_protection rule AND every gate the manual merge path enforces. AI-approval heuristic over `AI_REVIEW_MARKER` comments. Optional size cap. Side-effect-free; the K3 autopilot is the only caller. |
144| AI-driven autopilot | ✅ | K3 — extends `src/lib/autopilot.ts` with two new tick tasks: `auto-merge-sweep` (calls K2, merges eligible PRs, audits `auto_merge.merged` / `auto_merge.merge_failed`, posts `<!-- gluecron:auto-merge:v1 -->` marker) and `ai-build-from-issues` (finds open issues labelled `ai:build`, dispatches via spec-to-pr, posts `<!-- gluecron:ai-build:v1 -->` marker for idempotency). `src/lib/pr-merge.ts` factored shared `performMerge(prId)` helper. `src/lib/ai-build-tasks.ts` orchestrates the AI-build sweep with 5 DI'd collaborators. Both tasks respect `AUTOPILOT_DISABLED=1` and skip archived repos. |
143145| App-bot push auth (`ghi_` install tokens) | ✅ | 2026-04-30 — `git-receive-pack` Authorization header now resolves `ghi_*` install tokens to the synthetic `users` row that `marketplace.ts createApp` creates alongside the bot. Bots get identity for the audit log + protected-tag checks; authorisation (collaborator grants) is still separate. Legacy bots created before the back-fill landed remain anonymous. Move #4 from `docs/STRATEGY.md`. |
144146| 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`. |
145147| 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. |
326328- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
327329- **H2** — GitHub Apps equivalent (bot identities + installation tokens) → ✅ shipped. Same schema as H1: every app gets a `<slug>[bot]` row in `app_bots`. `generateBearerToken()` produces `ghi_`-prefixed bearers; `hashBearer` (sha256) is the only form persisted. `verifyInstallToken(token)` returns `{installation, app, botUsername, permissions}` or `null` (checks revoked/expired/uninstalled/suspended). Permission vocabulary: `contents:read/write`, `issues:read/write`, `pulls:read/write`, `checks:read/write`, `deployments:read/write`, `metadata:read``hasPermission` implements write→read implication.
328330
331### BLOCK K — Claude-native development loop
332The "feature description → live site, no human merge step for the routine cases" loop. Three sub-blocks shipped together (PR #62) on 2026-05-13.
333- **K1** — MCP write surface → ✅ shipped. Extends `src/lib/mcp-tools.ts` with 10 write tools (`gluecron_create_issue` / `_comment_issue` / `_close_issue` / `_reopen_issue` / `_create_pr` / `_get_pr` / `_list_prs` / `_comment_pr` / `_merge_pr` / `_close_pr`). Per-tool auth gate: `ctx.userId === null``ERR_INVALID_PARAMS`. Write-access gate via `src/middleware/repo-access.ts``ERR_METHOD_NOT_FOUND` on denial (same shape as read tools so private repos don't leak). `merge_pr` re-uses the entire manual-merge gating pipeline. HTTP route still on `softAuth` so the original 5 read tools keep working anonymously. `src/__tests__/mcp-write.test.ts` — 34 tests covering happy path + auth gate + write-access gate per tool. Mock-isolation hardened via spread-from-real, defensive `users` defaults, and `afterAll` row resets so the file no longer poisons downstream tests in the same `bun test` run.
334- **K2** — AI-gated auto-merge → ✅ shipped. `src/lib/auto-merge.ts` (399 lines), `drizzle/0040_branch_protection_auto_merge.sql`, settings checkbox on `src/routes/gates.tsx`. Pure `decideAutoMerge(facts)` + DB orchestrator `evaluateAutoMerge(ctx, opts?)`. Decision rules (all must hold): matching branch_protection rule with `enable_auto_merge=true`, PR not draft, manual-merge `evaluateProtection` passes, AI approval per `aiCommentLooksApproved` when `requireAiApproval=true`, optional size cap. 19 tests covering each block reason + the `blocking-non-empty iff merge=false` invariant. Side-effect-free; only K3 calls it.
335- **K3** — AI-driven autopilot → ✅ shipped. Two new tick tasks on the locked autopilot ticker: `auto-merge-sweep` (calls K2 every tick, performs merges via `performMerge`, audits `auto_merge.evaluated`/`auto_merge.merged`/`auto_merge.merge_failed`, posts `<!-- gluecron:auto-merge:v1 -->` marker) and `ai-build-from-issues` (open issues labelled `ai:build` → spec-to-PR dispatch, posts `<!-- gluecron:ai-build:v1 -->` marker). Shared `src/lib/pr-merge.ts performMerge(prId)` factored from `routes/pulls.tsx`. `src/lib/ai-build-tasks.ts runAiBuildTaskOnce(deps)` orchestrator with 5 DI'd collaborators. 14 tests covering draft skip, archived skip, marker dedup, AUTOPILOT_DISABLED short-circuit, AI-key absence short-circuit, and "merge fn called exactly once per merge=true PR".
336
329337---
330338
331339## 4. LOCKED BLOCKS (DO NOT UNDO)
375383- `drizzle/0036_invite_token_hash.sql` — migration, never edited in place. Adds `invite_token_hash` column (sha256 of plaintext) to `repo_collaborators` for email-flow invites; NULL on legacy auto-accepted rows.
376384- `drizzle/0037_workflow_engine_v2.sql` — migration, never edited in place. Strictly additive to Block C1 (`drizzle/0008_workflows.sql` stays locked). Adds `workflow_secrets` (AES-256-GCM, `(repo, name)` unique), `workflow_dispatch_inputs` (per-workflow input schema), `workflow_run_cache` (content-addressable cache, repo/branch/tag scoped), `workflow_runner_pool` (warm-runner registry).
377385- `drizzle/0038_deployment_ready_after.sql` — migration, never edited in place. Adds `deployments.ready_after` (timestamptz, NULL = no wait) + partial index. Backs the wait-timer enforcement for environment approvals (was previously a stub per Block C4 v1).
386- `drizzle/0040_branch_protection_auto_merge.sql` (Block K2) — migration, never edited in place. Adds `branch_protection.enable_auto_merge` (boolean, default false) — strict opt-in so the K3 autopilot sweep is default-deny per branch. Slot 0040 (not 0039) because `drizzle/0039_repair_flywheel.sql` was already taken; additive only, never replaces an earlier migration.
378387
379388### 4.2 Git layer (locked)
380389- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
427436- `src/lib/pr-triage.ts``triggerPrTriage(input)` post-PR-create hook. Builds a tiny diff summary (numstat only), loads available labels + candidate reviewers (owner + recent PR authors, capped at 12), calls `triagePullRequest()` from `ai-generators.ts`, then inserts a "## AI Triage" comment. Idempotent via `PR_TRIAGE_MARKER` (HTML comment in body). Pure helper `renderTriageComment(triage)` exported. Suggestions only — never applies.
428437- `src/lib/issue-triage.ts``triggerIssueTriage(input)` post-issue-create hook. Mirrors the PR-triage pattern: idempotency via `ISSUE_TRIAGE_MARKER`, loads existing labels + recent issues (last 30) for context, calls `triageIssue()` from `ai-generators.ts`, inserts "## AI Triage" issue comment with summary / priority / labels / possible-duplicate callout. Pure helper `renderIssueTriageComment(triage)` exported. Suggestions only.
429438- `src/lib/ai-review.ts``triggerAiReview(...)` PR-create hook now actually runs Claude review. Computes `git diff base...head`, caps at 100KB, calls `reviewDiff()`, posts a summary comment + N inline file/line comments. Idempotent via `AI_REVIEW_MARKER`; degrades to a single "AI review unavailable" comment on Anthropic API failure. Never throws.
439- `src/lib/auto-merge.ts` (Block K2) — pure decision helper `decideAutoMerge(facts)` plus the DB orchestrator `evaluateAutoMerge(ctx, opts?)`. Default-deny: requires a matching `branch_protection` rule with `enable_auto_merge=true` AND every gate the manual merge path enforces (reuses `matchProtection` / `evaluateProtection` / `listRequiredChecks` / `passingCheckNames` from §4.4's branch-protection module). When `requireAiApproval=true`, demands an AI-review comment carrying `AI_REVIEW_MARKER` that survives `aiCommentLooksApproved` (rejects "AI review unavailable" / "severity: blocking" / "flagged N item(s)"). Optional size cap (`maxChangedFiles` / `maxChangedLines`). Side-effect-free; K3's autopilot is the only intended caller. Also exports `recordAutoMergeAttempt(repoId, prId, decision)` for the audit trail.
440- `src/lib/pr-merge.ts` (Block K3) — shared `performMerge(prId)` factored from `src/routes/pulls.tsx`. Pure `{ok, error}` return; never throws. Handles git-update-ref / `mergeWithAutoResolve` switch + PR state flip + J7 close-keyword auto-close. The HTTP route still has its own inline merge mechanics — refactoring it to call `performMerge()` is tracked as a follow-up. Both code paths are kept in lock-step until that refactor.
441- `src/lib/ai-build-tasks.ts` (Block K3) — `runAiBuildTaskOnce(deps)` orchestrator + 5 collaborator interfaces (`findIssues`, `listOpenPrs`, `postIssueComment`, `buildSpec`, `dispatchSpec`). Finds open issues labelled `ai:build` (case-insensitive), skips those already linked to a PR via J7 close-keywords or already dispatched (marker `<!-- gluecron:ai-build:v1 -->`), composes a spec via `buildSpecFromIssue` (from `src/routes/specs.tsx`), dispatches to `src/lib/spec-to-pr.ts`. Posts the marker on the issue so re-runs never re-dispatch. Cap 20 issues per tick. Fire-and-forget; spec-to-pr failures caught + logged.
430442
431443### 4.5 Platform (locked)
432444- `src/lib/notify.ts` — notification creation + audit log (swallow-failures pattern). Also fans out email to opted-in recipients for `mention|review_requested|assigned|gate_failed`. Exports `__internal` for tests.
440452- `src/lib/observability.ts` — minimal dependency-free observability layer. Wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws.
441453- `src/lib/sse.ts` — in-process topic-based pub/sub broadcaster for Server-Sent Events. Backs the live UI updates layer.
442454- `src/lib/sse-client.ts` — emits a `<script>`-droppable JS snippet for SSR'd views to subscribe to a topic. Plain JS only — no client framework.
443- `src/lib/autopilot.ts` — 5-minute self-sufficiency ticker. Default tasks (in order): `mirror-sync`, `merge-queue`, `weekly-digest`, `advisory-rescan`, `wait-timer-release`, `scheduled-workflows`. `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` + `getTickCount()`.
455- `src/lib/autopilot.ts` — 5-minute self-sufficiency ticker. Default tasks (in order): `mirror-sync`, `merge-queue`, `weekly-digest`, `advisory-rescan`, `wait-timer-release`, `scheduled-workflows`, `auto-merge-sweep` (Block K3), `ai-build-from-issues` (Block K3). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` + `getTickCount()`. K3-added tasks: `auto-merge-sweep` calls `evaluateAutoMerge` (§4.4) on every open non-draft PR updated in the last 24h (cap 50), performs the merge via `pr-merge.ts performMerge` on `merge:true` decisions, and emits `auto_merge.evaluated`/`auto_merge.merged`/`auto_merge.merge_failed` audit rows plus a stable `<!-- gluecron:auto-merge:v1 -->` PR comment. `ai-build-from-issues` runs the orchestrator in `ai-build-tasks.ts`.
444456- `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()` (creates `demo` user + `hello-python` / `todo-api` / `design-docs`). `DEMO_SEED_ON_BOOT=1` boot flag in `src/index.ts`.
445457- `src/lib/import-helper.ts` — small helpers for the GitHub import flow (`src/routes/import.tsx`).
446458- `src/lib/import-verify.ts` — post-migration smoke verifier (object count, branches, default-branch HEAD). Re-runnable from `src/routes/migrations.tsx`.
600612```bash
601613bun install
602614bun dev # hot reload
603bun test # 1214 tests currently pass (8 skipped, 0 failed) as of 2026-04-30 — run `bun install` first
615bun test # 1350 tests currently pass (0 failed) as of 2026-05-13 after K1/K2/K3 — run `bun install` first
604616bun run db:migrate
605617```
606618
649661
650662(Intentionally empty. Add here if a block is partially complete at session end.)
651663
664**2026-05-13 — BLOCK K (PR #62) follow-ups (all four are non-blocking; the suite is 1350/0 green):**
665
6661. Refactor `src/routes/pulls.tsx` merge handler to call `src/lib/pr-merge.ts performMerge()`. K3 factored the helper but left the route's inline merge mechanics untouched because the route's `c.redirect(?error=…)` flow doesn't map cleanly to the helper's `{ok,error}` return — and cracking open the locked route risked altering user-facing redirect semantics. The two paths are currently lock-step duplicates. Single shared code path is the goal.
6672. K1's `gluecron_merge_pr` MCP tool also duplicates merge logic. Once #1 lands, this tool should switch to `performMerge()` too. Both merge call sites must move together.
6683. Add a synthetic `system`/`autopilot` user (or a nullable `authored_by_system` column on `pr_comments`/`issue_comments`) so K3's auto-merge / ai-build marker comments don't credit the PR/issue author as the comment author. Today the marker is posted with `authorId = pr.authorId` (or issue authorId) because no system user exists.
6694. Surface the new K3 tasks (`auto-merge-sweep`, `ai-build-from-issues`) on `/admin/autopilot`. Today they run but the admin UI lists only the original six tasks.
670
652671**2026-04-29 reconciliation pass (branch `claude/platform-launch-assessment-8dWV8`):**
653672
654673The Bible had drifted behind reality. This session reconciled §2 / §4 / §5 against the actual codebase. All edits below are strictly additive — no locked entry was deleted or renamed.
Addeddrizzle/0040_branch_protection_auto_merge.sql+14−0View fileUnifiedSplit
1-- Block K2 — AI-gated auto-merge.
2--
3-- Adds an opt-in `enable_auto_merge` flag to each branch-protection rule.
4-- When true, the K3 autopilot ticker may auto-merge PRs whose base branch
5-- matches this rule — provided every other gate the manual-merge path
6-- enforces is green. Default-deny on purpose: owners must explicitly turn
7-- this on per rule.
8--
9-- NOTE: this should have been named 0039 per the K2 spec, but the
10-- repair-flywheel work landed first and took the 0039 slot, so we ship as
11-- 0040 to keep migration ordering monotonic and additive.
12
13ALTER TABLE "branch_protection"
14 ADD COLUMN IF NOT EXISTS "enable_auto_merge" boolean NOT NULL DEFAULT false;
Addedsrc/__tests__/auto-merge.test.ts+240−0View fileUnifiedSplit
1/**
2 * Block K2 — AI-gated auto-merge evaluator tests.
3 *
4 * Drives the pure decision helper directly (no DB) so every branch is
5 * deterministic. The DB-backed `evaluateAutoMerge` wrapper is exercised
6 * indirectly via the same decision logic — its own integration is
7 * trivial glue.
8 */
9
10import { describe, expect, test } from "bun:test";
11import {
12 __test,
13 type AutoMergeDecision,
14} from "../lib/auto-merge";
15import type { BranchProtection } from "../db/schema";
16
17const { decideAutoMerge, aiCommentLooksApproved } = __test;
18
19function rule(overrides: Partial<BranchProtection> = {}): BranchProtection {
20 return {
21 id: "id-rule",
22 repositoryId: "repo-1",
23 pattern: "main",
24 requirePullRequest: true,
25 requireGreenGates: false,
26 requireAiApproval: false,
27 requireHumanReview: false,
28 requiredApprovals: 0,
29 allowForcePush: false,
30 allowDeletion: false,
31 dismissStaleReviews: true,
32 enableAutoMerge: true,
33 createdAt: new Date(),
34 updatedAt: new Date(),
35 ...overrides,
36 } as BranchProtection;
37}
38
39function happyArgs() {
40 return {
41 rule: rule(),
42 isDraft: false,
43 aiApproved: true,
44 humanApprovalCount: 0,
45 hasFailedGates: false,
46 passingCheckNames: [] as string[],
47 requiredCheckNames: [] as string[],
48 };
49}
50
51function assertInvariant(d: AutoMergeDecision) {
52 // The blocking list is non-empty iff merge=false.
53 if (d.merge) {
54 expect(d.blocking === undefined || d.blocking.length === 0).toBe(true);
55 } else {
56 expect(d.blocking && d.blocking.length > 0).toBe(true);
57 }
58}
59
60describe("decideAutoMerge", () => {
61 test("happy path: rule on, no draft, AI approved, gates green → merge", () => {
62 const d = decideAutoMerge({
63 ...happyArgs(),
64 rule: rule({ requireAiApproval: true }),
65 aiApproved: true,
66 });
67 expect(d.merge).toBe(true);
68 assertInvariant(d);
69 });
70
71 test("default-deny when no branch_protection rule matches", () => {
72 const d = decideAutoMerge({ ...happyArgs(), rule: null });
73 expect(d.merge).toBe(false);
74 expect(d.blocking?.[0]).toMatch(/default-deny/i);
75 assertInvariant(d);
76 });
77
78 test("default-deny when enableAutoMerge=false on the matching rule", () => {
79 const d = decideAutoMerge({
80 ...happyArgs(),
81 rule: rule({ enableAutoMerge: false }),
82 });
83 expect(d.merge).toBe(false);
84 expect(d.blocking?.some((r) => /auto-merge enabled/i.test(r))).toBe(true);
85 assertInvariant(d);
86 });
87
88 test("blocks when PR is draft", () => {
89 const d = decideAutoMerge({ ...happyArgs(), isDraft: true });
90 expect(d.merge).toBe(false);
91 expect(d.blocking?.some((r) => /draft/i.test(r))).toBe(true);
92 assertInvariant(d);
93 });
94
95 test("blocks when AI approval required but missing", () => {
96 const d = decideAutoMerge({
97 ...happyArgs(),
98 rule: rule({ requireAiApproval: true }),
99 aiApproved: false,
100 });
101 expect(d.merge).toBe(false);
102 expect(d.blocking?.some((r) => /AI approval/i.test(r))).toBe(true);
103 assertInvariant(d);
104 });
105
106 test("blocks on failing hard gate (requireGreenGates)", () => {
107 const d = decideAutoMerge({
108 ...happyArgs(),
109 rule: rule({ requireGreenGates: true }),
110 hasFailedGates: true,
111 });
112 expect(d.merge).toBe(false);
113 expect(d.blocking?.some((r) => /green gates/i.test(r))).toBe(true);
114 assertInvariant(d);
115 });
116
117 test("blocks on missing required check", () => {
118 const d = decideAutoMerge({
119 ...happyArgs(),
120 requiredCheckNames: ["lint", "test"],
121 passingCheckNames: ["lint"],
122 hasFailedGates: true, // K3 caller would set this consistently
123 });
124 expect(d.merge).toBe(false);
125 expect(d.blocking?.some((r) => /test/i.test(r))).toBe(true);
126 assertInvariant(d);
127 });
128
129 test("allows when all required checks pass", () => {
130 const d = decideAutoMerge({
131 ...happyArgs(),
132 requiredCheckNames: ["lint", "test"],
133 passingCheckNames: ["lint", "test"],
134 hasFailedGates: false,
135 });
136 expect(d.merge).toBe(true);
137 assertInvariant(d);
138 });
139
140 test("blocks when human review required and not present", () => {
141 const d = decideAutoMerge({
142 ...happyArgs(),
143 rule: rule({ requireHumanReview: true }),
144 humanApprovalCount: 0,
145 });
146 expect(d.merge).toBe(false);
147 expect(d.blocking?.some((r) => /human review/i.test(r))).toBe(true);
148 assertInvariant(d);
149 });
150
151 test("size cap blocks when over limit", () => {
152 const d = decideAutoMerge({
153 ...happyArgs(),
154 diffStats: { files: 50, lines: 5000 },
155 caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
156 });
157 expect(d.merge).toBe(false);
158 expect(d.blocking?.length).toBeGreaterThanOrEqual(2);
159 assertInvariant(d);
160 });
161
162 test("size cap does not block when under limit", () => {
163 const d = decideAutoMerge({
164 ...happyArgs(),
165 diffStats: { files: 3, lines: 80 },
166 caps: { maxChangedFiles: 10, maxChangedLines: 1000 },
167 });
168 expect(d.merge).toBe(true);
169 assertInvariant(d);
170 });
171
172 test("size cap skipped when no caps provided even with big diff", () => {
173 const d = decideAutoMerge({
174 ...happyArgs(),
175 diffStats: { files: 9999, lines: 9999 },
176 });
177 expect(d.merge).toBe(true);
178 assertInvariant(d);
179 });
180
181 test("accumulates multiple blocking reasons", () => {
182 const d = decideAutoMerge({
183 ...happyArgs(),
184 rule: rule({
185 requireAiApproval: true,
186 requireGreenGates: true,
187 requireHumanReview: true,
188 }),
189 isDraft: true,
190 aiApproved: false,
191 humanApprovalCount: 0,
192 hasFailedGates: true,
193 });
194 expect(d.merge).toBe(false);
195 expect(d.blocking?.length).toBeGreaterThanOrEqual(4);
196 assertInvariant(d);
197 });
198
199 test("invariant: blocking list non-empty iff merge=false (random shapes)", () => {
200 const shapes = [
201 happyArgs(),
202 { ...happyArgs(), rule: null },
203 { ...happyArgs(), isDraft: true },
204 { ...happyArgs(), rule: rule({ enableAutoMerge: false }) },
205 ];
206 for (const s of shapes) {
207 assertInvariant(decideAutoMerge(s));
208 }
209 });
210});
211
212describe("aiCommentLooksApproved", () => {
213 test("approves a clean AI summary", () => {
214 const body =
215 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** no blocking issues found.\n\nLooks good.";
216 expect(aiCommentLooksApproved(body)).toBe(true);
217 });
218
219 test("rejects when API was unavailable", () => {
220 const body =
221 "<!-- gluecron-ai-review:summary -->\n## AI review unavailable\n\nThe AI review attempt failed: timeout.";
222 expect(aiCommentLooksApproved(body)).toBe(false);
223 });
224
225 test("rejects on severity: blocking marker (case-insensitive)", () => {
226 const body =
227 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\nFindings:\n- Severity: BLOCKING — auth bypass at line 42.";
228 expect(aiCommentLooksApproved(body)).toBe(false);
229 });
230
231 test("rejects when AI flagged items for human attention", () => {
232 const body =
233 "<!-- gluecron-ai-review:summary -->\n## AI Code Review\n\n**AI review:** flagged 3 item(s) for human attention.";
234 expect(aiCommentLooksApproved(body)).toBe(false);
235 });
236
237 test("rejects empty body", () => {
238 expect(aiCommentLooksApproved("")).toBe(false);
239 });
240});
Addedsrc/__tests__/autopilot-ai-tasks.test.ts+438−0View fileUnifiedSplit
1/**
2 * Block K3 — autopilot AI-driver task tests.
3 *
4 * Uses the dependency-injection seams added in `src/lib/autopilot.ts`
5 * (`runAutoMergeSweep`) and `src/lib/ai-build-tasks.ts`
6 * (`runAiBuildTaskOnce`) so we never hit the DB or the AI client.
7 *
8 * Covers the contract called out in the K3 spec:
9 * - auto-merge-sweep skips drafts
10 * - auto-merge-sweep skips archived repos
11 * - auto-merge-sweep invokes merge exactly once per `merge:true` PR
12 * - ai-build dispatches against `ai:build`-labelled issues
13 * - ai-build skips an issue with a marker comment already present
14 * - both tasks no-op cleanly when AUTOPILOT_DISABLED=1
15 */
16
17import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18import {
19 startAutopilot,
20 runAutoMergeSweep,
21 type AutoMergeSweepDeps,
22 type AutopilotTask,
23} from "../lib/autopilot";
24import {
25 runAiBuildTaskOnce,
26 AI_BUILD_MARKER,
27 type AiBuildCandidate,
28} from "../lib/ai-build-tasks";
29import type {
30 AutoMergeContext,
31 AutoMergeDecision,
32} from "../lib/auto-merge";
33import type { PerformMergeResult } from "../lib/pr-merge";
34
35// ---------------------------------------------------------------------------
36// Test fixtures
37// ---------------------------------------------------------------------------
38
39type Candidate = Parameters<NonNullable<AutoMergeSweepDeps["merge"]>>[0];
40
41function makeCandidate(overrides: Partial<Candidate> = {}): Candidate {
42 return {
43 prId: "pr-1",
44 prNumber: 42,
45 prTitle: "Test PR",
46 prBody: "Closes #99",
47 baseBranch: "main",
48 headBranch: "feature",
49 isDraft: false,
50 repositoryId: "repo-1",
51 authorUserId: "user-1",
52 ownerUsername: "alice",
53 repoName: "demo",
54 state: "open",
55 ...overrides,
56 };
57}
58
59const okMerge: PerformMergeResult = {
60 ok: true,
61 closedIssueNumbers: [],
62 resolvedFiles: [],
63};
64
65const allowDecision: AutoMergeDecision = {
66 merge: true,
67 reason: "All auto-merge conditions met for 'main'.",
68};
69
70const blockDecision: AutoMergeDecision = {
71 merge: false,
72 reason: "blocked",
73 blocking: ["draft"],
74};
75
76// ---------------------------------------------------------------------------
77// auto-merge-sweep
78// ---------------------------------------------------------------------------
79
80describe("auto-merge-sweep", () => {
81 it("skips drafts at the candidate-finder layer (drafts must never enter the loop)", async () => {
82 // The default findCandidates filters drafts via SQL. We model that
83 // contract here: when the caller supplies drafts, the sweep STILL
84 // delegates the merge() call ONLY for non-draft PRs whose decision is
85 // merge:true. Drafts that somehow leak through must surface as blocked
86 // (decideAutoMerge will reject them).
87 const draft = makeCandidate({ prId: "draft-1", isDraft: true });
88 const open = makeCandidate({ prId: "open-1", isDraft: false });
89 const mergeCalls: string[] = [];
90
91 const summary = await runAutoMergeSweep({
92 findCandidates: async () => [draft, open],
93 // Simulate evaluateAutoMerge: drafts get blocked, opens get allowed.
94 evaluate: async (ctx: AutoMergeContext) =>
95 ctx.isDraft ? blockDecision : allowDecision,
96 merge: async (cand) => {
97 mergeCalls.push(cand.prId);
98 return okMerge;
99 },
100 recordAttempt: async () => {},
101 onMerged: async () => {},
102 onMergeFailed: async () => {},
103 shouldShortCircuitAi: async () => false,
104 });
105
106 expect(mergeCalls).toEqual(["open-1"]);
107 expect(summary.evaluated).toBe(2);
108 expect(summary.merged).toBe(1);
109 expect(summary.blocked).toBe(1);
110 });
111
112 it("skips archived repos (no candidates surfaced from the finder)", async () => {
113 // The default finder excludes archived repos in SQL. We simulate by
114 // supplying an empty result and confirming a clean no-op summary.
115 let findCalled = false;
116 let mergeCalled = 0;
117 const summary = await runAutoMergeSweep({
118 findCandidates: async () => {
119 findCalled = true;
120 return [];
121 },
122 evaluate: async () => allowDecision,
123 merge: async () => {
124 mergeCalled += 1;
125 return okMerge;
126 },
127 recordAttempt: async () => {},
128 onMerged: async () => {},
129 onMergeFailed: async () => {},
130 shouldShortCircuitAi: async () => false,
131 });
132 expect(findCalled).toBe(true);
133 expect(mergeCalled).toBe(0);
134 expect(summary).toEqual({ evaluated: 0, merged: 0, blocked: 0 });
135 });
136
137 it("invokes the merge function exactly once per merge:true PR and records an evaluated audit per PR", async () => {
138 const candidates = [
139 makeCandidate({ prId: "a" }),
140 makeCandidate({ prId: "b" }),
141 makeCandidate({ prId: "c" }),
142 ];
143 const mergeCalls: string[] = [];
144 const evaluatedAuditCalls: string[] = [];
145 const mergedSuccessCalls: string[] = [];
146
147 const summary = await runAutoMergeSweep({
148 findCandidates: async () => candidates,
149 // 'a' and 'c' are allowed, 'b' is blocked.
150 evaluate: async (ctx) =>
151 ctx.pullRequestId === "b" ? blockDecision : allowDecision,
152 merge: async (cand) => {
153 mergeCalls.push(cand.prId);
154 return okMerge;
155 },
156 recordAttempt: async (_repoId, prId) => {
157 evaluatedAuditCalls.push(prId);
158 },
159 onMerged: async (cand) => {
160 mergedSuccessCalls.push(cand.prId);
161 },
162 onMergeFailed: async () => {},
163 shouldShortCircuitAi: async () => false,
164 });
165
166 expect(mergeCalls).toEqual(["a", "c"]);
167 expect(mergedSuccessCalls).toEqual(["a", "c"]);
168 // recordAttempt fires for EVERY evaluation, not just successes.
169 expect(evaluatedAuditCalls.sort()).toEqual(["a", "b", "c"]);
170 expect(summary).toEqual({ evaluated: 3, merged: 2, blocked: 1 });
171 });
172
173 it("emits the merge_failed audit when performMerge returns ok=false (and does NOT emit merged audit)", async () => {
174 const cand = makeCandidate({ prId: "x" });
175 let mergedHits = 0;
176 let failedHits = 0;
177
178 const summary = await runAutoMergeSweep({
179 findCandidates: async () => [cand],
180 evaluate: async () => allowDecision,
181 merge: async () => ({
182 ok: false,
183 error: "git update-ref failed: not a fast-forward",
184 closedIssueNumbers: [],
185 resolvedFiles: [],
186 }),
187 recordAttempt: async () => {},
188 onMerged: async () => {
189 mergedHits += 1;
190 },
191 onMergeFailed: async () => {
192 failedHits += 1;
193 },
194 shouldShortCircuitAi: async () => false,
195 });
196
197 expect(mergedHits).toBe(0);
198 expect(failedHits).toBe(1);
199 expect(summary).toEqual({ evaluated: 1, merged: 0, blocked: 1 });
200 });
201
202 it("short-circuits AI-required rules when ANTHROPIC_API_KEY is unset (logged as blocked, not error)", async () => {
203 const cand = makeCandidate({ prId: "ai-1" });
204 let evaluateHits = 0;
205 let mergeHits = 0;
206
207 const summary = await runAutoMergeSweep({
208 findCandidates: async () => [cand],
209 shouldShortCircuitAi: async () => true,
210 evaluate: async () => {
211 evaluateHits += 1;
212 return allowDecision;
213 },
214 merge: async () => {
215 mergeHits += 1;
216 return okMerge;
217 },
218 recordAttempt: async () => {},
219 onMerged: async () => {},
220 onMergeFailed: async () => {},
221 });
222 expect(evaluateHits).toBe(0); // never invoked
223 expect(mergeHits).toBe(0); // never invoked
224 expect(summary).toEqual({ evaluated: 1, merged: 0, blocked: 1 });
225 });
226
227 it("never throws even when the candidate-finder throws — returns zero summary", async () => {
228 const summary = await runAutoMergeSweep({
229 findCandidates: async () => {
230 throw new Error("db blew up");
231 },
232 evaluate: async () => allowDecision,
233 merge: async () => okMerge,
234 recordAttempt: async () => {},
235 onMerged: async () => {},
236 onMergeFailed: async () => {},
237 shouldShortCircuitAi: async () => false,
238 });
239 expect(summary).toEqual({ evaluated: 0, merged: 0, blocked: 0 });
240 });
241
242 it("isolates per-PR failures — a thrown merge() doesn't stop later PRs", async () => {
243 const candidates = [
244 makeCandidate({ prId: "first" }),
245 makeCandidate({ prId: "second" }),
246 ];
247 const mergeCalls: string[] = [];
248 const summary = await runAutoMergeSweep({
249 findCandidates: async () => candidates,
250 evaluate: async () => allowDecision,
251 merge: async (cand) => {
252 mergeCalls.push(cand.prId);
253 if (cand.prId === "first") throw new Error("kaboom");
254 return okMerge;
255 },
256 recordAttempt: async () => {},
257 onMerged: async () => {},
258 onMergeFailed: async () => {},
259 shouldShortCircuitAi: async () => false,
260 });
261 expect(mergeCalls).toEqual(["first", "second"]);
262 expect(summary.evaluated).toBe(2);
263 expect(summary.merged).toBe(1);
264 expect(summary.blocked).toBe(1);
265 });
266});
267
268// ---------------------------------------------------------------------------
269// ai-build-from-issues
270// ---------------------------------------------------------------------------
271
272function makeAiBuildCandidate(
273 overrides: Partial<AiBuildCandidate> = {}
274): AiBuildCandidate {
275 return {
276 issueId: "issue-1",
277 issueNumber: 7,
278 issueTitle: "Add a sparkle button",
279 issueBody: "Should sparkle when clicked.",
280 repositoryId: "repo-1",
281 authorUserId: "user-1",
282 ownerUsername: "alice",
283 repoName: "demo",
284 defaultBranch: "main",
285 ...overrides,
286 };
287}
288
289describe("ai-build-from-issues", () => {
290 it("dispatches the spec-to-PR pipeline for an ai:build-labelled issue", async () => {
291 const dispatched: Array<{ repoId: string; spec: string; baseRef: string }> =
292 [];
293 const markersPosted: string[] = [];
294
295 const summary = await runAiBuildTaskOnce({
296 findCandidates: async () => [makeAiBuildCandidate()],
297 hasDispatchMarker: async () => false,
298 hasOpenLinkedPr: async () => false,
299 dispatcher: async (args) => {
300 dispatched.push({
301 repoId: args.repoId,
302 spec: args.spec,
303 baseRef: args.baseRef,
304 });
305 return { ok: true, prNumber: 123 };
306 },
307 postMarkerComment: async (issueId) => {
308 markersPosted.push(issueId);
309 },
310 });
311
312 expect(dispatched.length).toBe(1);
313 expect(dispatched[0].repoId).toBe("repo-1");
314 expect(dispatched[0].baseRef).toBe("main");
315 // buildSpecFromIssue prefixes with "Implement:" and includes "Closes #N".
316 expect(dispatched[0].spec).toContain("Implement: Add a sparkle button");
317 expect(dispatched[0].spec).toContain("Closes #7");
318 expect(markersPosted).toEqual(["issue-1"]);
319 expect(summary).toEqual({ queued: 1, skipped: 0 });
320 });
321
322 it("skips an issue that already has the dispatch marker comment", async () => {
323 const dispatched: string[] = [];
324 const markersPosted: string[] = [];
325
326 const summary = await runAiBuildTaskOnce({
327 findCandidates: async () => [makeAiBuildCandidate({ issueId: "i-already" })],
328 hasDispatchMarker: async (id) => id === "i-already",
329 hasOpenLinkedPr: async () => false,
330 dispatcher: async (args) => {
331 dispatched.push(args.repoId);
332 return { ok: true, prNumber: 1 };
333 },
334 postMarkerComment: async (id) => {
335 markersPosted.push(id);
336 },
337 });
338
339 expect(dispatched).toEqual([]);
340 expect(markersPosted).toEqual([]);
341 expect(summary).toEqual({ queued: 0, skipped: 1 });
342 });
343
344 it("skips an issue that already has an open PR closing it via close-keywords", async () => {
345 let dispatcherHits = 0;
346 const summary = await runAiBuildTaskOnce({
347 findCandidates: async () => [makeAiBuildCandidate({ issueNumber: 42 })],
348 hasDispatchMarker: async () => false,
349 hasOpenLinkedPr: async (_repoId, n) => n === 42,
350 dispatcher: async () => {
351 dispatcherHits += 1;
352 return { ok: true, prNumber: 1 };
353 },
354 postMarkerComment: async () => {},
355 });
356 expect(dispatcherHits).toBe(0);
357 expect(summary).toEqual({ queued: 0, skipped: 1 });
358 });
359
360 it("swallows dispatcher failures without crashing the tick", async () => {
361 let markerPosted = false;
362 const summary = await runAiBuildTaskOnce({
363 findCandidates: async () => [makeAiBuildCandidate()],
364 hasDispatchMarker: async () => false,
365 hasOpenLinkedPr: async () => false,
366 dispatcher: async () => {
367 throw new Error("anthropic 500");
368 },
369 postMarkerComment: async () => {
370 markerPosted = true;
371 },
372 });
373 // Marker still posted (idempotency takes priority over success).
374 expect(markerPosted).toBe(true);
375 // The issue counts as queued because we did our part — we don't retry.
376 expect(summary.queued).toBe(1);
377 });
378
379 it("returns zero summary if findCandidates throws", async () => {
380 const summary = await runAiBuildTaskOnce({
381 findCandidates: async () => {
382 throw new Error("db down");
383 },
384 });
385 expect(summary).toEqual({ queued: 0, skipped: 0 });
386 });
387
388 it("uses the AI_BUILD_MARKER constant in the marker body", async () => {
389 let receivedBody = "";
390 await runAiBuildTaskOnce({
391 findCandidates: async () => [makeAiBuildCandidate()],
392 hasDispatchMarker: async () => false,
393 hasOpenLinkedPr: async () => false,
394 dispatcher: async () => ({ ok: true, prNumber: 1 }),
395 postMarkerComment: async (_id, _author, body) => {
396 receivedBody = body;
397 },
398 });
399 expect(receivedBody.includes(AI_BUILD_MARKER)).toBe(true);
400 });
401});
402
403// ---------------------------------------------------------------------------
404// AUTOPILOT_DISABLED=1 — both tasks no-op when the parent loop never runs.
405// ---------------------------------------------------------------------------
406
407describe("autopilot disabled — neither AI task fires", () => {
408 const originalDisabled = process.env.AUTOPILOT_DISABLED;
409 afterEach(() => {
410 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
411 else process.env.AUTOPILOT_DISABLED = originalDisabled;
412 });
413
414 it("startAutopilot with AUTOPILOT_DISABLED=1 doesn't run injected AI tasks", async () => {
415 process.env.AUTOPILOT_DISABLED = "1";
416 let autoMergeRan = 0;
417 let aiBuildRan = 0;
418 const tasks: AutopilotTask[] = [
419 {
420 name: "auto-merge-sweep",
421 run: async () => {
422 autoMergeRan += 1;
423 },
424 },
425 {
426 name: "ai-build-from-issues",
427 run: async () => {
428 aiBuildRan += 1;
429 },
430 },
431 ];
432 const { stop } = startAutopilot({ intervalMs: 5, tasks });
433 await new Promise((r) => setTimeout(r, 40));
434 stop();
435 expect(autoMergeRan).toBe(0);
436 expect(aiBuildRan).toBe(0);
437 });
438});
Addedsrc/__tests__/mcp-write.test.ts+900−0View fileUnifiedSplit
1/**
2 * Block K1 — MCP write surface tests.
3 *
4 * Covers the 10 new tools added in src/lib/mcp-tools.ts:
5 * create_issue / comment_issue / close_issue / reopen_issue
6 * create_pr / get_pr / list_prs / comment_pr
7 * merge_pr / close_pr
8 *
9 * Each tool gets at minimum:
10 * - Happy-ish path: authenticated owner returns the spec-described shape
11 * - Auth gate : ctx.userId === null → McpError(-32602 INVALID_PARAMS)
12 * - Write-access : authed but no write access → McpError(-32601 NOT_FOUND)
13 *
14 * We stub the `../db` module (same pattern as repo-access.test.ts and
15 * import-verify.test.ts) so these tests never touch Neon. The fake `db`
16 * dispatches on the table passed to `.from(...)` / `.insert(...)` to
17 * decide which canned shape to return. Mutations are recorded in the
18 * `_inserted` / `_updated` arrays so the assertions can verify the audit
19 * + DB-write path was actually exercised.
20 *
21 * We also stub `./notify` to avoid the email-fanout path (which would
22 * also try to hit the DB), and `./gate` + `./branch-protection` +
23 * `./merge-resolver` for the merge_pr happy path so we don't shell out
24 * to git.
25 */
26
27import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
28import {
29 ERR_INVALID_PARAMS,
30 ERR_METHOD_NOT_FOUND,
31 McpError,
32} from "../lib/mcp";
33
34// Capture the REAL modules BEFORE any mock.module() call below so we can
35// restore them in afterAll. `mock.module()` is process-global in Bun and
36// otherwise bleeds into every test file that runs after this one in the
37// same `bun test` invocation, silently breaking every test that imports
38// (directly or transitively) any of the modules we stub.
39const _real_db = await import("../db");
40const _real_notify = await import("../lib/notify");
41const _real_gate = await import("../lib/gate");
42const _real_branchProtection = await import("../lib/branch-protection");
43const _real_mergeResolver = await import("../lib/merge-resolver");
44const _real_aiReview = await import("../lib/ai-review");
45const _real_closeKeywords = await import("../lib/close-keywords");
46const _real_sse = await import("../lib/sse");
47
48// ---------------------------------------------------------------------------
49// Module stubs
50// ---------------------------------------------------------------------------
51
52/** Per-test override hooks. Reset in beforeEach. */
53let _nextRepoRow: any = null;
54let _nextCollabRow: any = null;
55let _nextIssueRow: any = null;
56let _nextPrRow: any = null;
57let _nextAiCommentRows: any[] = [];
58let _nextProtectionRule: any = null;
59
60const _inserted: { table: string; values: any; returned: any }[] = [];
61const _updated: { table: string; set: any }[] = [];
62
63let _lastSelectFrom: any = null;
64let _lastInsertTable: any = null;
65let _lastUpdateTable: any = null;
66let _lastInsertValues: any = null;
67
68const tableName = (t: any): string => {
69 if (!t || typeof t !== "object") return "?";
70 // Drizzle pgTable objects expose a Symbol-keyed name. We probe `_` /
71 // common keys to identify the table without importing the schema.
72 if ("isPrivate" in t) return "repositories";
73 if ("acceptedAt" in t || "invitedAt" in t) return "repo_collaborators";
74 if ("issueId" in t && "body" in t) return "issue_comments";
75 if ("pullRequestId" in t) return "pr_comments";
76 if (
77 "baseBranch" in t ||
78 "headBranch" in t ||
79 "mergedAt" in t ||
80 "isDraft" in t
81 )
82 return "pull_requests";
83 if ("state" in t && "closedAt" in t && "title" in t && !("baseBranch" in t))
84 return "issues";
85 if ("username" in t && "passwordHash" in t) return "users";
86 if ("kind" in t) return "notifications";
87 if ("action" in t && "userId" in t) return "audit_log";
88 return "?";
89};
90
91const _selectChain: any = {
92 from: (t: any) => {
93 _lastSelectFrom = t;
94 return _selectChain;
95 },
96 innerJoin: () => _selectChain,
97 leftJoin: () => _selectChain,
98 rightJoin: () => _selectChain,
99 where: () => _selectChain,
100 orderBy: () => _selectChain,
101 groupBy: () => _selectChain,
102 limit: async () => {
103 const name = tableName(_lastSelectFrom);
104 if (name === "repositories") {
105 return _nextRepoRow ? [_nextRepoRow] : [];
106 }
107 if (name === "repo_collaborators") {
108 return _nextCollabRow ? [_nextCollabRow] : [];
109 }
110 if (name === "issues") {
111 return _nextIssueRow ? [_nextIssueRow] : [];
112 }
113 if (name === "pull_requests") {
114 return _nextPrRow ? [_nextPrRow] : [];
115 }
116 if (name === "pr_comments") {
117 return _nextAiCommentRows;
118 }
119 if (name === "users") {
120 // Only echo a username when the test explicitly set one via
121 // `_nextRepoRow.username`. Returning a default row breaks every
122 // downstream test that expects an empty result for nonexistent
123 // users (graphql, api-v2, etc.).
124 return _nextRepoRow?.username ? [{ username: _nextRepoRow.username }] : [];
125 }
126 return [];
127 },
128 // For pr-comments AI-approval lookup, the route doesn't `.limit(1)` — it
129 // awaits the chain directly. We expose `.then` so `await chain` yields
130 // the rows.
131 then: (resolve: (v: any) => void) => {
132 const name = tableName(_lastSelectFrom);
133 if (name === "pr_comments") return resolve(_nextAiCommentRows);
134 if (name === "pull_requests") return resolve(_nextPrRow ? [_nextPrRow] : []);
135 return resolve([]);
136 },
137};
138
139const _insertChain = (table: any) => {
140 _lastInsertTable = table;
141 return {
142 values: (vals: any) => {
143 _lastInsertValues = vals;
144 return {
145 returning: async () => {
146 const name = tableName(table);
147 let returned: any;
148 if (name === "issues") {
149 returned = {
150 id: "iss-id-1",
151 number: 42,
152 repositoryId: vals.repositoryId,
153 authorId: vals.authorId,
154 title: vals.title,
155 body: vals.body,
156 state: "open",
157 createdAt: new Date(),
158 updatedAt: new Date(),
159 closedAt: null,
160 };
161 } else if (name === "issue_comments") {
162 returned = {
163 id: "ic-id-1",
164 issueId: vals.issueId,
165 authorId: vals.authorId,
166 body: vals.body,
167 createdAt: new Date(),
168 updatedAt: new Date(),
169 };
170 } else if (name === "pull_requests") {
171 returned = {
172 id: "pr-id-1",
173 number: 7,
174 repositoryId: vals.repositoryId,
175 authorId: vals.authorId,
176 title: vals.title,
177 body: vals.body,
178 state: "open",
179 baseBranch: vals.baseBranch,
180 headBranch: vals.headBranch,
181 isDraft: vals.isDraft ?? false,
182 mergeStrategy: "merge",
183 milestoneId: null,
184 mergedAt: null,
185 mergedBy: null,
186 createdAt: new Date(),
187 updatedAt: new Date(),
188 closedAt: null,
189 };
190 } else if (name === "pr_comments") {
191 returned = {
192 id: "pc-id-1",
193 pullRequestId: vals.pullRequestId,
194 authorId: vals.authorId,
195 body: vals.body,
196 isAiReview: vals.isAiReview ?? false,
197 filePath: null,
198 lineNumber: null,
199 createdAt: new Date(),
200 updatedAt: new Date(),
201 };
202 } else {
203 returned = vals;
204 }
205 _inserted.push({ table: name, values: vals, returned });
206 return [returned];
207 },
208 // Allow `await db.insert(...).values(...)` (no `.returning()`),
209 // used by notify/audit + auto-close-issues path.
210 then: (resolve: (v: any) => void) => {
211 _inserted.push({ table: tableName(table), values: vals, returned: null });
212 resolve(undefined);
213 },
214 };
215 },
216 };
217};
218
219const _updateChain = (table: any) => {
220 _lastUpdateTable = table;
221 return {
222 set: (vals: any) => {
223 _updated.push({ table: tableName(table), set: vals });
224 return {
225 where: () => ({
226 then: (resolve: (v: any) => void) => resolve(undefined),
227 }),
228 };
229 },
230 };
231};
232
233const _fakeDb = {
234 db: {
235 select: () => _selectChain,
236 insert: (t: any) => _insertChain(t),
237 update: (t: any) => _updateChain(t),
238 delete: () => ({ where: () => Promise.resolve() }),
239 },
240 getDb: () => _fakeDb.db,
241 // NOTE: `isNeonUrl` is intentionally NOT overridden. The real export
242 // is a pure substring helper that downstream tests (db-driver.test.ts)
243 // exercise directly — overriding it here used to bleed `() => false`
244 // into every test in the run.
245};
246// Mock ONLY `../db`. Bun's `mock.module()` is process-global AND it
247// poisons every downstream test file that imports the same module —
248// overrides applied here persist past `afterAll` because ESM caches
249// the resolved module in each test file at load time. So we keep the
250// stub surface minimal: only the modules that would touch external
251// resources (real DB, real git subprocess, real Anthropic API) get
252// inert overrides. Everything else runs the real implementation,
253// which behaves correctly when the underlying DB returns nothing.
254//
255// For modules that DO need their behaviour neutered (gate runner,
256// merge-resolver, ai-review's API call), we install spy-style stubs
257// inside `beforeEach` and remove them in `afterAll` so they don't
258// leak across files.
259const _notifyCalls: any[] = [];
260const _auditCalls: any[] = [];
261mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
262mock.module("../lib/notify", () => ({
263 ..._real_notify,
264 notify: async (userId: string, opts: any) => {
265 _notifyCalls.push({ userId, ...opts });
266 },
267 notifyMany: async () => {},
268 audit: async (opts: any) => {
269 _auditCalls.push(opts);
270 },
271}));
272// `runAllGateChecks` shells out to git + the gate runner; replace with
273// a permissive no-op. Other gate exports stay real.
274mock.module("../lib/gate", () => ({
275 ..._real_gate,
276 runAllGateChecks: async () => ({ checks: [] }),
277}));
278// `mergeWithAutoResolve` shells out to git; replace with a success no-op.
279mock.module("../lib/merge-resolver", () => ({
280 ..._real_mergeResolver,
281 mergeWithAutoResolve: async () => ({ success: true, resolvedFiles: [] }),
282}));
283// `triggerAiReview` calls Anthropic; replace with a no-op. Crucially,
284// `AI_REVIEW_MARKER` (used by auto-merge.ts elsewhere) stays the real
285// const.
286mock.module("../lib/ai-review", () => ({
287 ..._real_aiReview,
288 isAiReviewEnabled: () => false,
289 triggerAiReview: async () => {},
290}));
291// NOTE: `../lib/branch-protection`, `../lib/close-keywords`, and
292// `../lib/sse` are intentionally NOT mocked here. The real modules
293// are pure (close-keywords) or fail-safe when the DB is the inert
294// stub (branch-protection's `matchProtection` returns null when no
295// rows come back, which is the K1 happy-path expectation anyway).
296// Mocking these previously broke K2's auto-merge.test.ts and the
297// sse + close-keywords + branch-protection test files.
298
299// We stub Bun.spawn globally so any git subprocess (e.g. resolveRef,
300// `git update-ref` in merge_pr) returns a deterministic happy exit. We
301// intentionally do NOT mock `../git/repository` so the rest of the
302// library's static imports keep resolving correctly.
303const _realBunSpawn = (globalThis as any).Bun?.spawn;
304function stubBunSpawnSuccess() {
305 (globalThis as any).Bun.spawn = () => ({
306 exited: Promise.resolve(0),
307 stdout: new ReadableStream({
308 start(c) {
309 c.enqueue(new TextEncoder().encode("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"));
310 c.close();
311 },
312 }),
313 stderr: new ReadableStream({
314 start(c) {
315 c.close();
316 },
317 }),
318 });
319}
320function restoreBunSpawn() {
321 if (_realBunSpawn) (globalThis as any).Bun.spawn = _realBunSpawn;
322}
323// Bun.spawn stub is NOT installed globally — the previous global install
324// bled into every downstream test file in the run and broke any test
325// whose code path eventually shelled out to `git`. The merge-PR happy
326// path test below installs + restores it inside the test body instead.
327
328afterAll(() => {
329 restoreBunSpawn();
330 // Reset every per-test row hook so any downstream test file whose
331 // code path runs `db.select()...limit(1)` against our stub sees an
332 // empty result (the no-rows branch) rather than inheriting the
333 // PUBLIC_REPO_ROW that K1's beforeEach last installed.
334 _nextRepoRow = null;
335 _nextCollabRow = null;
336 _nextIssueRow = null;
337 _nextPrRow = null;
338 _nextAiCommentRows = [];
339 _nextProtectionRule = null;
340 // Best-effort restoration: re-register the real modules. Note that
341 // downstream test files' static imports are already cached against
342 // whatever was active at their load time, so this is mostly a hygiene
343 // measure for files using dynamic imports.
344 mock.module("../db", () => _real_db);
345 mock.module("../lib/notify", () => _real_notify);
346 mock.module("../lib/gate", () => _real_gate);
347 mock.module("../lib/merge-resolver", () => _real_mergeResolver);
348 mock.module("../lib/ai-review", () => _real_aiReview);
349});
350
351// ---------------------------------------------------------------------------
352// Test fixtures
353// ---------------------------------------------------------------------------
354
355const OWNER_ID = "11111111-1111-1111-1111-111111111111";
356const REPO_ID = "22222222-2222-2222-2222-222222222222";
357const OTHER_USER_ID = "33333333-3333-3333-3333-333333333333";
358
359const PUBLIC_REPO_ROW = {
360 id: REPO_ID,
361 ownerId: OWNER_ID,
362 isPrivate: false,
363 defaultBranch: "main",
364 username: "alice",
365};
366
367const ownerCtx = { userId: OWNER_ID };
368const anonCtx = { userId: null };
369const otherCtx = { userId: OTHER_USER_ID };
370
371function resetState() {
372 _nextRepoRow = PUBLIC_REPO_ROW;
373 _nextCollabRow = null; // No collaborator rows → public repo non-owner = read
374 _nextIssueRow = null;
375 _nextPrRow = null;
376 _nextAiCommentRows = [];
377 _nextProtectionRule = null;
378 _inserted.length = 0;
379 _updated.length = 0;
380 _notifyCalls.length = 0;
381 _auditCalls.length = 0;
382}
383
384beforeEach(() => {
385 resetState();
386});
387
388async function getTools() {
389 const m = await import("../lib/mcp-tools");
390 return m.__test;
391}
392
393// ---------------------------------------------------------------------------
394// gluecron_create_issue
395// ---------------------------------------------------------------------------
396
397describe("gluecron_create_issue", () => {
398 it("owner can create an issue (happy path)", async () => {
399 const T = await getTools();
400 const result = (await T.createIssue.run(
401 { owner: "alice", repo: "demo", title: "Hello", body: "World" },
402 ownerCtx
403 )) as { number: number; url: string };
404 expect(result.number).toBe(42);
405 expect(result.url).toBe("/alice/demo/issues/42");
406 expect(_inserted.find((r) => r.table === "issues")).toBeTruthy();
407 expect(_auditCalls.some((a) => a.action === "issue.created")).toBe(true);
408 });
409
410 it("anonymous → -32602 INVALID_PARAMS", async () => {
411 const T = await getTools();
412 let caught: McpError | null = null;
413 try {
414 await T.createIssue.run(
415 { owner: "alice", repo: "demo", title: "x" },
416 anonCtx
417 );
418 } catch (err) {
419 if (err instanceof McpError) caught = err;
420 }
421 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
422 expect(caught?.message).toMatch(/authentication required/);
423 });
424
425 it("authed-but-no-write → -32601 METHOD_NOT_FOUND", async () => {
426 const T = await getTools();
427 // Public repo, other user, no collab row → only "read"
428 let caught: McpError | null = null;
429 try {
430 await T.createIssue.run(
431 { owner: "alice", repo: "demo", title: "x" },
432 otherCtx
433 );
434 } catch (err) {
435 if (err instanceof McpError) caught = err;
436 }
437 expect(caught?.code).toBe(ERR_METHOD_NOT_FOUND);
438 expect(caught?.message).toMatch(/no write access/);
439 });
440});
441
442// ---------------------------------------------------------------------------
443// gluecron_comment_issue
444// ---------------------------------------------------------------------------
445
446describe("gluecron_comment_issue", () => {
447 it("owner can comment on an issue", async () => {
448 const T = await getTools();
449 _nextIssueRow = {
450 id: "iss-id-1",
451 number: 42,
452 repositoryId: REPO_ID,
453 authorId: OWNER_ID,
454 state: "open",
455 };
456 const result = (await T.commentIssue.run(
457 { owner: "alice", repo: "demo", number: 42, body: "thanks!" },
458 ownerCtx
459 )) as { commentId: string };
460 expect(result.commentId).toBe("ic-id-1");
461 expect(_inserted.find((r) => r.table === "issue_comments")).toBeTruthy();
462 });
463
464 it("anonymous → -32602", async () => {
465 const T = await getTools();
466 expect(
467 T.commentIssue.run(
468 { owner: "alice", repo: "demo", number: 1, body: "x" },
469 anonCtx
470 )
471 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
472 });
473
474 it("other user (read-only) → -32601", async () => {
475 const T = await getTools();
476 expect(
477 T.commentIssue.run(
478 { owner: "alice", repo: "demo", number: 1, body: "x" },
479 otherCtx
480 )
481 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
482 });
483});
484
485// ---------------------------------------------------------------------------
486// gluecron_close_issue
487// ---------------------------------------------------------------------------
488
489describe("gluecron_close_issue", () => {
490 it("owner can close an open issue", async () => {
491 const T = await getTools();
492 _nextIssueRow = {
493 id: "iss-1",
494 number: 5,
495 repositoryId: REPO_ID,
496 authorId: OWNER_ID,
497 state: "open",
498 };
499 const result = (await T.closeIssue.run(
500 { owner: "alice", repo: "demo", number: 5 },
501 ownerCtx
502 )) as { state: string };
503 expect(result.state).toBe("closed");
504 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
505 });
506
507 it("anonymous → -32602", async () => {
508 const T = await getTools();
509 expect(
510 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
511 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
512 });
513
514 it("other user → -32601", async () => {
515 const T = await getTools();
516 expect(
517 T.closeIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
518 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
519 });
520});
521
522// ---------------------------------------------------------------------------
523// gluecron_reopen_issue
524// ---------------------------------------------------------------------------
525
526describe("gluecron_reopen_issue", () => {
527 it("owner can reopen a closed issue", async () => {
528 const T = await getTools();
529 _nextIssueRow = {
530 id: "iss-1",
531 number: 5,
532 repositoryId: REPO_ID,
533 authorId: OWNER_ID,
534 state: "closed",
535 closedAt: new Date(),
536 };
537 const result = (await T.reopenIssue.run(
538 { owner: "alice", repo: "demo", number: 5 },
539 ownerCtx
540 )) as { state: string };
541 expect(result.state).toBe("open");
542 expect(_updated.find((u) => u.table === "issues")).toBeTruthy();
543 });
544
545 it("anonymous → -32602", async () => {
546 const T = await getTools();
547 expect(
548 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, anonCtx)
549 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
550 });
551
552 it("other user → -32601", async () => {
553 const T = await getTools();
554 expect(
555 T.reopenIssue.run({ owner: "alice", repo: "demo", number: 5 }, otherCtx)
556 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
557 });
558});
559
560// ---------------------------------------------------------------------------
561// gluecron_create_pr
562// ---------------------------------------------------------------------------
563
564describe("gluecron_create_pr", () => {
565 it("owner can open a PR (base defaults to repo default branch)", async () => {
566 const T = await getTools();
567 const result = (await T.createPr.run(
568 {
569 owner: "alice",
570 repo: "demo",
571 title: "Feature X",
572 head_branch: "feat/x",
573 },
574 ownerCtx
575 )) as { number: number; url: string };
576 expect(result.number).toBe(7);
577 expect(result.url).toBe("/alice/demo/pulls/7");
578 const ins = _inserted.find((r) => r.table === "pull_requests");
579 expect(ins).toBeTruthy();
580 expect(ins?.values.baseBranch).toBe("main");
581 expect(ins?.values.headBranch).toBe("feat/x");
582 });
583
584 it("rejects when base === head", async () => {
585 const T = await getTools();
586 let caught: McpError | null = null;
587 try {
588 await T.createPr.run(
589 {
590 owner: "alice",
591 repo: "demo",
592 title: "x",
593 head_branch: "main",
594 base_branch: "main",
595 },
596 ownerCtx
597 );
598 } catch (err) {
599 if (err instanceof McpError) caught = err;
600 }
601 expect(caught?.code).toBe(ERR_INVALID_PARAMS);
602 });
603
604 it("anonymous → -32602", async () => {
605 const T = await getTools();
606 expect(
607 T.createPr.run(
608 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
609 anonCtx
610 )
611 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
612 });
613
614 it("other user → -32601", async () => {
615 const T = await getTools();
616 expect(
617 T.createPr.run(
618 { owner: "alice", repo: "demo", title: "x", head_branch: "feat" },
619 otherCtx
620 )
621 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
622 });
623});
624
625// ---------------------------------------------------------------------------
626// gluecron_get_pr
627// ---------------------------------------------------------------------------
628
629describe("gluecron_get_pr", () => {
630 it("returns full detail for an authed reader", async () => {
631 const T = await getTools();
632 _nextPrRow = {
633 id: "pr-1",
634 number: 7,
635 repositoryId: REPO_ID,
636 authorId: OWNER_ID,
637 title: "Feature",
638 body: "Body",
639 state: "open",
640 baseBranch: "main",
641 headBranch: "feat/x",
642 isDraft: false,
643 createdAt: new Date(),
644 updatedAt: new Date(),
645 mergedAt: null,
646 closedAt: null,
647 };
648 const r = (await T.getPr.run(
649 { owner: "alice", repo: "demo", number: 7 },
650 ownerCtx
651 )) as any;
652 expect(r.number).toBe(7);
653 expect(r.state).toBe("open");
654 expect(r.baseBranch).toBe("main");
655 expect(r.headBranch).toBe("feat/x");
656 expect(r.url).toBe("/alice/demo/pulls/7");
657 });
658
659 it("anonymous → -32602 (write surface requires auth)", async () => {
660 const T = await getTools();
661 expect(
662 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
663 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
664 });
665
666 it("private repo + non-collaborator → -32601 (privacy)", async () => {
667 const T = await getTools();
668 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
669 expect(
670 T.getPr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
671 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
672 });
673});
674
675// ---------------------------------------------------------------------------
676// gluecron_list_prs
677// ---------------------------------------------------------------------------
678
679describe("gluecron_list_prs", () => {
680 it("rejects an unknown state value", async () => {
681 const T = await getTools();
682 expect(
683 T.listPrs.run(
684 { owner: "alice", repo: "demo", state: "garbage" },
685 ownerCtx
686 )
687 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
688 });
689
690 it("anonymous → -32602", async () => {
691 const T = await getTools();
692 expect(
693 T.listPrs.run({ owner: "alice", repo: "demo" }, anonCtx)
694 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
695 });
696
697 it("private repo + non-collaborator → -32601", async () => {
698 const T = await getTools();
699 _nextRepoRow = { ...PUBLIC_REPO_ROW, isPrivate: true };
700 expect(
701 T.listPrs.run({ owner: "alice", repo: "demo" }, otherCtx)
702 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
703 });
704
705 it("authed reader gets a 0-row list when none exist", async () => {
706 const T = await getTools();
707 const r = (await T.listPrs.run(
708 { owner: "alice", repo: "demo" },
709 ownerCtx
710 )) as { total: number; prs: any[] };
711 // The fake DB returns [] for the listPrs path because the chain only
712 // returns canned rows on `.limit(1)` of repositories. The unbounded
713 // PR list shape comes back empty — which is the explicit "no PRs"
714 // happy path we want to assert.
715 expect(typeof r.total).toBe("number");
716 expect(Array.isArray(r.prs)).toBe(true);
717 });
718});
719
720// ---------------------------------------------------------------------------
721// gluecron_comment_pr
722// ---------------------------------------------------------------------------
723
724describe("gluecron_comment_pr", () => {
725 it("owner can comment on a PR", async () => {
726 const T = await getTools();
727 _nextPrRow = {
728 id: "pr-1",
729 number: 7,
730 repositoryId: REPO_ID,
731 authorId: OWNER_ID,
732 state: "open",
733 baseBranch: "main",
734 headBranch: "feat/x",
735 isDraft: false,
736 };
737 const result = (await T.commentPr.run(
738 { owner: "alice", repo: "demo", number: 7, body: "LGTM" },
739 ownerCtx
740 )) as { commentId: string };
741 expect(result.commentId).toBe("pc-id-1");
742 expect(_inserted.find((r) => r.table === "pr_comments")).toBeTruthy();
743 });
744
745 it("anonymous → -32602", async () => {
746 const T = await getTools();
747 expect(
748 T.commentPr.run(
749 { owner: "alice", repo: "demo", number: 1, body: "x" },
750 anonCtx
751 )
752 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
753 });
754
755 it("other user → -32601", async () => {
756 const T = await getTools();
757 expect(
758 T.commentPr.run(
759 { owner: "alice", repo: "demo", number: 1, body: "x" },
760 otherCtx
761 )
762 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
763 });
764});
765
766// ---------------------------------------------------------------------------
767// gluecron_merge_pr
768// ---------------------------------------------------------------------------
769
770describe("gluecron_merge_pr", () => {
771 it("owner can merge a clean open PR (no protection, no conflicts)", async () => {
772 const T = await getTools();
773 _nextPrRow = {
774 id: "pr-1",
775 number: 7,
776 repositoryId: REPO_ID,
777 authorId: OWNER_ID,
778 title: "Feature",
779 body: "",
780 state: "open",
781 baseBranch: "main",
782 headBranch: "feat/x",
783 isDraft: false,
784 };
785 stubBunSpawnSuccess();
786 try {
787 const r = (await T.mergePr.run(
788 { owner: "alice", repo: "demo", number: 7 },
789 ownerCtx
790 )) as { merged: boolean; sha?: string; reason?: string };
791 expect(r.merged).toBe(true);
792 expect(typeof r.sha).toBe("string");
793 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
794 } finally {
795 restoreBunSpawn();
796 }
797 });
798
799 it("draft PR → merged=false with human-readable reason", async () => {
800 const T = await getTools();
801 _nextPrRow = {
802 id: "pr-1",
803 number: 7,
804 repositoryId: REPO_ID,
805 authorId: OWNER_ID,
806 state: "open",
807 baseBranch: "main",
808 headBranch: "feat/x",
809 isDraft: true,
810 };
811 const r = (await T.mergePr.run(
812 { owner: "alice", repo: "demo", number: 7 },
813 ownerCtx
814 )) as { merged: boolean; reason?: string };
815 expect(r.merged).toBe(false);
816 expect(r.reason).toMatch(/draft/i);
817 });
818
819 it("anonymous → -32602", async () => {
820 const T = await getTools();
821 expect(
822 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
823 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
824 });
825
826 it("other user → -32601", async () => {
827 const T = await getTools();
828 expect(
829 T.mergePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
830 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
831 });
832});
833
834// ---------------------------------------------------------------------------
835// gluecron_close_pr
836// ---------------------------------------------------------------------------
837
838describe("gluecron_close_pr", () => {
839 it("owner can close an open PR", async () => {
840 const T = await getTools();
841 _nextPrRow = {
842 id: "pr-1",
843 number: 7,
844 repositoryId: REPO_ID,
845 authorId: OWNER_ID,
846 state: "open",
847 baseBranch: "main",
848 headBranch: "feat/x",
849 isDraft: false,
850 };
851 const r = (await T.closePr.run(
852 { owner: "alice", repo: "demo", number: 7 },
853 ownerCtx
854 )) as { state: string };
855 expect(r.state).toBe("closed");
856 expect(_updated.some((u) => u.table === "pull_requests")).toBe(true);
857 });
858
859 it("anonymous → -32602", async () => {
860 const T = await getTools();
861 expect(
862 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, anonCtx)
863 ).rejects.toMatchObject({ code: ERR_INVALID_PARAMS });
864 });
865
866 it("other user → -32601", async () => {
867 const T = await getTools();
868 expect(
869 T.closePr.run({ owner: "alice", repo: "demo", number: 1 }, otherCtx)
870 ).rejects.toMatchObject({ code: ERR_METHOD_NOT_FOUND });
871 });
872});
873
874// ---------------------------------------------------------------------------
875// Default registry contains the new tools
876// ---------------------------------------------------------------------------
877
878describe("defaultTools — registry shape", () => {
879 it("includes all 10 K1 write-surface tools", async () => {
880 const { defaultTools } = await import("../lib/mcp-tools");
881 const tools = defaultTools();
882 const expected = [
883 "gluecron_create_issue",
884 "gluecron_comment_issue",
885 "gluecron_close_issue",
886 "gluecron_reopen_issue",
887 "gluecron_create_pr",
888 "gluecron_get_pr",
889 "gluecron_list_prs",
890 "gluecron_comment_pr",
891 "gluecron_merge_pr",
892 "gluecron_close_pr",
893 ];
894 for (const name of expected) {
895 expect(tools[name]).toBeTruthy();
896 expect(tools[name].tool.name).toBe(name);
897 expect(tools[name].tool.inputSchema.type).toBe("object");
898 }
899 });
900});
Modifiedsrc/db/schema.ts+4−0View fileUnifiedSplit
148148 allowForcePush: boolean("allow_force_push").default(false).notNull(),
149149 allowDeletion: boolean("allow_deletion").default(false).notNull(),
150150 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
151 // K2 — Auto-merge opt-in. When true, the autopilot ticker may auto-merge
152 // a PR whose base branch matches this rule, provided every gate the
153 // manual-merge path enforces is green. Default-deny.
154 enableAutoMerge: boolean("enable_auto_merge").default(false).notNull(),
151155 createdAt: timestamp("created_at").defaultNow().notNull(),
152156 updatedAt: timestamp("updated_at").defaultNow().notNull(),
153157 },
Addedsrc/lib/ai-build-tasks.ts+364−0View fileUnifiedSplit
1/**
2 * Block K3 — AI-build dispatcher (issues tagged `ai:build`).
3 *
4 * Walks open issues whose label set includes a (case-insensitive) `ai:build`
5 * label and, for each one, dispatches a Spec-to-PR build off the existing
6 * `src/lib/spec-to-pr.ts` pipeline. Cheap idempotency: every dispatched
7 * issue gets a marker comment containing `AI_BUILD_MARKER`; on subsequent
8 * ticks that marker tells us "already handled, skip".
9 *
10 * Every dispatch is fire-and-forget — failures are logged and swallowed so
11 * one bad issue can't wedge the autopilot tick.
12 *
13 * Inputs are dependency-injected so the autopilot test suite can exercise
14 * the loop without touching the DB or the AI client.
15 *
16 * NOT in this module:
17 * - The Spec-to-PR pipeline itself (lives in `src/lib/spec-to-pr.ts`).
18 * - The autopilot wrapper / timing concerns (lives in `src/lib/autopilot.ts`).
19 */
20
21import { and, eq, ilike, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 issueComments,
25 issueLabels,
26 issues,
27 labels,
28 pullRequests,
29 repositories,
30 users,
31} from "../db/schema";
32import { extractClosingRefsMulti } from "./close-keywords";
33import { buildSpecFromIssue } from "../routes/specs";
34
35/**
36 * Stable marker baked into the issue comment so subsequent ticks can detect
37 * "already dispatched" without race conditions. Versioned so we can bump
38 * the contract later without re-dispatching every old issue.
39 */
40export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
41
42const DEFAULT_MAX_ISSUES_PER_TICK = 20;
43
44export interface AiBuildCandidate {
45 issueId: string;
46 issueNumber: number;
47 issueTitle: string;
48 issueBody: string | null;
49 repositoryId: string;
50 authorUserId: string;
51 /** Resolved repo owner username; null if the row is somehow orphaned. */
52 ownerUsername: string | null;
53 /** Repo name (for logging/branch naming downstream). */
54 repoName: string;
55 /** Default branch — passed to spec-to-PR as `baseRef`. */
56 defaultBranch: string;
57}
58
59export interface SpecDispatcher {
60 /**
61 * Same signature as `createSpecPR` in `src/lib/spec-to-pr.ts`.
62 * Returns `ok:false` when ANTHROPIC_API_KEY is unset (gracefully).
63 */
64 (args: {
65 repoId: string;
66 spec: string;
67 baseRef: string;
68 userId: string;
69 }): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }>;
70}
71
72export interface AiBuildTaskDeps {
73 /** Override how candidates are sourced (DI for tests). */
74 findCandidates?: (limit: number) => Promise<AiBuildCandidate[]>;
75 /** Override whether a candidate is already marker-tagged (DI for tests). */
76 hasDispatchMarker?: (issueId: string) => Promise<boolean>;
77 /**
78 * Override whether an open PR already closes this issue via close-keywords.
79 * Returns true if some open PR's title/body references `closes #N`.
80 */
81 hasOpenLinkedPr?: (
82 repositoryId: string,
83 issueNumber: number
84 ) => Promise<boolean>;
85 /** Inject the dispatcher (real one lives in spec-to-pr.ts). */
86 dispatcher?: SpecDispatcher;
87 /** Inject the marker-comment writer (DI for tests). */
88 postMarkerComment?: (
89 issueId: string,
90 authorUserId: string,
91 body: string
92 ) => Promise<void>;
93 /** Override the per-tick cap. */
94 maxIssuesPerTick?: number;
95}
96
97export interface AiBuildTaskSummary {
98 queued: number;
99 skipped: number;
100}
101
102/**
103 * Default implementation of `findCandidates`. Joins open issues to their
104 * label set, filters on a case-insensitive `ai:build` label name, and
105 * resolves repo metadata + owner so the dispatcher has everything it needs.
106 *
107 * Skips archived repos. Cap is applied at the SQL layer.
108 */
109async function defaultFindCandidates(
110 limit: number
111): Promise<AiBuildCandidate[]> {
112 try {
113 const rows = await db
114 .select({
115 issueId: issues.id,
116 issueNumber: issues.number,
117 issueTitle: issues.title,
118 issueBody: issues.body,
119 repositoryId: issues.repositoryId,
120 authorUserId: issues.authorId,
121 ownerUsername: users.username,
122 repoName: repositories.name,
123 defaultBranch: repositories.defaultBranch,
124 })
125 .from(issues)
126 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
127 .leftJoin(users, eq(users.id, repositories.ownerId))
128 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
129 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
130 .where(
131 and(
132 eq(issues.state, "open"),
133 eq(repositories.isArchived, false),
134 ilike(labels.name, "ai:build")
135 )
136 )
137 .limit(limit);
138 return rows.map((r) => ({
139 issueId: r.issueId,
140 issueNumber: r.issueNumber,
141 issueTitle: r.issueTitle,
142 issueBody: r.issueBody,
143 repositoryId: r.repositoryId,
144 authorUserId: r.authorUserId,
145 ownerUsername: r.ownerUsername ?? null,
146 repoName: r.repoName,
147 defaultBranch: r.defaultBranch || "main",
148 }));
149 } catch (err) {
150 console.error("[autopilot] ai-build: candidate query failed:", err);
151 return [];
152 }
153}
154
155/**
156 * Default implementation of `hasDispatchMarker`. Looks for ANY comment on
157 * the issue whose body contains `AI_BUILD_MARKER`.
158 */
159async function defaultHasDispatchMarker(issueId: string): Promise<boolean> {
160 try {
161 const rows = await db
162 .select({ id: issueComments.id })
163 .from(issueComments)
164 .where(
165 and(
166 eq(issueComments.issueId, issueId),
167 sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
168 )
169 )
170 .limit(1);
171 return rows.length > 0;
172 } catch {
173 // Be conservative: on DB error, treat as already-dispatched so we don't
174 // spam the issue on a transient outage.
175 return true;
176 }
177}
178
179/**
180 * Default implementation of `hasOpenLinkedPr`. Scans open PRs in the same
181 * repo and uses `extractClosingRefsMulti` against title + body to see if
182 * any of them references the issue with a closing keyword.
183 */
184async function defaultHasOpenLinkedPr(
185 repositoryId: string,
186 issueNumber: number
187): Promise<boolean> {
188 try {
189 const rows = await db
190 .select({ title: pullRequests.title, body: pullRequests.body })
191 .from(pullRequests)
192 .where(
193 and(
194 eq(pullRequests.repositoryId, repositoryId),
195 eq(pullRequests.state, "open")
196 )
197 );
198 for (const r of rows) {
199 const refs = extractClosingRefsMulti([r.title, r.body]);
200 if (refs.includes(issueNumber)) return true;
201 }
202 return false;
203 } catch {
204 return false;
205 }
206}
207
208/**
209 * Default dispatcher. Dynamic-imports `createSpecPR` from `spec-to-pr.ts`
210 * the same way `src/routes/specs.tsx` does, so we tolerate optional builds
211 * where the module might be absent or missing the export.
212 */
213async function defaultDispatcher(args: {
214 repoId: string;
215 spec: string;
216 baseRef: string;
217 userId: string;
218}): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }> {
219 try {
220 const mod: any = await import("./spec-to-pr");
221 const fn = mod && (mod.createSpecPR || mod.default?.createSpecPR);
222 if (typeof fn !== "function") {
223 return { ok: false, error: "createSpecPR not exported by spec-to-pr.ts" };
224 }
225 const res = await fn(args);
226 // Normalise — createSpecPR may return additional fields; we only need ok/prNumber.
227 if (res && res.ok) return { ok: true, prNumber: res.prNumber };
228 return {
229 ok: false,
230 error: (res && "error" in res && res.error) || "unknown error",
231 };
232 } catch (err) {
233 return {
234 ok: false,
235 error: err instanceof Error ? err.message : String(err),
236 };
237 }
238}
239
240/**
241 * Default marker-comment writer. Posts a single issue comment authored by
242 * the issue's own author so we don't need a separate "autopilot" user
243 * (avoids dependency on a system user existing).
244 */
245async function defaultPostMarkerComment(
246 issueId: string,
247 authorUserId: string,
248 body: string
249): Promise<void> {
250 try {
251 await db.insert(issueComments).values({
252 issueId,
253 authorId: authorUserId,
254 body,
255 });
256 } catch (err) {
257 console.error("[autopilot] ai-build: marker insert failed:", err);
258 }
259}
260
261/**
262 * One iteration of the ai-build dispatcher. Returns a summary suitable
263 * for the autopilot tick log. Never throws.
264 */
265export async function runAiBuildTaskOnce(
266 deps: AiBuildTaskDeps = {}
267): Promise<AiBuildTaskSummary> {
268 const limit = deps.maxIssuesPerTick ?? DEFAULT_MAX_ISSUES_PER_TICK;
269 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
270 const hasDispatchMarker = deps.hasDispatchMarker ?? defaultHasDispatchMarker;
271 const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr;
272 const dispatcher = deps.dispatcher ?? defaultDispatcher;
273 const postMarkerComment = deps.postMarkerComment ?? defaultPostMarkerComment;
274
275 let candidates: AiBuildCandidate[] = [];
276 try {
277 candidates = await findCandidates(limit);
278 } catch (err) {
279 console.error("[autopilot] ai-build: findCandidates threw:", err);
280 return { queued: 0, skipped: 0 };
281 }
282
283 let queued = 0;
284 let skipped = 0;
285
286 for (const cand of candidates) {
287 try {
288 // Sanity: we need an owner to build the on-disk path further down.
289 if (!cand.ownerUsername) {
290 skipped += 1;
291 continue;
292 }
293
294 // Skip if there's already an open PR that closes this issue via
295 // close-keyword convention.
296 if (await hasOpenLinkedPr(cand.repositoryId, cand.issueNumber)) {
297 skipped += 1;
298 continue;
299 }
300
301 // Skip if we've already dispatched (marker comment present).
302 if (await hasDispatchMarker(cand.issueId)) {
303 skipped += 1;
304 continue;
305 }
306
307 const spec = buildSpecFromIssue({
308 number: cand.issueNumber,
309 title: cand.issueTitle,
310 body: cand.issueBody,
311 });
312
313 // Post the marker BEFORE dispatching. This way, if the dispatcher is
314 // slow or transiently fails, a subsequent tick won't double-fire.
315 // The marker is the source of truth for idempotency.
316 await postMarkerComment(
317 cand.issueId,
318 cand.authorUserId,
319 `${AI_BUILD_MARKER}\nQueued an AI-build off this issue's spec. The PR (if any) will reference this issue via "Closes #${cand.issueNumber}".`
320 );
321
322 // Fire the dispatcher. Errors are swallowed — the marker has already
323 // been posted so we won't retry. (Operators can delete the marker
324 // comment to force a re-run.)
325 try {
326 const res = await dispatcher({
327 repoId: cand.repositoryId,
328 spec,
329 baseRef: cand.defaultBranch,
330 userId: cand.authorUserId,
331 });
332 if (!res.ok) {
333 console.error(
334 `[autopilot] ai-build: dispatcher failed for issue=${cand.issueId}: ${res.error}`
335 );
336 }
337 } catch (err) {
338 console.error(
339 `[autopilot] ai-build: dispatcher threw for issue=${cand.issueId}:`,
340 err
341 );
342 }
343 queued += 1;
344 } catch (err) {
345 console.error(
346 `[autopilot] ai-build: per-issue failure for issue=${cand.issueId}:`,
347 err
348 );
349 skipped += 1;
350 }
351 }
352
353 return { queued, skipped };
354}
355
356/** Test-only surface. */
357export const __test = {
358 defaultFindCandidates,
359 defaultHasDispatchMarker,
360 defaultHasOpenLinkedPr,
361 defaultDispatcher,
362 defaultPostMarkerComment,
363 DEFAULT_MAX_ISSUES_PER_TICK,
364};
Addedsrc/lib/auto-merge.ts+399−0View fileUnifiedSplit
1/**
2 * Block K2 — AI-gated auto-merge evaluator.
3 *
4 * Pure decision helper. Given a PR, answer the single question:
5 *
6 * "Should this PR auto-merge right now?"
7 *
8 * This module is intentionally a parallel surface to the manual-merge path
9 * in `src/routes/pulls.tsx` — it MUST NOT relax any rule that the manual
10 * path enforces. The rule of thumb: anything an autopilot can do, a human
11 * could have done by clicking Merge themselves.
12 *
13 * Decision rules (all must hold for `merge: true`):
14 *
15 * 1. A `branch_protection` rule matches the base branch AND
16 * `enableAutoMerge=true` on that rule. Default-deny when no rule
17 * matches — auto-merge is strictly opt-in per branch.
18 * 2. PR is not a draft.
19 * 3. `evaluateProtection` (the existing branch-protection helper)
20 * returns allowed=true for this PR's context, including required
21 * status checks via `listRequiredChecks` / `passingCheckNames`.
22 * 4. When `requireAiApproval=true` on the rule, there is an
23 * AI-review comment carrying `AI_REVIEW_MARKER` whose body looks
24 * like an approval (see `aiCommentLooksApproved`).
25 * 5. (Optional) PR diff is within `opts.maxChangedFiles` /
26 * `opts.maxChangedLines` if provided.
27 *
28 * K3 (the autopilot ticker) is the only intended caller — this module
29 * deliberately does NOT execute the merge. It only decides.
30 */
31
32import { and, eq } from "drizzle-orm";
33import { db } from "../db";
34import { branchProtection, prComments } from "../db/schema";
35import type { BranchProtection } from "../db/schema";
36import {
37 countHumanApprovals,
38 evaluateProtection,
39 listRequiredChecks,
40 matchProtection,
41 passingCheckNames,
42} from "./branch-protection";
43import { AI_REVIEW_MARKER } from "./ai-review";
44import { audit } from "./notify";
45import { getRepoPath } from "../git/repository";
46
47// ---------------------------------------------------------------------------
48// Public types
49// ---------------------------------------------------------------------------
50
51export interface AutoMergeContext {
52 pullRequestId: string;
53 repositoryId: string;
54 baseBranch: string;
55 isDraft: boolean;
56 authorUserId: string;
57}
58
59export interface AutoMergeDecision {
60 merge: boolean;
61 reason: string;
62 blocking?: string[];
63}
64
65export interface AutoMergeOptions {
66 maxChangedFiles?: number;
67 maxChangedLines?: number;
68 /** Injectable clock for tests. Unused today but reserved for K3 cooldowns. */
69 now?: Date;
70 /**
71 * Test-only injection of an owner/repo pair so the diff-size check can
72 * shell out to the bare repo. In production the caller resolves these
73 * from the repository row before calling. When omitted, the size cap is
74 * skipped (treated as "no cap configured").
75 */
76 ownerName?: string;
77 repoName?: string;
78 /** Head branch for the diff-size check. Required when caps are set. */
79 headBranch?: string;
80}
81
82// ---------------------------------------------------------------------------
83// Pure decision helper
84// ---------------------------------------------------------------------------
85
86/**
87 * Internal pure decision helper. All DB-derived facts are passed in as
88 * arguments so tests can drive every branch without a real database.
89 */
90export function decideAutoMerge(args: {
91 rule: BranchProtection | null;
92 isDraft: boolean;
93 aiApproved: boolean;
94 humanApprovalCount: number;
95 hasFailedGates: boolean;
96 passingCheckNames: string[];
97 requiredCheckNames: string[];
98 diffStats?: { files: number; lines: number } | null;
99 caps?: { maxChangedFiles?: number; maxChangedLines?: number };
100}): AutoMergeDecision {
101 const blocking: string[] = [];
102
103 // 1. Default-deny: must have a matching rule AND it must opt in.
104 if (!args.rule) {
105 blocking.push(
106 "No branch_protection rule matches the base branch — auto-merge is default-deny."
107 );
108 return { merge: false, reason: blocking[0], blocking };
109 }
110 if (!args.rule.enableAutoMerge) {
111 blocking.push(
112 `Branch protection '${args.rule.pattern}' does not have auto-merge enabled.`
113 );
114 }
115
116 // 2. Draft check.
117 if (args.isDraft) {
118 blocking.push("Pull request is marked as a draft.");
119 }
120
121 // 3. Reuse the manual-merge gating exactly. Whatever blocks a human Merge
122 // click must also block auto-merge.
123 const decision = evaluateProtection(
124 args.rule,
125 {
126 aiApproved: args.aiApproved,
127 humanApprovalCount: args.humanApprovalCount,
128 gateResultGreen: !args.hasFailedGates,
129 hasFailedGates: args.hasFailedGates,
130 passingCheckNames: args.passingCheckNames,
131 },
132 args.requiredCheckNames
133 );
134 if (!decision.allowed) {
135 for (const r of decision.reasons) blocking.push(r);
136 }
137
138 // 4. AI-approval semantics — already covered by evaluateProtection when
139 // requireAiApproval=true. We do NOT double-add the same reason here; the
140 // caller is responsible for sourcing `aiApproved` from a marker-bearing
141 // AI comment that survives `aiCommentLooksApproved`.
142
143 // 5. Optional size cap.
144 if (args.caps && args.diffStats) {
145 const { maxChangedFiles, maxChangedLines } = args.caps;
146 if (
147 typeof maxChangedFiles === "number" &&
148 args.diffStats.files > maxChangedFiles
149 ) {
150 blocking.push(
151 `PR changes ${args.diffStats.files} file(s); auto-merge cap is ${maxChangedFiles}.`
152 );
153 }
154 if (
155 typeof maxChangedLines === "number" &&
156 args.diffStats.lines > maxChangedLines
157 ) {
158 blocking.push(
159 `PR changes ${args.diffStats.lines} line(s); auto-merge cap is ${maxChangedLines}.`
160 );
161 }
162 }
163
164 if (blocking.length === 0) {
165 return {
166 merge: true,
167 reason: `All auto-merge conditions met for '${args.rule.pattern}'.`,
168 };
169 }
170 return { merge: false, reason: blocking.join(" "), blocking };
171}
172
173// ---------------------------------------------------------------------------
174// AI-comment approval heuristic
175// ---------------------------------------------------------------------------
176
177/**
178 * Decide whether a single AI-review comment body indicates approval.
179 *
180 * `triggerAiReview` (in src/lib/ai-review.ts) emits a marker-bearing
181 * summary comment in two shapes:
182 *
183 * - On success: starts with `## AI Code Review`, followed by either
184 * `"no blocking issues found"` (approved) or
185 * `"flagged N item(s) for human attention"` (not approved).
186 * - On API failure: starts with `## AI review unavailable`.
187 *
188 * Per spec, approval is defined negatively:
189 * - body does NOT contain `"AI review unavailable"`, AND
190 * - body does NOT contain `"severity: blocking"` (case-insensitive).
191 *
192 * We additionally treat the "flagged N item(s)" verdict as not-approved
193 * because that's what triggerAiReview itself uses to signal blocking
194 * findings, even though it doesn't use the `severity: blocking` token.
195 * If future reviewers do emit `severity: blocking`, that branch still
196 * matches via the case-insensitive substring rule.
197 */
198export function aiCommentLooksApproved(body: string): boolean {
199 if (!body) return false;
200 const lower = body.toLowerCase();
201 if (lower.includes("ai review unavailable")) return false;
202 if (lower.includes("severity: blocking")) return false;
203 // triggerAiReview's "flagged N item(s)" wording — explicit non-approval.
204 if (/flagged \d+ item/i.test(body)) return false;
205 return true;
206}
207
208// ---------------------------------------------------------------------------
209// DB-backed orchestrator
210// ---------------------------------------------------------------------------
211
212/**
213 * Locate the AI-review summary comment for a PR and return whether it
214 * looks like an approval. Returns false when no marker-bearing AI
215 * comment is found — i.e. AI review hasn't completed yet.
216 */
217async function aiApprovedForPr(pullRequestId: string): Promise<boolean> {
218 try {
219 const rows = await db
220 .select({ body: prComments.body })
221 .from(prComments)
222 .where(
223 and(
224 eq(prComments.pullRequestId, pullRequestId),
225 eq(prComments.isAiReview, true)
226 )
227 );
228 const markerRows = rows.filter((r) =>
229 (r.body || "").includes(AI_REVIEW_MARKER)
230 );
231 if (markerRows.length === 0) return false;
232 // If *any* marker comment looks approved, count as approved. In
233 // practice triggerAiReview writes exactly one summary marker, so this
234 // collapses to the single comment's verdict.
235 return markerRows.some((r) => aiCommentLooksApproved(r.body || ""));
236 } catch {
237 return false;
238 }
239}
240
241/**
242 * Best-effort diff stats for size caps. Shells out to `git diff
243 * --numstat base...head` in the bare repo. Returns null on any error so
244 * the caller can decide whether to fail-closed (we currently treat null
245 * stats as "size unknown → don't enforce the cap" which is permissive
246 * but documented).
247 */
248async function diffStatsForBranches(
249 ownerName: string,
250 repoName: string,
251 baseBranch: string,
252 headBranch: string
253): Promise<{ files: number; lines: number } | null> {
254 try {
255 const cwd = getRepoPath(ownerName, repoName);
256 const proc = Bun.spawn(
257 ["git", "diff", "--numstat", `${baseBranch}...${headBranch}`, "--"],
258 { cwd, stdout: "pipe", stderr: "pipe" }
259 );
260 const text = await new Response(proc.stdout).text();
261 await proc.exited;
262 let files = 0;
263 let lines = 0;
264 for (const line of text.split("\n")) {
265 if (!line.trim()) continue;
266 const [add, del] = line.split("\t");
267 files += 1;
268 const a = add === "-" ? 0 : parseInt(add, 10) || 0;
269 const d = del === "-" ? 0 : parseInt(del, 10) || 0;
270 lines += a + d;
271 }
272 return { files, lines };
273 } catch {
274 return null;
275 }
276}
277
278/**
279 * Headline entry point. Resolves the DB-derived facts and delegates to
280 * `decideAutoMerge`. K3 (autopilot ticker) calls this; the optional
281 * AI-review completion path may also call it to flip the merge-now bit.
282 */
283export async function evaluateAutoMerge(
284 ctx: AutoMergeContext,
285 opts: AutoMergeOptions = {}
286): Promise<AutoMergeDecision> {
287 // 1. Match the protection rule. matchProtection returns the most
288 // specific rule, or null when none configured.
289 const rule = await matchProtection(ctx.repositoryId, ctx.baseBranch);
290
291 // 2. Source the AI-approval signal only if the rule actually requires
292 // it. Avoids the DB hit on rules that don't care.
293 const aiApproved =
294 rule && rule.requireAiApproval ? await aiApprovedForPr(ctx.pullRequestId) : true;
295
296 // 3. Human approvals — same query the manual-merge path uses.
297 const humanApprovalCount = await countHumanApprovals(ctx.pullRequestId);
298
299 // 4. Required-checks matrix. Skip the DB hit if the rule has no
300 // matched required checks.
301 let requiredCheckNames: string[] = [];
302 let passing: string[] = [];
303 if (rule) {
304 try {
305 const required = await listRequiredChecks(rule.id);
306 requiredCheckNames = required.map((r) => r.checkName);
307 if (requiredCheckNames.length > 0) {
308 // We don't have the head SHA in the context. Passing
309 // commitSha=null causes passingCheckNames to scan the most
310 // recent 200 rows for the repo, which is good enough for the
311 // K2 decision surface — the K3 ticker is the source of truth
312 // for fresh status. This matches the spirit of the manual path
313 // (which uses the freshly-resolved head SHA).
314 passing = await passingCheckNames(ctx.repositoryId, null);
315 }
316 } catch {
317 requiredCheckNames = [];
318 passing = [];
319 }
320 }
321
322 // 5. hasFailedGates: derived from required checks. We don't run the
323 // full `runAllGateChecks` here because that's a heavyweight side-
324 // effecting call; K3 is expected to have already triggered gate runs.
325 // Treat "any required check is not in the passing set" as failing.
326 const hasFailedGates =
327 requiredCheckNames.length > 0 &&
328 requiredCheckNames.some((n) => !passing.includes(n));
329
330 // 6. Optional size cap.
331 let diffStats: { files: number; lines: number } | null = null;
332 const hasCap =
333 typeof opts.maxChangedFiles === "number" ||
334 typeof opts.maxChangedLines === "number";
335 if (hasCap && opts.ownerName && opts.repoName && opts.headBranch) {
336 diffStats = await diffStatsForBranches(
337 opts.ownerName,
338 opts.repoName,
339 ctx.baseBranch,
340 opts.headBranch
341 );
342 }
343
344 return decideAutoMerge({
345 rule,
346 isDraft: ctx.isDraft,
347 aiApproved,
348 humanApprovalCount,
349 hasFailedGates,
350 passingCheckNames: passing,
351 requiredCheckNames,
352 diffStats,
353 caps: hasCap
354 ? {
355 maxChangedFiles: opts.maxChangedFiles,
356 maxChangedLines: opts.maxChangedLines,
357 }
358 : undefined,
359 });
360}
361
362// ---------------------------------------------------------------------------
363// Audit helper
364// ---------------------------------------------------------------------------
365
366/**
367 * Record an auto-merge attempt in the audit log. K3 should call this
368 * once per evaluation tick so operators can see the decision trail.
369 * Uses `auto_merge.evaluated` for any decision, and `auto_merge.merged`
370 * separately when K3 actually performs the merge (K3's responsibility).
371 */
372export async function recordAutoMergeAttempt(
373 repositoryId: string,
374 pullRequestId: string,
375 decision: AutoMergeDecision
376): Promise<void> {
377 await audit({
378 repositoryId,
379 action: "auto_merge.evaluated",
380 targetType: "pull_request",
381 targetId: pullRequestId,
382 metadata: {
383 merge: decision.merge,
384 reason: decision.reason,
385 blocking: decision.blocking ?? [],
386 },
387 });
388}
389
390// ---------------------------------------------------------------------------
391// Test-only surface
392// ---------------------------------------------------------------------------
393
394export const __test = {
395 decideAutoMerge,
396 aiCommentLooksApproved,
397 diffStatsForBranches,
398 aiApprovedForPr,
399};
Modifiedsrc/lib/autopilot.ts+371−2View fileUnifiedSplit
99 * try/caught so a single failure never blocks the others.
1010 */
1111
12import { sql } from "drizzle-orm";
12import { and, eq, gte, sql } from "drizzle-orm";
1313import { db } from "../db";
14import { mergeQueueEntries, repoDependencies } from "../db/schema";
14import {
15 mergeQueueEntries,
16 prComments,
17 pullRequests,
18 repoDependencies,
19 repositories,
20 users,
21} from "../db/schema";
1522import { syncAllDue } from "./mirrors";
1623import { peekHead } from "./merge-queue";
1724import { sendDigestsToAll } from "./email-digest";
1825import { scanRepositoryForAlerts } from "./advisories";
1926import { releaseExpiredWaitTimers } from "./environments";
2027import { runScheduledWorkflowsTick } from "./scheduled-workflows";
28import {
29 evaluateAutoMerge,
30 recordAutoMergeAttempt,
31 type AutoMergeContext,
32 type AutoMergeDecision,
33} from "./auto-merge";
34import { matchProtection } from "./branch-protection";
35import { performMerge, type PerformMergeResult } from "./pr-merge";
36import { audit } from "./notify";
37import { runAiBuildTaskOnce } from "./ai-build-tasks";
2138
2239export interface AutopilotTaskResult {
2340 name: string;
5067
5168const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
5269const ADVISORY_RESCAN_BATCH = 5;
70/** K3 — recency window for auto-merge candidate selection. */
71const AUTO_MERGE_LOOKBACK_HOURS = 24;
72/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
73const AUTO_MERGE_MAX_PER_TICK = 50;
74/** K3 — stable marker for the auto-merge audit comment. */
75const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
5376
5477/**
5578 * Default task set. Each task is a thin wrapper around an existing locked
93116 await runScheduledWorkflowsTick();
94117 },
95118 },
119 {
120 name: "auto-merge-sweep",
121 run: async () => {
122 await runAutoMergeSweep();
123 },
124 },
125 {
126 name: "ai-build-from-issues",
127 run: async () => {
128 const summary = await runAiBuildTaskOnce();
129 console.log(
130 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
131 );
132 },
133 },
96134 ];
97135}
98136
137// ---------------------------------------------------------------------------
138// K3 — auto-merge-sweep
139// ---------------------------------------------------------------------------
140
141interface SweepCandidate {
142 prId: string;
143 prNumber: number;
144 prTitle: string;
145 prBody: string | null;
146 baseBranch: string;
147 headBranch: string;
148 isDraft: boolean;
149 repositoryId: string;
150 authorUserId: string;
151 ownerUsername: string | null;
152 repoName: string;
153 state: string;
154}
155
156export interface AutoMergeSweepDeps {
157 /** Inject candidate-finder for tests. */
158 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
159 /** Inject evaluator for tests. */
160 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
161 /** Inject the merge executor for tests. */
162 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
163 /** Inject the audit-recording side-effect for tests. */
164 recordAttempt?: (
165 repoId: string,
166 prId: string,
167 decision: AutoMergeDecision
168 ) => Promise<void>;
169 /** Inject the audit/comment side-effects for the merged path (tests). */
170 onMerged?: (
171 cand: SweepCandidate,
172 result: PerformMergeResult
173 ) => Promise<void>;
174 /** Inject the audit side-effect for the merge-failed path (tests). */
175 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
176 /** Inject the AI-key short-circuit signal for tests. */
177 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
178}
179
180export interface AutoMergeSweepSummary {
181 evaluated: number;
182 merged: number;
183 blocked: number;
184}
185
186/**
187 * Default candidate-finder. Selects open, non-draft PRs from non-archived
188 * repos whose `updated_at` is within the lookback window. Joins repo +
189 * owner so the merge executor doesn't need extra round trips. Cap is
190 * enforced at the SQL layer.
191 */
192async function defaultFindAutoMergeCandidates(
193 lookbackHours: number,
194 limit: number
195): Promise<SweepCandidate[]> {
196 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
197 try {
198 const rows = await db
199 .select({
200 prId: pullRequests.id,
201 prNumber: pullRequests.number,
202 prTitle: pullRequests.title,
203 prBody: pullRequests.body,
204 baseBranch: pullRequests.baseBranch,
205 headBranch: pullRequests.headBranch,
206 isDraft: pullRequests.isDraft,
207 repositoryId: pullRequests.repositoryId,
208 authorUserId: pullRequests.authorId,
209 ownerUsername: users.username,
210 repoName: repositories.name,
211 state: pullRequests.state,
212 })
213 .from(pullRequests)
214 .innerJoin(
215 repositories,
216 eq(repositories.id, pullRequests.repositoryId)
217 )
218 .leftJoin(users, eq(users.id, repositories.ownerId))
219 .where(
220 and(
221 eq(pullRequests.state, "open"),
222 eq(pullRequests.isDraft, false),
223 eq(repositories.isArchived, false),
224 gte(pullRequests.updatedAt, cutoff)
225 )
226 )
227 .limit(limit);
228 return rows.map((r) => ({
229 prId: r.prId,
230 prNumber: r.prNumber,
231 prTitle: r.prTitle,
232 prBody: r.prBody,
233 baseBranch: r.baseBranch,
234 headBranch: r.headBranch,
235 isDraft: r.isDraft,
236 repositoryId: r.repositoryId,
237 authorUserId: r.authorUserId,
238 ownerUsername: r.ownerUsername ?? null,
239 repoName: r.repoName,
240 state: r.state,
241 }));
242 } catch (err) {
243 console.error("[autopilot] auto-merge: candidate query failed:", err);
244 return [];
245 }
246}
247
248/**
249 * Determine whether the matched branch_protection rule on this PR
250 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
251 * case the AI-approval check would inevitably fail downstream, so we
252 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
253 * — keeps the log readable and prevents misleading "AI review unavailable"
254 * lines in the audit trail.
255 */
256async function defaultShouldShortCircuitAi(
257 cand: SweepCandidate
258): Promise<boolean> {
259 if (process.env.ANTHROPIC_API_KEY) return false;
260 try {
261 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
262 return !!(rule && rule.requireAiApproval);
263 } catch {
264 return false;
265 }
266}
267
268/**
269 * Default success-path: post an `auto_merge.merged` audit row + a stable
270 * marker comment on the PR so a partial-merge retry doesn't double-post.
271 * Both are best-effort; failures are logged not thrown.
272 */
273async function defaultOnMerged(
274 cand: SweepCandidate,
275 result: PerformMergeResult
276): Promise<void> {
277 try {
278 await audit({
279 repositoryId: cand.repositoryId,
280 action: "auto_merge.merged",
281 targetType: "pull_request",
282 targetId: cand.prId,
283 metadata: {
284 prNumber: cand.prNumber,
285 baseBranch: cand.baseBranch,
286 headBranch: cand.headBranch,
287 closedIssueNumbers: result.closedIssueNumbers,
288 resolvedFiles: result.resolvedFiles,
289 },
290 });
291 } catch (err) {
292 console.error("[autopilot] auto-merge: merged audit failed:", err);
293 }
294 try {
295 await db.insert(prComments).values({
296 pullRequestId: cand.prId,
297 authorId: cand.authorUserId,
298 isAiReview: true,
299 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
300 });
301 } catch (err) {
302 console.error("[autopilot] auto-merge: comment insert failed:", err);
303 }
304}
305
306/** Default failure-path: only an audit row; no comment (we may retry). */
307async function defaultOnMergeFailed(
308 cand: SweepCandidate,
309 error: string
310): Promise<void> {
311 try {
312 await audit({
313 repositoryId: cand.repositoryId,
314 action: "auto_merge.merge_failed",
315 targetType: "pull_request",
316 targetId: cand.prId,
317 metadata: {
318 prNumber: cand.prNumber,
319 baseBranch: cand.baseBranch,
320 headBranch: cand.headBranch,
321 error,
322 },
323 });
324 } catch (err) {
325 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
326 }
327}
328
329/**
330 * Execute one sweep over recently-updated open PRs. For each, evaluate
331 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
332 * record the merged/merge-failed audit row + comment. Always record the
333 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
334 *
335 * Returns a counts summary that the autopilot prints as the tick log line.
336 * Never throws.
337 */
338export async function runAutoMergeSweep(
339 deps: AutoMergeSweepDeps = {}
340): Promise<AutoMergeSweepSummary> {
341 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
342 const evaluate =
343 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
344 const merge =
345 deps.merge ??
346 (async (cand) => {
347 if (!cand.ownerUsername) {
348 return {
349 ok: false,
350 error: "owner username unresolved",
351 closedIssueNumbers: [],
352 resolvedFiles: [],
353 };
354 }
355 return performMerge({
356 pr: {
357 id: cand.prId,
358 number: cand.prNumber,
359 title: cand.prTitle,
360 body: cand.prBody,
361 baseBranch: cand.baseBranch,
362 headBranch: cand.headBranch,
363 repositoryId: cand.repositoryId,
364 authorId: cand.authorUserId,
365 state: cand.state as "open",
366 isDraft: cand.isDraft,
367 },
368 ownerName: cand.ownerUsername,
369 repoName: cand.repoName,
370 actorUserId: cand.authorUserId,
371 });
372 });
373 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
374 const onMerged = deps.onMerged ?? defaultOnMerged;
375 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
376 const shouldShortCircuitAi =
377 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
378
379 let candidates: SweepCandidate[] = [];
380 try {
381 candidates = await findCandidates(
382 AUTO_MERGE_LOOKBACK_HOURS,
383 AUTO_MERGE_MAX_PER_TICK
384 );
385 } catch (err) {
386 console.error("[autopilot] auto-merge: findCandidates threw:", err);
387 return { evaluated: 0, merged: 0, blocked: 0 };
388 }
389
390 let evaluated = 0;
391 let merged = 0;
392 let blocked = 0;
393
394 for (const cand of candidates) {
395 try {
396 evaluated += 1;
397
398 // AI-key short-circuit: if the rule requires AI approval and we have
399 // no key, treat as blocked without calling the evaluator (which would
400 // log a misleading "AI review unavailable").
401 let decision: AutoMergeDecision;
402 if (await shouldShortCircuitAi(cand)) {
403 decision = {
404 merge: false,
405 reason:
406 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
407 blocking: [
408 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
409 ],
410 };
411 } else {
412 decision = await evaluate({
413 pullRequestId: cand.prId,
414 repositoryId: cand.repositoryId,
415 baseBranch: cand.baseBranch,
416 isDraft: cand.isDraft,
417 authorUserId: cand.authorUserId,
418 });
419 }
420
421 // Always record the evaluation, regardless of outcome.
422 try {
423 await recordAttempt(cand.repositoryId, cand.prId, decision);
424 } catch (err) {
425 console.error(
426 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
427 err
428 );
429 }
430
431 if (!decision.merge) {
432 blocked += 1;
433 continue;
434 }
435
436 // Perform the actual merge.
437 const result = await merge(cand);
438 if (result.ok) {
439 merged += 1;
440 await onMerged(cand, result);
441 } else {
442 blocked += 1;
443 await onMergeFailed(cand, result.error || "unknown merge error");
444 }
445 } catch (err) {
446 blocked += 1;
447 console.error(
448 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
449 err
450 );
451 }
452 }
453
454 console.log(
455 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
456 );
457
458 return { evaluated, merged, blocked };
459}
460
99461/**
100462 * Visits each distinct (repo, base_branch) that has queued rows and logs a
101463 * stub depth line. The actual gate-running + merge happens in the pulls
262624 rescanAdvisoriesBatch,
263625 DEFAULT_INTERVAL_MS,
264626 ADVISORY_RESCAN_BATCH,
627 AUTO_MERGE_LOOKBACK_HOURS,
628 AUTO_MERGE_MAX_PER_TICK,
629 AUTO_MERGE_COMMENT_MARKER,
630 defaultFindAutoMergeCandidates,
631 defaultOnMerged,
632 defaultOnMergeFailed,
633 defaultShouldShortCircuitAi,
265634};
Modifiedsrc/lib/mcp-tools.ts+1009−2View fileUnifiedSplit
Large file (1,043 lines). Load full file
Addedsrc/lib/pr-merge.ts+270−0View fileUnifiedSplit
1/**
2 * Block K3 — Shared PR merge executor.
3 *
4 * Factors the side-effecting merge mechanics out of the
5 * `POST /:owner/:repo/pulls/:number/merge` HTTP handler in `src/routes/pulls.tsx`
6 * so the autopilot's `auto-merge-sweep` task can perform a merge without
7 * replicating route logic. The HTTP handler retains its own gating chain
8 * (gate checks, branch-protection re-evaluation, error redirects); this
9 * module only covers the post-decision mechanics:
10 *
11 * 1. Run the actual ref update (`git update-ref` for ff/clean merges,
12 * delegating to `mergeWithAutoResolve` when the K2-style decision
13 * flagged conflicts and AI conflict-resolution is enabled).
14 * 2. Flip `pull_requests.state` to `merged`, stamp `mergedAt` / `mergedBy`.
15 * 3. Run J7 close-keyword scanning — close any referenced open issues in
16 * the same repo and post the back-link comment.
17 *
18 * Pure error-funnel: every failure is returned as `{ok:false, error}`; we
19 * never throw. Callers decide how to surface the error (HTTP redirect vs.
20 * audit row).
21 *
22 * Intentionally NOT in this file:
23 * - Gate evaluation / branch-protection (use `evaluateAutoMerge` in K2,
24 * or the inline chain in the HTTP handler).
25 * - AI review comment posting (the auto-merge audit/comment is the
26 * autopilot task's responsibility).
27 */
28
29import { and, eq } from "drizzle-orm";
30import { db } from "../db";
31import {
32 issueComments,
33 issues,
34 pullRequests,
35 type PullRequest,
36} from "../db/schema";
37import { getRepoPath } from "../git/repository";
38import { mergeWithAutoResolve } from "./merge-resolver";
39import { isAiReviewEnabled } from "./ai-review";
40import { extractClosingRefsMulti } from "./close-keywords";
41
42export interface PerformMergeArgs {
43 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
44 pr: Pick<
45 PullRequest,
46 | "id"
47 | "number"
48 | "title"
49 | "body"
50 | "baseBranch"
51 | "headBranch"
52 | "repositoryId"
53 | "authorId"
54 | "state"
55 | "isDraft"
56 >;
57 ownerName: string;
58 repoName: string;
59 /** Whose user id to stamp on `merged_by` + close-keyword comments. */
60 actorUserId: string;
61 /**
62 * When true, indicates the caller's gate matrix saw a `Merge check` failure
63 * — we should route through `mergeWithAutoResolve` (Claude-assisted
64 * resolution) instead of a plain ref update. The autopilot sweep currently
65 * passes `false` because `evaluateAutoMerge` already requires green gates.
66 */
67 hasConflicts?: boolean;
68}
69
70export interface PerformMergeResult {
71 ok: boolean;
72 error?: string;
73 /**
74 * Issue numbers that were auto-closed by J7 close-keyword scanning.
75 * Empty array on no matches or on close-keyword failure (never throws).
76 */
77 closedIssueNumbers: number[];
78 /**
79 * Files that the AI conflict resolver touched, when `hasConflicts` routed
80 * through `mergeWithAutoResolve`. Empty when a plain ref update was used.
81 */
82 resolvedFiles: string[];
83}
84
85/**
86 * Internal helper: run the actual git operation (ref update or
87 * Claude-assisted merge). Returns `{ok}` so the caller decides whether to
88 * flip DB state.
89 */
90async function executeGitMerge(args: {
91 ownerName: string;
92 repoName: string;
93 baseBranch: string;
94 headBranch: string;
95 prNumber: number;
96 prTitle: string;
97 hasConflicts: boolean;
98}): Promise<{ ok: true; resolvedFiles: string[] } | { ok: false; error: string }> {
99 const repoDir = getRepoPath(args.ownerName, args.repoName);
100
101 if (args.hasConflicts && isAiReviewEnabled()) {
102 const mergeResult = await mergeWithAutoResolve(
103 args.ownerName,
104 args.repoName,
105 args.baseBranch,
106 args.headBranch,
107 `Merge pull request #${args.prNumber}: ${args.prTitle}`
108 );
109 if (!mergeResult.success) {
110 return {
111 ok: false,
112 error: mergeResult.error || "Auto-merge failed",
113 };
114 }
115 return { ok: true, resolvedFiles: mergeResult.resolvedFiles };
116 }
117
118 try {
119 const proc = Bun.spawn(
120 [
121 "git",
122 "update-ref",
123 `refs/heads/${args.baseBranch}`,
124 `refs/heads/${args.headBranch}`,
125 ],
126 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
127 );
128 const exit = await proc.exited;
129 if (exit !== 0) {
130 const errText = await new Response(proc.stderr).text();
131 return {
132 ok: false,
133 error: `git update-ref failed: ${errText.trim() || `exit ${exit}`}`,
134 };
135 }
136 return { ok: true, resolvedFiles: [] };
137 } catch (err) {
138 return {
139 ok: false,
140 error: `git update-ref threw: ${err instanceof Error ? err.message : String(err)}`,
141 };
142 }
143}
144
145/**
146 * Apply J7 close-keyword scanning. Best-effort — failures swallowed and
147 * surfaced via the returned array (which is empty on any error).
148 */
149async function applyCloseKeywords(args: {
150 pr: PerformMergeArgs["pr"];
151 actorUserId: string;
152}): Promise<number[]> {
153 const closed: number[] = [];
154 try {
155 const refs = extractClosingRefsMulti([args.pr.title, args.pr.body]);
156 for (const n of refs) {
157 const [issue] = await db
158 .select()
159 .from(issues)
160 .where(
161 and(
162 eq(issues.repositoryId, args.pr.repositoryId),
163 eq(issues.number, n)
164 )
165 )
166 .limit(1);
167 if (!issue || issue.state !== "open") continue;
168 await db
169 .update(issues)
170 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
171 .where(eq(issues.id, issue.id));
172 await db.insert(issueComments).values({
173 issueId: issue.id,
174 authorId: args.actorUserId,
175 body: `Closed by pull request #${args.pr.number}.`,
176 });
177 closed.push(n);
178 }
179 } catch {
180 // J7 invariant: close-keyword failures never block the merge.
181 }
182 return closed;
183}
184
185/**
186 * Run a PR merge end-to-end (git + DB + close-keywords). Caller is
187 * responsible for having pre-validated that the merge is allowed.
188 *
189 * Returns:
190 * - ok=true with `closedIssueNumbers` + `resolvedFiles` on full success.
191 * - ok=false with `error` if the git step failed; DB is left untouched.
192 * (DB-update failures are bubbled up the same way.)
193 */
194export async function performMerge(
195 args: PerformMergeArgs
196): Promise<PerformMergeResult> {
197 // Defence-in-depth: refuse to act on PRs that aren't actually open/non-draft.
198 if (args.pr.state !== "open") {
199 return {
200 ok: false,
201 error: `PR is not open (state=${args.pr.state}).`,
202 closedIssueNumbers: [],
203 resolvedFiles: [],
204 };
205 }
206 if (args.pr.isDraft) {
207 return {
208 ok: false,
209 error: "PR is a draft — drafts cannot be merged.",
210 closedIssueNumbers: [],
211 resolvedFiles: [],
212 };
213 }
214
215 const gitResult = await executeGitMerge({
216 ownerName: args.ownerName,
217 repoName: args.repoName,
218 baseBranch: args.pr.baseBranch,
219 headBranch: args.pr.headBranch,
220 prNumber: args.pr.number,
221 prTitle: args.pr.title,
222 hasConflicts: args.hasConflicts === true,
223 });
224 if (!gitResult.ok) {
225 return {
226 ok: false,
227 error: gitResult.error,
228 closedIssueNumbers: [],
229 resolvedFiles: [],
230 };
231 }
232
233 try {
234 await db
235 .update(pullRequests)
236 .set({
237 state: "merged",
238 mergedAt: new Date(),
239 mergedBy: args.actorUserId,
240 updatedAt: new Date(),
241 })
242 .where(eq(pullRequests.id, args.pr.id));
243 } catch (err) {
244 return {
245 ok: false,
246 error: `DB update failed after git merge: ${
247 err instanceof Error ? err.message : String(err)
248 }`,
249 closedIssueNumbers: [],
250 resolvedFiles: gitResult.resolvedFiles,
251 };
252 }
253
254 const closedIssueNumbers = await applyCloseKeywords({
255 pr: args.pr,
256 actorUserId: args.actorUserId,
257 });
258
259 return {
260 ok: true,
261 closedIssueNumbers,
262 resolvedFiles: gitResult.resolvedFiles,
263 };
264}
265
266/** Test-only surface. */
267export const __test = {
268 executeGitMerge,
269 applyCloseKeywords,
270};
Modifiedsrc/routes/gates.tsx+10−0View fileUnifiedSplit
297297 {p.requireHumanReview
298298 ? `${p.requiredApprovals} human approval(s) · `
299299 : ""}
300 {p.enableAutoMerge ? "AI auto-merge · " : ""}
300301 {!p.allowForcePush ? "No force push · " : ""}
301302 {!p.allowDeletion ? "No deletion" : ""}
302303 </div>
376377 <input type="checkbox" name="allowDeletion" value="1" />
377378 Allow deletion
378379 </label>
380 <label
381 style="display: flex; align-items: center; gap: 6px"
382 title="K2 — Let the autopilot ticker auto-merge PRs that pass every gate this rule enforces."
383 >
384 <input type="checkbox" name="enableAutoMerge" value="1" />
385 Enable AI auto-merge
386 </label>
379387 </div>
380388 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
381389 Add rule
460468 requiredApprovals,
461469 allowForcePush: b("allowForcePush"),
462470 allowDeletion: b("allowDeletion"),
471 // K2 — opt-in flag for the autopilot auto-merger.
472 enableAutoMerge: b("enableAutoMerge"),
463473 });
464474 } catch (err) {
465475 console.error("[gates] protection save:", err);
466476