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

Merge pull request #31 from ccantynz-alt/claude/issue-to-pr-and-protections

Merge pull request #31 from ccantynz-alt/claude/issue-to-pr-and-protections

Claude/issue to pr and protections
ccantynz App committed on April 30, 2026Parents: f98000c f5a18f9
50 files changed+5429756cca321e2ddc52d008b8f1c05a7acd96af26b94c
50 changed files+5429−75
ModifiedBUILD_BIBLE.md+33−12View fileUnifiedSplit
9090| Dependency graph | ✅ | J1 — `src/lib/deps.ts` parses package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / Gemfile / composer.json without a TOML lib. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` grouped by ecosystem with per-ecosystem counts; owner-only reindex walks the default-branch tree (max 200 manifests, 1MB each). `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies`. |
9191| Security advisories / Dependabot alerts | ✅ | J2 — curated 12-entry seed list + minimal semver range matcher cross-referenced against J1 dep rows. `src/lib/advisories.ts` + `src/routes/advisories.tsx` serve `/:owner/:repo/security/advisories` (open) + `/all`, owner-only `POST /scan`, and per-alert dismiss/reopen. `drizzle/0029_security_advisories.sql` adds `security_advisories` + `repo_advisory_alerts`. |
9292| Commit signature verification (Verified badge) | ✅ | J3 — GPG + SSH pubkey registration at `/settings/signing-keys`, `gpgsig` extraction from raw commit objects, OpenPGP packet walker for Issuer Fingerprint, SHA-256 fingerprints for SSHSIG pubkeys, memoised in `commit_verifications`. Green "Verified" badge rendered on commit list + detail when a registered key matches. `src/lib/signatures.ts` + `src/routes/signing-keys.tsx` + `drizzle/0030_signing_keys.sql`. |
93| Repository rulesets (push policy engine) | ✅ | J6 — named policies group N rules at active/evaluate/disabled enforcement. Pure evaluator `evaluatePush(rulesets, ctx)``{allowed, violations}`. Six rule types: commit_message_pattern, branch_name_pattern, tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push. Glob-lite matcher (`*` = non-slash, `**` = anything). Owner-only CRUD at `/:owner/:repo/settings/rulesets`. `src/lib/rulesets.ts` + `src/routes/rulesets.tsx` + `drizzle/0032_repo_rulesets.sql`. |
93| Repository rulesets (push policy engine) | ✅ | J6 — named policies group N rules at active/evaluate/disabled enforcement. Pure evaluator `evaluatePush(rulesets, ctx)``{allowed, violations}`. Six rule types: commit_message_pattern, branch_name_pattern, tag_name_pattern, blocked_file_paths, max_file_size, forbid_force_push. Glob-lite matcher (`*` = non-slash, `**` = anything). Owner-only CRUD at `/:owner/:repo/settings/rulesets`. `src/lib/rulesets.ts` + `src/routes/rulesets.tsx` + `drizzle/0032_repo_rulesets.sql`. **Pre-receive enforcement (2026-04-30):** `src/lib/push-policy.ts` evaluates active rulesets at the HTTP layer for ref-name patterns; violations return 403 with a human-readable `remote: ` body. Pack-content rules (`commit_message_pattern`, `blocked_file_paths`, `max_file_size`) still need pack inspection — tracked in §7. |
9494| Commit status API (external CI signals) | ✅ | J8 — external systems POST per-commit (sha, context) statuses with state pending/success/failure/error. Combined rollup reduces to worst state. Public list + combined endpoints; write requires owner auth. Rendered on commit detail view as a pill row. `src/lib/commit-statuses.ts` + `src/routes/commit-statuses.ts` + `drizzle/0033_commit_statuses.sql`. |
9595
9696### 2.3 Collaboration
127127| AI commit messages | ✅ | `src/lib/ai-generators.ts` |
128128| AI PR summaries | ✅ | |
129129| AI changelogs | ✅ | auto on release create; arbitrary-range viewer at `/:owner/:repo/ai/changelog?from=&to=` (D7) |
130| AI code review | ✅ | `src/lib/ai-review.ts` |
130| AI code review | ✅ | `src/lib/ai-review.ts``triggerAiReview` now runs real Claude review on PR open: `git diff base...head`, calls `reviewDiff()`, posts a summary comment + N inline file/line comments. Idempotent via `AI_REVIEW_MARKER`. Was a stub until 2026-04-30; now fully wired with a 100KB diff cap and graceful fallback when the Anthropic API fails. |
131131| AI merge conflict resolver | ✅ | `src/lib/merge-resolver.ts` |
132132| AI chat (global + repo) | ✅ | `src/routes/ask.tsx` |
133133| AI explain-this-codebase | ✅ | D6 — per-commit cached markdown, `GET /:owner/:repo/explain`, `src/lib/ai-explain.ts` + `src/routes/ai-explain.tsx` |
134| AI PR triage | ✅ | D3 — Claude Haiku suggests labels/reviewers/priority as an AI comment on PR create; `triagePullRequest` in `src/lib/ai-generators.ts`, wired in `src/routes/pulls.tsx` |
134| AI PR triage | ✅ | D3 — Claude Haiku suggests labels/reviewers/priority as an AI comment on PR create. `triagePullRequest` in `src/lib/ai-generators.ts` is the AI helper; `src/lib/pr-triage.ts` `triggerPrTriage` is the fire-and-forget caller wired in `src/routes/pulls.tsx`. Builds a numstat-only diff summary, loads available labels + candidate reviewers (owner + recent PR authors, capped at 12), inserts a "## AI Triage" comment. Idempotent via `PR_TRIAGE_MARKER`. Was a stub until 2026-04-30; now fully wired. |
135135| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
136| Scheduled workflows (cron triggers) | ✅ | 2026-04-30 — `on: schedule: [{cron: "0 * * * *"}]` triggers fire from the autopilot tick. `src/lib/cron.ts` (5-field UNIX parser/matcher, POSIX dom/dow OR, no @aliases or L/W/#/?). `src/lib/scheduled-workflows.ts` walks non-disabled workflows, computes `since` from latest schedule run (fallback now-6min), enqueues at most one schedule run per workflow per tick. `MAX_RUNS_PER_TICK=50` safety cap. Wired as the `scheduled-workflows` autopilot task. |
137| Live comment updates (SSE) | ✅ | 2026-04-30 — `src/routes/issues.tsx` + `src/routes/pulls.tsx` publish on comment create via `src/lib/sse.ts`; the same detail pages render a hidden banner + `liveCommentBannerScript` from `src/lib/sse-client.ts` that reveals "X new comment(s) — reload" when remote tabs post. Multi-segment topic grammar (`repo:<uuid>:issue:<n>`) accepted by `src/routes/live-events.ts`. In-process broadcaster only — cross-node fanout still in §7. |
138| AI Suggest PR description | ✅ | 2026-04-30 — "Suggest description with AI" button on `/:owner/:repo/pulls/new` calls `POST /:owner/:repo/ai/pr-description` (write-access gated). Endpoint computes `git diff base...head`, calls `generatePrSummary` (Sonnet 4), returns JSON `{ok, body}`. Inline JS replaces the description textarea on success (with a confirm-overwrite prompt if non-empty). Helper was on disk but unused until 2026-04-30. |
139| 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. |
140| 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. |
141| 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`. |
143| 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`. |
136144| 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`. |
137145| 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. |
138146| Copilot code completion | ✅ | D9 — `POST /api/copilot/completions` (PAT/OAuth/session), `GET /api/copilot/ping`. `src/lib/ai-completion.ts`, `src/routes/copilot.ts`. LRU-cached, rate-limited 60/min. |
168176| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
169177| Merge queues | ✅ | E5 — serialised merge with re-test. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, `drizzle/0017_merge_queue.sql`; per `(repo, base_branch)` queue, owner-only process-next re-runs gates against latest base before merging. |
170178| Required checks matrix | ✅ | E6 — per branch-protection named check list. `src/routes/required-checks.tsx`, `drizzle/0018_required_checks.sql`; `listRequiredChecks` + `passingCheckNames` helpers in `src/lib/branch-protection.ts`; merge handler verifies every required name has a passing gate_run or workflow_run. |
171| Protected tags | ✅ | E7 — owners can mark tag patterns (`v*`, `release-*`) protected. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, `drizzle/0019_protected_tags.sql`; advisory enforcement via post-receive audit log (v1). |
179| Protected tags | ✅ | E7 — owners can mark tag patterns (`v*`, `release-*`) protected. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, `drizzle/0019_protected_tags.sql`. **Pre-receive enforcement (2026-04-30):** `src/lib/push-policy.ts` blocks tag pushes that match a protected pattern unless the pusher is the repo owner; rejection returns 403 with the violation list. Audit log entries (`push.rejected`) record blocked attempts. Was advisory-only until 2026-04-30. |
172180
173181### 2.6 Observability + safety
174182| Feature | Status | Notes |
182190| API documentation page | ✅ | `src/routes/api-docs.tsx` — interactive in-app docs. |
183191| SSE in-process pub/sub | ✅ | `src/lib/sse.ts` + `src/lib/sse-client.ts` — topic-based broadcaster. `src/routes/live-events.ts` exposes `GET /live-events/:topic`. Used by the live UI updates layer. |
184192| Inbound deploy event receiver (Signal Bus P1) | ✅ | `src/routes/events.ts` — Crontech → Gluecron event ingest. Idempotent via `drizzle/0034_processed_events.sql` (sha256 of payload deduped at write-site). |
185| Autopilot ticker | ✅ | `src/lib/autopilot.ts` — 5-min cycle: mirror sync, merge-queue peek, weekly digests, advisory rescans. `AUTOPILOT_DISABLED=1` opt-out. Admin page `/admin/autopilot`. |
193| Autopilot ticker | ✅ | `src/lib/autopilot.ts` — 5-min cycle: mirror sync, merge-queue peek, weekly digests, advisory rescans, **wait-timer-release** (env approvals), **scheduled-workflows** (cron triggers). `AUTOPILOT_DISABLED=1` opt-out. Admin page `/admin/autopilot`. |
186194| Demo seed | ✅ | `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()`. `DEMO_SEED_ON_BOOT=1` boot flag. Site-admin reseed at `/admin` (`POST /admin/demo/reseed`). `/demo` redirect to a sample repo. |
187195| SEO (robots + sitemap) | ✅ | `src/routes/seo.ts``/robots.txt` + `/sitemap.xml`. |
188196| Audit log (table) | ✅ | `audit_log` table |
366374- `drizzle/0035_repo_collaborators.sql` — migration, never edited in place. Adds `repo_collaborators` (unique on `(repository_id, user_id)`, role enum read/write/admin, `invited_by` nullable, `accepted_at` nullable).
367375- `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.
368376- `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).
377- `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).
369378
370379### 4.2 Git layer (locked)
371380- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
386395- `src/lib/workflow-runner.ts` (Block C1) — shell executor. Exports `executeRun`, `drainOneRun`, `enqueueRun`, `startWorker`. Clones repo to tmpdir, runs each job via `Bun.spawn(["bash","-c",step.run])` with SIGTERM→SIGKILL timeouts, size-capped stdout/stderr, cleans up in `finally`.
387396- `src/lib/packages.ts` (Block C2) — npm protocol helpers: `parsePackageName`, `computeShasum` (sha1), `computeIntegrity` (sha512 base64), `buildPackument`, `resolveRepoFromPackageJson`, `parseRepoUrl`, `tarballFilename`. Pure functions.
388397- `src/lib/pages.ts` (Block C3) — `onPagesPush` (never throws), `resolvePagesPath` (probe list including pretty URLs + traversal strip), `contentTypeFor` (MIME).
389- `src/lib/environments.ts` (Block C4) — `matchGlob`, `listEnvironments`, `getOrCreateEnvironment`, `getEnvironmentByName`, `isReviewer`, `reviewerIdsOf`, `allowedBranchesOf`, `computeApprovalState`, `reduceApprovalState`, `recordApproval`, `requiresApprovalFor`. Empty reviewers list → repo owner approves. Any rejection hard-stops.
398- `src/lib/environments.ts` (Block C4) — `matchGlob`, `listEnvironments`, `getOrCreateEnvironment`, `getEnvironmentByName`, `isReviewer`, `reviewerIdsOf`, `allowedBranchesOf`, `computeApprovalState` (now also returns `readyAfter`), `reduceApprovalState`, `recordApproval`, `requiresApprovalFor`. Wait-timer enforcement: `latestApprovalAt(decided)`, `computeReadyAfter(env, decided)`, `releaseExpiredWaitTimers(now)` flip `status="waiting_timer"``"pending"` once `ready_after` elapses. Wired into autopilot as the `wait-timer-release` task. Empty reviewers list → repo owner approves. Any rejection hard-stops.
390399
391400### 4.3.1 Permissions + collaborators (locked)
392401- `src/middleware/repo-access.ts` — centralised permission check applied to all repo-write routes. Resolves owner / collaborator-role / org-team-role and rejects unauthorised mutations.
397406
398407### 4.4 AI layer (locked)
399408- `src/lib/ai-client.ts` — Anthropic client + model constants
400- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage / **pull-request-triage (D3)**
409- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage / **pull-request-triage (D3)**. `IssueTriage` interface is exported (was module-local until 2026-04-30 when issue-triage.ts started consuming it). `generatePrSummary` + `generateCommitMessage` + `triageIssue` are now wired into routes (previously orphan exports).
401410- `src/lib/ai-chat.ts` — conversational chat
402411- `src/lib/ai-review.ts` — PR code review
403412- `src/lib/auto-repair.ts` — worktree-backed repair commits
413422- `src/lib/timetravel.ts` — time-travel code explorer (historical snapshot navigation).
414423- `src/lib/depimpact.ts` — dependency impact analyzer (cross-reference of who breaks if X bumps).
415424- `src/lib/rollback.ts` — one-click rollback helper for failed deploys.
416- `src/lib/spec-to-pr.ts` + `src/lib/spec-ai.ts` + `src/lib/spec-context.ts` + `src/lib/spec-git.ts` — Spec-to-PR v2 pipeline (NL feature spec → Claude → branch via git plumbing → PR row). Graceful fallback when no `ANTHROPIC_API_KEY`.
425- `src/lib/spec-to-pr.ts` + `src/lib/spec-ai.ts` + `src/lib/spec-context.ts` + `src/lib/spec-git.ts` — Spec-to-PR v2 pipeline (NL feature spec → Claude → branch via git plumbing → PR row). Graceful fallback when no `ANTHROPIC_API_KEY`. Issue-driven entry: the issue detail page renders a "Build with AI" button for owners on open issues, linking to `/spec?fromIssue=N`; spec is pre-filled with `Implement: <title>\n\n<body>\n\nCloses #N` so J7 close-keywords auto-close the issue on merge. Pure helper `buildSpecFromIssue` exported from `src/routes/specs.tsx`.
417426- `src/lib/autorepair.ts``autoRepair(...)` invoked from `src/hooks/post-receive.ts` and exercised by `src/__tests__/intelligence.test.ts`. **Distinct from `src/lib/auto-repair.ts`** (gate-time `repairSecrets`/`repairSecurityIssues` used by `src/lib/gate.ts`). Both are load-bearing — do not dedupe.
418- `src/lib/pr-triage.ts` — placeholder for a future post-create PR-triage hook (logs only, no AI call). Distinct from `triagePullRequest` in `src/lib/ai-generators.ts` which is the live D3 path.
427- `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.
428- `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.
429- `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.
419430
420431### 4.5 Platform (locked)
421432- `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.
429440- `src/lib/observability.ts` — minimal dependency-free observability layer. Wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws.
430441- `src/lib/sse.ts` — in-process topic-based pub/sub broadcaster for Server-Sent Events. Backs the live UI updates layer.
431442- `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.
432- `src/lib/autopilot.ts` — 5-minute self-sufficiency ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` + `getTickCount()`.
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()`.
433444- `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`.
434445- `src/lib/import-helper.ts` — small helpers for the GitHub import flow (`src/routes/import.tsx`).
435446- `src/lib/import-verify.ts` — post-migration smoke verifier (object count, branches, default-branch HEAD). Re-runnable from `src/routes/migrations.tsx`.
438449- `src/lib/workflow-conditionals.ts``if:` expression evaluator for workflow steps.
439450- `src/lib/workflow-matrix.ts` — matrix expansion for workflow jobs.
440451- `src/lib/workflow-artifacts.ts` — artifact persistence helpers backing `workflow_run_cache`-style storage.
441- `src/lib/workflow-secrets.ts` + `src/lib/workflow-secrets-crypto.ts` — AES-256-GCM secret storage. Crypto lib stays separate from DB lib so the DB layer holds opaque bytes only.
452- `src/lib/workflow-secrets.ts` + `src/lib/workflow-secrets-crypto.ts` — AES-256-GCM secret storage. Crypto lib stays separate from DB lib so the DB layer holds opaque bytes only. `substituteSecrets(template, secrets)` is the pure regex-replace helper consumed by the v1 runner; `loadSecretsContext(repoId)` is the per-run loader (fail-soft on master-key absence / decrypt failure / DB error).
453- `src/lib/cron.ts` — pure 5-field UNIX cron parser + matcher. Supports literals, ranges, steps (`a-b/n`, `*/n`), comma-lists, full POSIX OR semantics for dom + dow. Rejects @aliases and L/W/#/? with clear errors. Exports `parseCron`, `cronMatches`, `cronFiredBetween` (caps lookback at 1 day). Zero side effects.
454- `src/lib/scheduled-workflows.ts``runScheduledWorkflowsTick(now)` scans non-disabled workflows whose parsed JSON includes `schedules`, computes `since` from the latest schedule-triggered run (fallback now-6min), enqueues at most one schedule run per workflow per tick. `MAX_RUNS_PER_TICK=50` safety cap. Pure helpers `schedulesFromParsedJson` + `firstCronToFire` exported. Wired into `src/lib/autopilot.ts` as the `scheduled-workflows` task.
455- `src/lib/push-policy.ts` — pre-receive enforcement at the HTTP layer. Exports `evaluatePushPolicy({repositoryId, refs, pusherUserId})` returning `{allowed, violations}`; combines `matchProtectedTag` + `canBypassProtectedTag` (E7) and `evaluatePush` (J6) over the ref list. Only "active" enforcement blocks; "evaluate" enforcement remains dry-run. `formatPolicyError(violations)` builds the 403 body. Fail-open on any DB hiccup so a Postgres blip cannot wedge legitimate pushes. `ZERO_SHA` exported.
456- `src/lib/git-push-auth.ts` — Authorization-header → User resolver for `git-receive-pack`. Accepts Basic (user:secret) and Bearer schemes; secrets matching `glc_` (PAT), `glct_` (OAuth), or `ghi_` (Block H2 app-bot installation token) prefixes are looked up. Decoders `decodeBasicAuth` / `decodeBearerAuth` exported. Returns `null` on any failure → caller treats as anonymous. App-bot path (2026-04-30): joins `app_install_tokens` + `app_installations` + `app_bots` and resolves to the synthetic `users` row that `marketplace.ts createApp` creates alongside the bot.
457- `src/lib/mcp.ts` (move #9 from STRATEGY) — Model Context Protocol JSON-RPC 2.0 router. `routeMcpRequest({jsonrpc, id?, method, params?}, {ctx, tools})`. Supported methods: `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`. Standard JSON-RPC error codes exported (`ERR_PARSE`, `ERR_INVALID_REQUEST`, `ERR_METHOD_NOT_FOUND`, `ERR_INVALID_PARAMS`, `ERR_INTERNAL`). `McpError` class for typed throws from tool handlers. Notifications return null. Never throws.
458- `src/lib/mcp-tools.ts` (move #9) — read-only v1 tool surface: `gluecron_repo_search`, `gluecron_repo_read_file`, `gluecron_repo_list_issues`, `gluecron_repo_explain_codebase`. Public-only visibility check; private repos return `-32601 not found`. `argString` / `argNumber` helpers throw `McpError(-32602)` on missing or wrong-typed args. Default registry exposed via `defaultTools()`.
442459
443460### 4.6 Routes (locked endpoints — behaviour must be preserved)
444461- `src/routes/git.ts` — Smart HTTP (clone/push)
526543- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
527544- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
528545- `src/routes/api-v2.ts` — Comprehensive REST API v2 (full CRUD across resources). Authoritative integration surface alongside the v1 endpoints in this section.
546- `src/routes/mcp.ts` (move #9) — `GET /mcp` discovery (server info + tool count) + `POST /mcp` JSON-RPC 2.0 endpoint. Single requests + batched arrays both supported; notifications return 204. softAuth for the `userId` context; v1 tools are public-only so anonymous works. Mounted at root in `src/app.tsx` alongside graphql + live-events.
529547- `src/routes/api-docs.tsx` — interactive in-app API documentation page.
530548- `src/routes/collaborators.tsx` — repo collaborator add / list / remove. Owner-or-admin only. Audit-logged. (See §4.3.1.)
531549- `src/routes/team-collaborators.tsx` — bulk team-to-repo collaborator grant. (See §4.3.1.)
553571- Nav links: logo · search · theme-toggle · Explore · Ask · Notifications · New · Profile (or Sign in / Register)
554572- Keyboard chords: `/`, `Cmd+K`, `?`, `n`, `g d`, `g n`, `g e`, `g a`
555573
574### 4.7.1 Workflow runner extensions (locked)
575- `src/lib/workflow-runner.ts` — v1 runner. Now loads workflow secrets once per run via `loadSecretsContext(run.repositoryId)` and passes them through to `executeJob``runStep`. `runStep` substitutes `${{ secrets.NAME }}` in `step.run` via `substituteSecrets` only when the template contains `${{` (cheap pre-check); otherwise the hot path is unchanged. The v2 executor stays gated off via `_v2NeededFor` returning false; secrets plumbing for v2 will reuse the same args.
576
556577### 4.8 Tests (locked)
557578- `src/__tests__/green-ecosystem.test.ts` — secret scanner, codeowners, AI fallback, health, rate-limit headers, `/shortcuts`, `/search`
558579- All other existing test files — do not delete without owner permission
579600```bash
580601bun install
581602bun dev # hot reload
582bun test # 1033 tests currently pass (8 skipped, 0 failed) as of 2026-04-29 — run `bun install` first
603bun test # 1214 tests currently pass (8 skipped, 0 failed) as of 2026-04-30 — run `bun install` first
583604bun run db:migrate
584605```
585606
Addeddocs/STRATEGY.md+89−0View fileUnifiedSplit
1# Gluecron Strategy — GitHub parity + 10 next moves + 5-10 year horizon
2
3**Owner:** Cantynz · **Status:** Living document — update on every meaningful product move.
4
5**Read first:** `BUILD_BIBLE.md` (canonical implementation truth). This doc is the *direction*; the bible is the *position*.
6
7---
8
9## 1. What GitHub does well (the pros we must match)
10
11| # | Pro | Gluecron status |
12|---|---|---|
13| 1 | Network effect — largest dev community | ❌ no shortcut. Earned via product wins, not features. |
14| 2 | Decade-tuned UI polish, keyboard-first chords | ✅ dark/light, Cmd+K palette, `?` shortcuts, /shortcuts page |
15| 3 | Actions marketplace (huge action ecosystem) | 🟡 v2 engine + 5 builtins (cache/checkout/upload-artifact/download-artifact/gatetest); marketplace catalog ✅ |
16| 4 | Copilot completion latency + IDE integration | ✅ POST /api/copilot/completions + VS Code extension |
17| 5 | Code search at scale | ✅ ILIKE per-repo + global · ✅ semantic (Voyage `voyage-code-3`) · 🟡 vector index size at GitHub scale |
18| 6 | Codespaces (cloud dev environments) | ❌ — see move #6 below |
19| 7 | Pages (free static hosting) | ✅ `gh-pages` branch, custom domain |
20| 8 | REST + GraphQL APIs widely integrated | ✅ REST v1 + v2, GraphQL (queries) · 🟡 GraphQL mutations |
21| 9 | Security: Dependabot, code scanning, secret scanning | ✅ AI dep updater (J), advisories (J2), secret scanner, code-scanning UI |
22| 10 | Brand trust + docs | 🟡 still building. /help is ✅, comprehensive docs are 🟡 |
23| 11 | Forks, stars, follows, issues, PRs, reviews | ✅ all shipped |
24| 12 | Enterprise SSO + audit log | ✅ OIDC SSO + per-user + per-repo audit UI |
25
26## 2. What GitHub falls short on (the cons we are fixing)
27
28| # | Con | Gluecron's answer |
29|---|---|---|
30| 1 | AI is bolted on, not native | ✅ End-to-end: AI review on every PR, AI triage on every PR + issue, AI commit messages on the editor, AI PR descriptions on the new-PR form, AI explain on every commit, AI tests, semantic search, spec-to-PR, AI changelog, AI incident responder. Every surface degrades gracefully without ANTHROPIC_API_KEY. |
31| 2 | Owned by Microsoft → data + training concerns | ✅ Self-hostable single Bun binary; AGPL/MIT-friendly. Bring-your-own model. |
32| 3 | Push policy enforcement is via Actions (post-hoc) | ✅ Pre-receive: protected tags + ruleset name patterns block at the HTTP layer. Pack-content rules in flight (move #3). |
33| 4 | Copilot Workspace is paywalled, narrow scope | ✅ Spec-to-PR is built-in: paste an issue body → AI opens a draft PR. "Build with AI" button on every issue. |
34| 5 | Slow merge of feedback (PR review iteration takes days) | ✅ Re-run AI review + Re-run AI triage buttons (idempotency-bypass), live SSE comment banner, auto-merge on green gate. |
35| 6 | Workflow secrets advertised but Actions-only | ✅ AES-256-GCM-stored, substituted into v1 runner step.run via `${{ secrets.NAME }}` |
36| 7 | Wait timers, protected tags, environment approvals shown but not all enforced | ✅ Wait timer flips status="waiting_timer" + autopilot sweeper releases on tick. Protected tags 403 at receive. |
37| 8 | No first-class scheduled workflows beyond external cron | ✅ `on: schedule: [{cron: ...}]` driven by autopilot tick (50/tick safety cap) |
38| 9 | Limited live UX — refresh-driven | ✅ SSE foundation, live comment banner on issue + PR detail; live log tail on workflow runs |
39| 10 | Vendor lock-in (.github/workflows) | ✅ `.gluecron/workflows/*.yml` is a parallel namespace; importer respects `.github/workflows/*` for inbound migration |
40| 11 | Org-level gating is policy-only, no enforcement | ✅ Branch protection + required-checks matrix enforced at merge handler |
41| 12 | DMCA / privacy / sovereign deploys are hard | ✅ Single-tenant deploys are first-class (fly.toml, Dockerfile in repo) |
42
43## 3. Next 10 biggest moves (the strategic build queue)
44
45Numbers are priority, not size. Each maps to a concrete code surface; bible §3 is the canonical block list.
46
471. **Container registry (OCI / Docker)** — schema is ready (workflow_run_cache backs blobs). Closes the only major package-ecosystem gap. Estimated: 1 week.
482. **Cross-node SSE fanout (Redis pub/sub or NATS)** — `src/lib/sse.ts` TODO(scale). Required for >1 Bun instance behind a load balancer. Estimated: 2-3 days once Redis is on the deploy.
493. **Pack-content rule enforcement** — extend `src/lib/push-policy.ts` to read the new pack via `git index-pack --stdin`, scan commit messages + tree blobs for: `commit_message_pattern`, `blocked_file_paths`, `max_file_size`. Bible §2.5 J6 partial. Estimated: 1 week.
504. **App-bot push auth (`ghi_` install tokens)** — `src/db/schema.ts` `app_bots` lacks a `users.id` link. Add a synthetic-user shim so installation tokens identify a bot account that can own pushes / comments. Unblocks third-party integrations. Estimated: 3-4 days.
515. **Native mobile apps (iOS + Android)** — only ❌ in the parity scorecard. Wrap the PWA first (Capacitor), full native after. Estimated: 2 weeks for PWA wrap, 6+ weeks for native.
526. **Codespaces equivalent** — Bun-powered ephemeral container per branch + browser IDE. Backed by the existing workflow runner pool. Estimated: 4-6 weeks.
537. **AI agent-mode (multi-turn PR authoring)** — promote spec-to-PR to a *conversation*: the agent proposes, the human comments, the agent iterates. Builds on existing chat memory + PR comments. Estimated: 2-3 weeks.
548. **Proactive AI security advisories** — extend `src/lib/advisories.ts` to *propose patches*, not just flag. PR opened automatically against a `security/auto-patch-*` branch. Estimated: 1-2 weeks.
559. **MCP server endpoints** — Gluecron speaks the Model Context Protocol so any MCP-compatible client (Claude Desktop, Claude Code, Cursor) can read repos, post issues, run workflows. Estimated: 1 week.
5610. **AI repo-health coach** — daily/weekly digest surfaced on the dashboard: "your `auth.ts` has 3 TODOs older than 90 days; here's a draft PR fixing 2 of them." Builds on the existing health-score + autopilot. Estimated: 1-2 weeks.
57
58## 4. The 5-10 year horizon (the bets)
59
60Predictions, not promises. We optimise the architecture so each is a small step, not a rewrite.
61
621. **Code is a runtime artifact, intent is the source.** Humans describe; AI maintains 70%+ of generated code under continuous review. *Gluecron bet:* spec-to-PR + AI review + auto-merge are the load-bearing primitives. Every commit signed by an identifiable agent.
632. **Repos become living agents.** A `.gluecron/agent.yml` declares "what this repo does"; the agent self-heals dependencies, self-runs migrations, self-files incidents. *Gluecron bet:* autopilot framework + auto-repair + scheduled workflows are the seed. Add per-repo agent declarations next.
643. **Reviewers become evaluators.** "Did the agent meet the spec?" replaces line-by-line review. *Gluecron bet:* AI review already does this for every PR. Spec-to-PR closes the loop end-to-end.
654. **Continuous compliance.** Every push proves itself against policy (regulatory, security, custom). *Gluecron bet:* rulesets + protected tags + gate runs + audit log already do this; add SOC2 / HIPAA preset rulesets.
665. **Memory-augmented developers.** Each engineer carries a personal context that follows them across orgs and AI assistants. *Gluecron bet:* AI chat persistence per repo is in. User-level cross-repo memory is move #11 (off-list).
676. **Multimodal authoring.** Issues + PRs authored partly from speech, screenshots, video. *Gluecron bet:* the AI helpers all accept text only today; extending to multimodal is one prompt-shape change (Claude already supports vision).
687. **Edge inference + git.** Repo data + AI compute close to each user (CDN + on-device or regional inference). *Gluecron bet:* Bun + Fly.io regional placement makes this trivial; add per-region runner pools.
698. **Open weights, BYO model.** Users bring their own model (small fine-tuned or local). *Gluecron bet:* `src/lib/ai-client.ts` already isolates the Anthropic call; swap with an OpenAI-compatible adapter is hours.
709. **Sovereign deployments are normal.** Enterprises and states run their own Gluecron, mirroring upstream selectively. *Gluecron bet:* repo mirroring + SSO + admin panel + audit log + single-binary deploy → already shipped. Add a "Gluecron Federation" peer protocol.
7110. **The dashboard is the IDE.** Users live on a Gluecron tab the way they live on Slack. *Gluecron bet:* live comment banners, SSE foundation, command palette (Cmd+K), AI chat, dashboard health → the substrate is in. Codespaces (move #6) is the missing leg.
72
73## 5. What this means for the build queue
74
75- Every move in §3 maps to a bounded code change with a defined entry point.
76- Every prediction in §4 is a *vector*, not a *destination* — we widen options today (locked invariants, additive schemas, pluggable AI client) so any of these can land without a rewrite.
77- The bible is updated in lockstep. New work shows up in §2 (scorecard), §4 (locked files), §7 (in-flight). Strategy → reality flow stays one-way.
78
79## 6. Anti-goals (things we will NOT do)
80
811. Re-implement the GitHub UI pixel-for-pixel. Different platform, different UI choices.
822. Train our own foundation model. We integrate the best (Claude today, swappable tomorrow).
833. Lock users into Gluecron-specific YAML. Workflow files are portable; importer respects `.github/workflows/*`.
844. Tier essential AI features into paid plans. AI is the platform, not an add-on.
855. Track or sell user code. Privacy + sovereignty are first-class.
86
87---
88
89*Last updated 2026-04-30 alongside the AI-native flow batch (Build with AI, pre-receive enforcement, scheduled workflows, secret substitution, re-review/re-triage buttons, live comment banners). Next review: after move #1 ships.*
Addeddrizzle/0038_deployment_ready_after.sql+24−0View fileUnifiedSplit
1-- Deployment wait-timer enforcement (Block C4 follow-up).
2--
3-- Until now `environments.wait_timer_minutes` was stored but never enforced —
4-- approved deploys flipped to status="pending" the moment the last approval
5-- landed. This column unblocks real wait-timer semantics:
6--
7-- ready_after IS NULL → no wait; deploy may run as soon as it is "pending".
8-- ready_after > now() → deploy is "waiting_timer"; autopilot flips it to
9-- "pending" once the timer elapses.
10-- ready_after <= now() → deploy is ready (autopilot or any reader can flip).
11--
12-- Strictly additive — no existing rows touched, default is NULL so legacy
13-- deployments behave exactly as before.
14
15ALTER TABLE "deployments"
16 ADD COLUMN IF NOT EXISTS "ready_after" timestamptz;
17
18--> statement-breakpoint
19
20-- Partial index covers the autopilot sweep query
21-- (status='waiting_timer' AND ready_after <= now()).
22CREATE INDEX IF NOT EXISTS "deployments_ready_after"
23 ON "deployments" ("ready_after")
24 WHERE "ready_after" IS NOT NULL;
Addedsrc/__tests__/ai-review.test.ts+127−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/ai-review.ts.
3 *
4 * The Anthropic-calling path (reviewDiff) and the DB-touching paths
5 * (triggerAiReview's idempotency probe + insert) require external
6 * services we don't stand up in unit tests. We therefore cover the
7 * pure invariants:
8 *
9 * - The marker constant is stable so future searches keep working.
10 * - isAiReviewEnabled is a boolean reflecting config.anthropicApiKey.
11 * - triggerAiReview is a no-op (resolves cleanly) when the API key
12 * is absent — this is the documented graceful-degrade contract.
13 * - triggerAiReview never throws even when called with garbage.
14 * - The internal __test helpers are exported and shaped correctly.
15 */
16
17import { describe, it, expect } from "bun:test";
18import {
19 AI_REVIEW_MARKER,
20 isAiReviewEnabled,
21 triggerAiReview,
22 __test,
23} from "../lib/ai-review";
24
25describe("AI_REVIEW_MARKER", () => {
26 it("is the documented stable string", () => {
27 // Important: any change to this string is a breaking change for
28 // idempotency. New version → write a migration to back-fill old
29 // markers so older AI summaries still suppress duplicates.
30 expect(AI_REVIEW_MARKER).toBe("<!-- gluecron-ai-review:summary -->");
31 });
32
33 it("is an HTML comment so it doesn't render in markdown", () => {
34 expect(AI_REVIEW_MARKER.startsWith("<!--")).toBe(true);
35 expect(AI_REVIEW_MARKER.endsWith("-->")).toBe(true);
36 });
37});
38
39describe("isAiReviewEnabled", () => {
40 it("returns a boolean", () => {
41 const v = isAiReviewEnabled();
42 expect(typeof v).toBe("boolean");
43 });
44});
45
46describe("triggerAiReview — graceful degrade + crash-free", () => {
47 // Note: in the test sandbox ANTHROPIC_API_KEY is unset, so the
48 // function should resolve immediately without touching the DB. We
49 // also pass garbage repo names + nonexistent PR ids to confirm the
50 // overall try/catch holds.
51 it("resolves without throwing when API key is absent", async () => {
52 const before = process.env.ANTHROPIC_API_KEY;
53 delete process.env.ANTHROPIC_API_KEY;
54 try {
55 await triggerAiReview(
56 "alice",
57 "demo",
58 "00000000-0000-0000-0000-000000000000",
59 "Test PR",
60 "Body",
61 "main",
62 "feature"
63 );
64 } finally {
65 if (before) process.env.ANTHROPIC_API_KEY = before;
66 }
67 expect(true).toBe(true);
68 });
69
70 it("never throws even with invalid inputs", async () => {
71 let threw = false;
72 try {
73 await triggerAiReview(
74 "",
75 "",
76 "not-a-uuid",
77 "",
78 "",
79 "",
80 ""
81 );
82 } catch {
83 threw = true;
84 }
85 expect(threw).toBe(false);
86 });
87
88 it("survives an unknown branch combination without throwing", async () => {
89 let threw = false;
90 try {
91 await triggerAiReview(
92 "definitely-not-a-real-owner",
93 "definitely-not-a-real-repo",
94 "00000000-0000-0000-0000-000000000000",
95 "Title",
96 "Body",
97 "definitely-not-a-real-base",
98 "definitely-not-a-real-head"
99 );
100 } catch {
101 threw = true;
102 }
103 expect(threw).toBe(false);
104 });
105});
106
107describe("__test internals", () => {
108 it("exports diffBetweenBranches and alreadyReviewed", () => {
109 expect(typeof __test.diffBetweenBranches).toBe("function");
110 expect(typeof __test.alreadyReviewed).toBe("function");
111 });
112
113 it("diffBetweenBranches returns '' for a nonexistent repo", async () => {
114 const out = await __test.diffBetweenBranches(
115 "definitely-not-a-real-owner",
116 "definitely-not-a-real-repo",
117 "main",
118 "feature"
119 );
120 expect(out).toBe("");
121 });
122
123 it("alreadyReviewed returns false for an unknown PR id (fail-open)", async () => {
124 const out = await __test.alreadyReviewed("00000000-0000-0000-0000-000000000000");
125 expect(out).toBe(false);
126 });
127});
Addedsrc/__tests__/cron.test.ts+244−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/cron.ts.
3 *
4 * Pure module — no DB, no clock side effects. We exhaustively cover:
5 * - Field parser edge cases (literals, ranges, steps, lists, wildcards)
6 * - Whole-expression parser (5-field shape, errors)
7 * - cronMatches per-minute logic
8 * - cronFiredBetween interval semantics
9 * - POSIX OR semantics for dom & dow
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 parseCron,
15 cronMatches,
16 cronFiredBetween,
17 __test,
18} from "../lib/cron";
19
20const at = (iso: string) => new Date(iso);
21
22describe("parseField", () => {
23 const f = __test.parseField;
24
25 it("expands * to the full range", () => {
26 expect(f("*", 0, 4)).toEqual([0, 1, 2, 3, 4]);
27 });
28
29 it("parses literals", () => {
30 expect(f("3", 0, 59)).toEqual([3]);
31 });
32
33 it("parses ranges", () => {
34 expect(f("2-5", 0, 23)).toEqual([2, 3, 4, 5]);
35 });
36
37 it("parses steps with wildcard base", () => {
38 expect(f("*/15", 0, 59)).toEqual([0, 15, 30, 45]);
39 });
40
41 it("parses steps with range base", () => {
42 expect(f("0-10/2", 0, 59)).toEqual([0, 2, 4, 6, 8, 10]);
43 });
44
45 it("parses comma-separated lists with mixed forms", () => {
46 expect(f("0,15,30-32,*/30", 0, 59)).toEqual([0, 15, 30, 31, 32]);
47 });
48
49 it("rejects out-of-range values", () => {
50 expect(f("60", 0, 59)).toBeNull();
51 expect(f("-1", 0, 59)).toBeNull();
52 });
53
54 it("rejects bogus syntax", () => {
55 expect(f("a", 0, 59)).toBeNull();
56 expect(f("1-", 0, 59)).toBeNull();
57 expect(f("/5", 0, 59)).toBeNull();
58 expect(f("1/0", 0, 59)).toBeNull();
59 });
60
61 it("rejects literal+step combinations (1/5 makes no sense)", () => {
62 expect(f("5/2", 0, 59)).toBeNull();
63 });
64
65 it("rejects descending ranges", () => {
66 expect(f("5-3", 0, 59)).toBeNull();
67 });
68});
69
70describe("parseCron — error paths", () => {
71 it("rejects empty input", () => {
72 expect(parseCron("")).toEqual({ ok: false, error: "empty cron expression" });
73 });
74
75 it("rejects @-aliases", () => {
76 const r = parseCron("@hourly");
77 expect(r.ok).toBe(false);
78 if (!r.ok) expect(r.error).toContain("not supported");
79 });
80
81 it("rejects unsupported chars (L W # ?)", () => {
82 const r = parseCron("0 0 L * *");
83 expect(r.ok).toBe(false);
84 if (!r.ok) expect(r.error).toContain("unsupported characters");
85 });
86
87 it("rejects wrong number of fields", () => {
88 expect(parseCron("0 0 *").ok).toBe(false);
89 expect(parseCron("0 0 * * * *").ok).toBe(false);
90 });
91
92 it("collapses whitespace before counting fields", () => {
93 expect(parseCron("0 * * * *").ok).toBe(true);
94 });
95});
96
97describe("parseCron — happy path", () => {
98 it("every minute → all minutes/hours/dows/etc full", () => {
99 const r = parseCron("* * * * *");
100 expect(r.ok).toBe(true);
101 if (!r.ok) return;
102 expect(r.cron.minute.length).toBe(60);
103 expect(r.cron.hour.length).toBe(24);
104 expect(r.cron.dom.length).toBe(31);
105 expect(r.cron.month.length).toBe(12);
106 expect(r.cron.dow.length).toBe(7);
107 });
108
109 it("normalises dow=7 to dow=0", () => {
110 const r = parseCron("0 0 * * 7");
111 expect(r.ok).toBe(true);
112 if (!r.ok) return;
113 expect(r.cron.dow).toEqual([0]);
114 });
115
116 it("dedupes (e.g. 0,0,0)", () => {
117 const r = parseCron("0,0,0 * * * *");
118 expect(r.ok).toBe(true);
119 if (!r.ok) return;
120 expect(r.cron.minute).toEqual([0]);
121 });
122});
123
124describe("cronMatches", () => {
125 it("matches every-minute cron at any timestamp", () => {
126 const r = parseCron("* * * * *");
127 if (!r.ok) throw new Error("setup");
128 expect(cronMatches(r.cron, at("2026-04-30T12:34:56Z"))).toBe(true);
129 });
130
131 it("matches a specific minute only at that minute", () => {
132 const r = parseCron("0 * * * *");
133 if (!r.ok) throw new Error("setup");
134 expect(cronMatches(r.cron, at("2026-04-30T12:00:00Z"))).toBe(true);
135 expect(cronMatches(r.cron, at("2026-04-30T12:01:00Z"))).toBe(false);
136 });
137
138 it("matches a specific hour:minute (daily)", () => {
139 const r = parseCron("30 9 * * *");
140 if (!r.ok) throw new Error("setup");
141 expect(cronMatches(r.cron, at("2026-04-30T09:30:00Z"))).toBe(true);
142 expect(cronMatches(r.cron, at("2026-04-30T09:00:00Z"))).toBe(false);
143 expect(cronMatches(r.cron, at("2026-04-30T10:30:00Z"))).toBe(false);
144 });
145
146 it("matches by day-of-week (Mondays at 09:00)", () => {
147 const r = parseCron("0 9 * * 1");
148 if (!r.ok) throw new Error("setup");
149 // 2026-04-27 was a Monday. (Verified via UTC.)
150 expect(cronMatches(r.cron, at("2026-04-27T09:00:00Z"))).toBe(true);
151 // 2026-04-28 Tuesday
152 expect(cronMatches(r.cron, at("2026-04-28T09:00:00Z"))).toBe(false);
153 });
154
155 it("uses POSIX OR for dom + dow when both are restricted", () => {
156 // "Run on the 1st of the month OR every Friday at 12:00"
157 const r = parseCron("0 12 1 * 5");
158 if (!r.ok) throw new Error("setup");
159 // 2026-04-01 was a Wednesday (1st of April) → dom matches → fire.
160 expect(cronMatches(r.cron, at("2026-04-01T12:00:00Z"))).toBe(true);
161 // 2026-04-03 was a Friday → dow matches → fire.
162 expect(cronMatches(r.cron, at("2026-04-03T12:00:00Z"))).toBe(true);
163 // 2026-04-04 Saturday, not 1st → no fire.
164 expect(cronMatches(r.cron, at("2026-04-04T12:00:00Z"))).toBe(false);
165 });
166
167 it("matches every 15 minutes (*/15)", () => {
168 const r = parseCron("*/15 * * * *");
169 if (!r.ok) throw new Error("setup");
170 for (const min of [0, 15, 30, 45]) {
171 expect(
172 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
173 ).toBe(true);
174 }
175 for (const min of [1, 16, 31, 46]) {
176 expect(
177 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
178 ).toBe(false);
179 }
180 });
181});
182
183describe("cronFiredBetween", () => {
184 it("returns true when at least one minute in (since, until] matches", () => {
185 const r = parseCron("0 * * * *");
186 if (!r.ok) throw new Error("setup");
187 // since 12:30, until 13:05 — 13:00 lies in (since, until], should fire.
188 const fired = cronFiredBetween(
189 r.cron,
190 at("2026-04-30T12:30:00Z"),
191 at("2026-04-30T13:05:00Z")
192 );
193 expect(fired).toBe(true);
194 });
195
196 it("returns false when no matching minute in the interval", () => {
197 const r = parseCron("0 * * * *");
198 if (!r.ok) throw new Error("setup");
199 const fired = cronFiredBetween(
200 r.cron,
201 at("2026-04-30T12:01:00Z"),
202 at("2026-04-30T12:30:00Z")
203 );
204 expect(fired).toBe(false);
205 });
206
207 it("excludes the `since` boundary (half-open)", () => {
208 const r = parseCron("0 * * * *");
209 if (!r.ok) throw new Error("setup");
210 // since exactly at 12:00 — must not fire that same minute, only the
211 // next 12:00 would (which is an hour later). Until = 12:30 → false.
212 expect(
213 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
214 ).toBe(false);
215 });
216
217 it("includes the `until` boundary (half-open at the right)", () => {
218 const r = parseCron("30 * * * *");
219 if (!r.ok) throw new Error("setup");
220 expect(
221 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
222 ).toBe(true);
223 });
224
225 it("returns false on a zero/negative interval", () => {
226 const r = parseCron("* * * * *");
227 if (!r.ok) throw new Error("setup");
228 expect(
229 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T11:00:00Z"))
230 ).toBe(false);
231 });
232
233 it("caps the lookback at 1 day so a misconfigured `since` cannot blow up", () => {
234 const r = parseCron("* * * * *");
235 if (!r.ok) throw new Error("setup");
236 // since in 2020, until now — should still return true (every-minute
237 // cron always fires) without iterating millions of minutes.
238 const start = Date.now();
239 expect(
240 cronFiredBetween(r.cron, at("2020-01-01T00:00:00Z"), new Date())
241 ).toBe(true);
242 expect(Date.now() - start).toBeLessThan(1000);
243 });
244});
Addedsrc/__tests__/dashboard-coach.test.ts+103−0View fileUnifiedSplit
1/**
2 * Tests for the AI Health Coach pure helper exported from
3 * src/routes/dashboard.tsx (`pickRepoCoachPicks`).
4 *
5 * The dashboard route file is a .tsx module that may not import in
6 * the JSX-dev-runtime-less test sandbox; we use the same defensive
7 * loader pattern as other route tests so the suite stays green
8 * regardless. Pure-function semantics are pinned exhaustively.
9 */
10
11import { describe, it, expect } from "bun:test";
12
13async function tryLoad(): Promise<
14 | { ok: true; pickRepoCoachPicks: any }
15 | { ok: false; reason: "jsx-dev-runtime" | "other"; err: Error }
16> {
17 try {
18 const mod: any = await import("../routes/dashboard");
19 return { ok: true, pickRepoCoachPicks: mod.pickRepoCoachPicks };
20 } catch (err) {
21 const e = err instanceof Error ? err : new Error(String(err));
22 const reason = /jsx[-/]dev[-/]?runtime/i.test(e.message)
23 ? "jsx-dev-runtime"
24 : "other";
25 return { ok: false, reason, err: e };
26 }
27}
28
29const repo = (name: string, score: number, grade = "?") => ({
30 repo: { name, description: null },
31 healthScore: score,
32 healthGrade: grade,
33});
34
35describe("pickRepoCoachPicks — pure helper", () => {
36 it("filters out healthy repos (score >= 90)", async () => {
37 const loaded = await tryLoad();
38 if (!loaded.ok) {
39 expect(loaded.reason).toBe("jsx-dev-runtime");
40 return;
41 }
42 const fn = loaded.pickRepoCoachPicks;
43 const picks = fn([repo("a", 92), repo("b", 85), repo("c", 95)]);
44 expect(picks.map((p: any) => p.repo.name)).toEqual(["b"]);
45 });
46
47 it("filters out unscored repos (score === 0)", async () => {
48 const loaded = await tryLoad();
49 if (!loaded.ok) {
50 expect(loaded.reason).toBe("jsx-dev-runtime");
51 return;
52 }
53 const fn = loaded.pickRepoCoachPicks;
54 const picks = fn([repo("a", 0), repo("b", 70), repo("c", 0)]);
55 expect(picks.map((p: any) => p.repo.name)).toEqual(["b"]);
56 });
57
58 it("returns the lowest-N scores in ascending order", async () => {
59 const loaded = await tryLoad();
60 if (!loaded.ok) {
61 expect(loaded.reason).toBe("jsx-dev-runtime");
62 return;
63 }
64 const fn = loaded.pickRepoCoachPicks;
65 const picks = fn([
66 repo("a", 80),
67 repo("b", 50),
68 repo("c", 70),
69 repo("d", 60),
70 ]);
71 expect(picks.map((p: any) => p.repo.name)).toEqual(["b", "d", "c"]);
72 });
73
74 it("respects the topN cap (default 3)", async () => {
75 const loaded = await tryLoad();
76 if (!loaded.ok) {
77 expect(loaded.reason).toBe("jsx-dev-runtime");
78 return;
79 }
80 const fn = loaded.pickRepoCoachPicks;
81 const all = [
82 repo("a", 10),
83 repo("b", 20),
84 repo("c", 30),
85 repo("d", 40),
86 repo("e", 50),
87 ];
88 expect(fn(all).length).toBe(3);
89 expect(fn(all, 5).length).toBe(5);
90 expect(fn(all, 1)[0].repo.name).toBe("a");
91 });
92
93 it("returns [] when no repos qualify", async () => {
94 const loaded = await tryLoad();
95 if (!loaded.ok) {
96 expect(loaded.reason).toBe("jsx-dev-runtime");
97 return;
98 }
99 const fn = loaded.pickRepoCoachPicks;
100 expect(fn([])).toEqual([]);
101 expect(fn([repo("a", 0), repo("b", 95)])).toEqual([]);
102 });
103});
Addedsrc/__tests__/editor-ai-commit.test.ts+44−0View fileUnifiedSplit
1/**
2 * Smoke tests for the new AI commit-message endpoint:
3 * POST /:owner/:repo/ai/commit-message
4 *
5 * The route requires write access. Anonymous and bogus-bearer callers
6 * should never see a 200 / leaked AI body.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11
12describe("POST /:owner/:repo/ai/commit-message — auth guard", () => {
13 it("redirects to /login when unauthenticated", async () => {
14 const res = await app.request(
15 "/alice/demo/ai/commit-message",
16 {
17 method: "POST",
18 headers: { "content-type": "application/x-www-form-urlencoded" },
19 body: "ref=main&filePath=README.md&content=hello",
20 redirect: "manual",
21 }
22 );
23 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
24 if (res.status === 302 || res.status === 303 || res.status === 307) {
25 const loc = res.headers.get("location") || "";
26 expect(loc).toContain("/login");
27 }
28 });
29
30 it("rejects bogus bearer tokens", async () => {
31 const res = await app.request(
32 "/alice/demo/ai/commit-message",
33 {
34 method: "POST",
35 headers: {
36 "content-type": "application/x-www-form-urlencoded",
37 authorization: "Bearer glct_definitely-not-valid",
38 },
39 body: "ref=main&filePath=README.md&content=hello",
40 }
41 );
42 expect([401, 403, 404, 503]).toContain(res.status);
43 });
44});
Modifiedsrc/__tests__/environments.test.ts+101−0View fileUnifiedSplit
1515 reduceApprovalState,
1616 reviewerIdsOf,
1717 allowedBranchesOf,
18 latestApprovalAt,
19 computeReadyAfter,
20 releaseExpiredWaitTimers,
1821} from "../lib/environments";
1922import type { Environment, DeploymentApproval } from "../db/schema";
2023
194197 expect([401, 404, 503]).toContain(res.status);
195198 });
196199});
200
201// ---------------------------------------------------------------------------
202// Wait-timer enforcement (no longer a stub)
203// ---------------------------------------------------------------------------
204
205const mkApprovalAt = (
206 decision: "approved" | "rejected",
207 createdAt: Date,
208 userId = "u1"
209): DeploymentApproval =>
210 ({
211 id: `a-${Math.random()}`,
212 deploymentId: "d1",
213 userId,
214 decision,
215 comment: null,
216 createdAt,
217 }) as DeploymentApproval;
218
219describe("latestApprovalAt", () => {
220 it("returns null when there are no approvals", () => {
221 expect(latestApprovalAt([])).toBeNull();
222 });
223
224 it("ignores rejection timestamps", () => {
225 const t1 = new Date("2026-01-01T00:00:00Z");
226 expect(latestApprovalAt([mkApprovalAt("rejected", t1)])).toBeNull();
227 });
228
229 it("returns the most recent approval timestamp", () => {
230 const t1 = new Date("2026-01-01T00:00:00Z");
231 const t2 = new Date("2026-01-02T00:00:00Z");
232 const t3 = new Date("2026-01-03T00:00:00Z");
233 const out = latestApprovalAt([
234 mkApprovalAt("approved", t2, "u1"),
235 mkApprovalAt("approved", t3, "u2"),
236 mkApprovalAt("approved", t1, "u3"),
237 ]);
238 expect(out?.toISOString()).toBe(t3.toISOString());
239 });
240
241 it("tolerates a malformed createdAt", () => {
242 const out = latestApprovalAt([
243 {
244 ...mkApprovalAt("approved", new Date("2026-01-01T00:00:00Z")),
245 createdAt: new Date("not-a-real-date"),
246 } as any,
247 ]);
248 expect(out).toBeNull();
249 });
250});
251
252describe("computeReadyAfter", () => {
253 it("returns null when waitTimerMinutes <= 0", () => {
254 const env = envFixture({ waitTimerMinutes: 0 });
255 const t = new Date("2026-01-01T00:00:00Z");
256 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
257 });
258
259 it("returns null when there are no approvals (timer hasn't started)", () => {
260 const env = envFixture({ waitTimerMinutes: 30 });
261 expect(computeReadyAfter(env, [])).toBeNull();
262 });
263
264 it("adds waitTimerMinutes to the latest approval timestamp", () => {
265 const env = envFixture({ waitTimerMinutes: 30 });
266 const t = new Date("2026-01-01T12:00:00Z");
267 const ready = computeReadyAfter(env, [mkApprovalAt("approved", t)]);
268 expect(ready?.toISOString()).toBe("2026-01-01T12:30:00.000Z");
269 });
270
271 it("uses the latest approval, not the earliest", () => {
272 const env = envFixture({ waitTimerMinutes: 60 });
273 const t1 = new Date("2026-01-01T00:00:00Z");
274 const t2 = new Date("2026-01-01T01:00:00Z");
275 const ready = computeReadyAfter(env, [
276 mkApprovalAt("approved", t1, "u1"),
277 mkApprovalAt("approved", t2, "u2"),
278 ]);
279 expect(ready?.toISOString()).toBe("2026-01-01T02:00:00.000Z");
280 });
281
282 it("returns null on a malformed waitTimerMinutes value", () => {
283 const env = envFixture({ waitTimerMinutes: NaN as any });
284 const t = new Date("2026-01-01T00:00:00Z");
285 expect(computeReadyAfter(env, [mkApprovalAt("approved", t)])).toBeNull();
286 });
287});
288
289describe("releaseExpiredWaitTimers — fail-open", () => {
290 it("returns 0 (not throws) when DB is unavailable / no rows match", async () => {
291 // No live DB → drizzle will throw inside the helper, which catches and
292 // returns 0. Either way the test asserts the never-throw contract.
293 const out = await releaseExpiredWaitTimers(new Date());
294 expect(typeof out).toBe("number");
295 expect(out).toBeGreaterThanOrEqual(0);
296 });
297});
Addedsrc/__tests__/git-push-auth.test.ts+119−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/git-push-auth.ts.
3 *
4 * The DB-touching resolveByPat / resolveByOauth paths require a live DB,
5 * which we don't set up in unit tests. Instead we cover the pure header
6 * decoders (`decodeBasicAuth`, `decodeBearerAuth`) exhaustively and assert
7 * that `resolvePusher` returns null for every shape that should fall back
8 * to anonymous (no header, garbage header, unknown prefix, empty secret).
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 decodeBasicAuth,
14 decodeBearerAuth,
15 resolvePusher,
16} from "../lib/git-push-auth";
17
18function b64(s: string): string {
19 return Buffer.from(s, "utf8").toString("base64");
20}
21
22describe("decodeBasicAuth", () => {
23 it("decodes a normal user:secret pair", () => {
24 const out = decodeBasicAuth(`Basic ${b64("alice:glc_abc123")}`);
25 expect(out).toEqual({ user: "alice", secret: "glc_abc123" });
26 });
27
28 it("is case-insensitive on the scheme keyword", () => {
29 const out = decodeBasicAuth(`basic ${b64("alice:glc_abc")}`);
30 expect(out?.user).toBe("alice");
31 expect(out?.secret).toBe("glc_abc");
32 });
33
34 it("tolerates extra whitespace", () => {
35 const out = decodeBasicAuth(` Basic ${b64("alice:glc_x")} `);
36 expect(out).not.toBeNull();
37 expect(out?.user).toBe("alice");
38 });
39
40 it("returns null for empty / missing header", () => {
41 expect(decodeBasicAuth(null)).toBeNull();
42 expect(decodeBasicAuth(undefined)).toBeNull();
43 expect(decodeBasicAuth("")).toBeNull();
44 });
45
46 it("returns null when the scheme isn't Basic", () => {
47 expect(decodeBasicAuth(`Bearer ${b64("a:b")}`)).toBeNull();
48 expect(decodeBasicAuth("Token foobar")).toBeNull();
49 });
50
51 it("returns null when the credential lacks a colon separator", () => {
52 expect(decodeBasicAuth(`Basic ${b64("nocolonhere")}`)).toBeNull();
53 });
54
55 it("preserves a colon inside the secret", () => {
56 // Tokens never have colons in practice, but if one did the split must
57 // pick the first colon only.
58 const out = decodeBasicAuth(`Basic ${b64("user:has:colon")}`);
59 expect(out).toEqual({ user: "user", secret: "has:colon" });
60 });
61
62 it("allows an empty username", () => {
63 // git CLI sometimes sends an empty username + the PAT in the password.
64 const out = decodeBasicAuth(`Basic ${b64(":glc_abc")}`);
65 expect(out).toEqual({ user: "", secret: "glc_abc" });
66 });
67});
68
69describe("decodeBearerAuth", () => {
70 it("strips the Bearer prefix", () => {
71 expect(decodeBearerAuth("Bearer glct_abc")).toBe("glct_abc");
72 expect(decodeBearerAuth("bearer glc_xyz")).toBe("glc_xyz");
73 });
74
75 it("returns null for non-Bearer schemes", () => {
76 expect(decodeBearerAuth("Basic abc")).toBeNull();
77 expect(decodeBearerAuth("Token glc_xyz")).toBeNull();
78 });
79
80 it("returns null on empty / missing header", () => {
81 expect(decodeBearerAuth(null)).toBeNull();
82 expect(decodeBearerAuth(undefined)).toBeNull();
83 expect(decodeBearerAuth("")).toBeNull();
84 expect(decodeBearerAuth("Bearer ")).toBeNull();
85 });
86});
87
88describe("resolvePusher — anonymous fallbacks", () => {
89 it("returns null when there is no auth header", async () => {
90 expect(await resolvePusher(null)).toBeNull();
91 expect(await resolvePusher(undefined)).toBeNull();
92 expect(await resolvePusher("")).toBeNull();
93 });
94
95 it("returns null on an unrecognised scheme", async () => {
96 expect(await resolvePusher("Unknown abc")).toBeNull();
97 });
98
99 it("returns null on a Bearer with an unknown prefix", async () => {
100 expect(await resolvePusher("Bearer notatoken")).toBeNull();
101 });
102
103 it("returns null on a Basic with an unknown-prefix secret", async () => {
104 expect(await resolvePusher(`Basic ${b64("alice:notatoken")}`)).toBeNull();
105 });
106
107 it("returns null on a Basic with an empty secret", async () => {
108 expect(await resolvePusher(`Basic ${b64("alice:")}`)).toBeNull();
109 });
110
111 it("returns null for a ghi_ install token that doesn't exist in DB", async () => {
112 // Real install tokens are sha256-hashed in app_install_tokens; an
113 // unknown token should fail soft to anonymous, never throw.
114 expect(await resolvePusher("Bearer ghi_definitely-not-real")).toBeNull();
115 expect(
116 await resolvePusher(`Basic ${b64("x-access-token:ghi_definitely-not-real")}`)
117 ).toBeNull();
118 });
119});
Addedsrc/__tests__/issue-triage.test.ts+166−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/issue-triage.ts.
3 *
4 * Pure helper renderIssueTriageComment covered exhaustively. The
5 * DB-touching paths (alreadyTriaged, loadAvailableLabels,
6 * loadRecentIssues) are exercised via the fail-open contracts.
7 *
8 * triggerIssueTriage itself is verified to be a no-throw fire-and-forget
9 * even when called with garbage inputs and no API key.
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 ISSUE_TRIAGE_MARKER,
15 triggerIssueTriage,
16 __test,
17} from "../lib/issue-triage";
18import type { IssueTriage } from "../lib/ai-generators";
19
20const triageFixture = (overrides: Partial<IssueTriage> = {}): IssueTriage => ({
21 suggestedLabels: ["bug", "ui"],
22 duplicateOfIssueNumber: null,
23 priority: "medium",
24 summary: "Login button is unreadable in dark mode.",
25 ...overrides,
26});
27
28describe("ISSUE_TRIAGE_MARKER", () => {
29 it("is a stable HTML comment so future searches keep working", () => {
30 expect(ISSUE_TRIAGE_MARKER).toBe("<!-- gluecron-issue-triage:summary -->");
31 expect(ISSUE_TRIAGE_MARKER.startsWith("<!--")).toBe(true);
32 expect(ISSUE_TRIAGE_MARKER.endsWith("-->")).toBe(true);
33 });
34});
35
36describe("renderIssueTriageComment", () => {
37 it("includes the marker, summary, priority, labels", () => {
38 const out = __test.renderIssueTriageComment(triageFixture());
39 expect(out).toContain(ISSUE_TRIAGE_MARKER);
40 expect(out).toContain("## AI Triage");
41 expect(out).toContain("Login button is unreadable in dark mode.");
42 expect(out).toContain("**Priority:** medium");
43 expect(out).toContain("`bug`");
44 expect(out).toContain("`ui`");
45 });
46
47 it("uses an italic placeholder when there are no label suggestions", () => {
48 const out = __test.renderIssueTriageComment(
49 triageFixture({ suggestedLabels: [] })
50 );
51 expect(out).toContain("_(no label suggestions)_");
52 });
53
54 it("uses an italic placeholder when summary is missing/whitespace", () => {
55 const a = __test.renderIssueTriageComment(triageFixture({ summary: "" }));
56 const b = __test.renderIssueTriageComment(triageFixture({ summary: " " }));
57 expect(a).toContain("_(no summary)_");
58 expect(b).toContain("_(no summary)_");
59 });
60
61 it("renders a duplicate callout when AI flagged one", () => {
62 const out = __test.renderIssueTriageComment(
63 triageFixture({ duplicateOfIssueNumber: 42 })
64 );
65 expect(out).toContain("**Possible duplicate of:** #42");
66 });
67
68 it("omits the duplicate callout for null / 0 / negative numbers", () => {
69 const a = __test.renderIssueTriageComment(triageFixture({ duplicateOfIssueNumber: null }));
70 const b = __test.renderIssueTriageComment(
71 triageFixture({ duplicateOfIssueNumber: 0 as any })
72 );
73 const c = __test.renderIssueTriageComment(
74 triageFixture({ duplicateOfIssueNumber: -1 as any })
75 );
76 for (const out of [a, b, c]) {
77 expect(out).not.toContain("Possible duplicate of");
78 }
79 });
80
81 it("ends with the suggestions-only disclaimer", () => {
82 const out = __test.renderIssueTriageComment(triageFixture());
83 expect(out.trimEnd().endsWith(
84 "_Suggestions only — nothing has been applied. The author stays in control._"
85 )).toBe(true);
86 });
87
88 it("formats critical priority literally (no pictographic emoji)", () => {
89 const out = __test.renderIssueTriageComment(
90 triageFixture({ priority: "critical" })
91 );
92 expect(out).toContain("**Priority:** critical");
93 expect(/\p{Extended_Pictographic}/u.test(out)).toBe(false);
94 });
95});
96
97describe("__test fail-open contracts", () => {
98 it("alreadyTriaged returns false when no DB / unknown issue", async () => {
99 const out = await __test.alreadyTriaged(
100 "00000000-0000-0000-0000-000000000000"
101 );
102 expect(out).toBe(false);
103 });
104
105 it("loadAvailableLabels returns [] for an unknown repo", async () => {
106 const out = await __test.loadAvailableLabels(
107 "00000000-0000-0000-0000-000000000000"
108 );
109 expect(Array.isArray(out)).toBe(true);
110 });
111
112 it("loadRecentIssues returns [] for an unknown repo", async () => {
113 const out = await __test.loadRecentIssues(
114 "00000000-0000-0000-0000-000000000000",
115 "00000000-0000-0000-0000-000000000000"
116 );
117 expect(Array.isArray(out)).toBe(true);
118 });
119});
120
121describe("triggerIssueTriage — fire-and-forget never throws", () => {
122 const before = process.env.ANTHROPIC_API_KEY;
123
124 it("resolves without throwing when API key is absent", async () => {
125 delete process.env.ANTHROPIC_API_KEY;
126 try {
127 let threw = false;
128 try {
129 await triggerIssueTriage({
130 ownerName: "alice",
131 repoName: "demo",
132 repositoryId: "00000000-0000-0000-0000-000000000000",
133 issueId: "00000000-0000-0000-0000-000000000000",
134 issueNumber: 1,
135 authorId: "00000000-0000-0000-0000-000000000000",
136 title: "Test",
137 body: "",
138 });
139 } catch {
140 threw = true;
141 }
142 expect(threw).toBe(false);
143 } finally {
144 if (before) process.env.ANTHROPIC_API_KEY = before;
145 }
146 });
147
148 it("never throws on garbage inputs", async () => {
149 let threw = false;
150 try {
151 await triggerIssueTriage({
152 ownerName: "",
153 repoName: "",
154 repositoryId: "not-a-uuid",
155 issueId: "not-a-uuid",
156 issueNumber: -1,
157 authorId: "not-a-uuid",
158 title: "",
159 body: "",
160 });
161 } catch {
162 threw = true;
163 }
164 expect(threw).toBe(false);
165 });
166});
Addedsrc/__tests__/issues-ai-retriage.test.ts+69−0View fileUnifiedSplit
1/**
2 * Smoke tests for the on-demand issue re-triage endpoint:
3 * POST /:owner/:repo/issues/:number/ai-retriage
4 *
5 * Write-access only. Verifies auth-guard contracts and the
6 * triggerIssueTriage `force` parameter never-throws contract.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11
12describe("POST /:owner/:repo/issues/:number/ai-retriage — auth guard", () => {
13 it("redirects to /login when unauthenticated", async () => {
14 const res = await app.request(
15 "/alice/demo/issues/1/ai-retriage",
16 {
17 method: "POST",
18 headers: { "content-type": "application/x-www-form-urlencoded" },
19 body: "",
20 redirect: "manual",
21 }
22 );
23 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
24 if (res.status === 302 || res.status === 303 || res.status === 307) {
25 const loc = res.headers.get("location") || "";
26 expect(loc).toContain("/login");
27 }
28 });
29
30 it("rejects bogus bearer tokens", async () => {
31 const res = await app.request(
32 "/alice/demo/issues/1/ai-retriage",
33 {
34 method: "POST",
35 headers: {
36 "content-type": "application/x-www-form-urlencoded",
37 authorization: "Bearer glct_definitely-not-valid",
38 },
39 body: "",
40 }
41 );
42 expect([401, 403, 404, 503]).toContain(res.status);
43 });
44});
45
46describe("triggerIssueTriage — force option (idempotency bypass)", () => {
47 it("accepts and propagates the force flag without throwing", async () => {
48 const { triggerIssueTriage } = await import("../lib/issue-triage");
49 let threw = false;
50 try {
51 await triggerIssueTriage(
52 {
53 ownerName: "alice",
54 repoName: "demo",
55 repositoryId: "00000000-0000-0000-0000-000000000000",
56 issueId: "00000000-0000-0000-0000-000000000000",
57 issueNumber: 1,
58 authorId: "00000000-0000-0000-0000-000000000000",
59 title: "Test",
60 body: "",
61 },
62 { force: true }
63 );
64 } catch {
65 threw = true;
66 }
67 expect(threw).toBe(false);
68 });
69});
Addedsrc/__tests__/live-events.test.ts+82−0View fileUnifiedSplit
1/**
2 * Smoke tests for GET /live-events/:topic — primarily the topic
3 * grammar. The full streaming path needs a live DB (read-gate), so we
4 * focus on:
5 * - 400 on invalid topics (single-letter, missing kind, bad chars)
6 * - non-400 on the valid forms (single-segment `kind:id`, multi-
7 * segment `kind:id:scope1:scope2`).
8 *
9 * The route uses softAuth; no cookie required.
10 */
11
12import { describe, it, expect } from "bun:test";
13import app from "../app";
14
15const NON_400_OK = [200, 301, 302, 303, 307, 401, 403, 404, 503];
16
17describe("GET /live-events/:topic — topic grammar", () => {
18 it("rejects an empty path beyond the prefix", async () => {
19 const res = await app.request("/live-events/", { redirect: "manual" });
20 // 404 for "no param" (Hono path doesn't match) is acceptable.
21 expect([400, 404]).toContain(res.status);
22 });
23
24 it("rejects a kind-only topic (no id)", async () => {
25 const res = await app.request("/live-events/repo");
26 expect([400, 404]).toContain(res.status);
27 });
28
29 it("rejects topics with disallowed characters", async () => {
30 const res = await app.request(
31 "/live-events/" + encodeURIComponent("repo:has spaces")
32 );
33 expect(res.status).toBe(400);
34 });
35
36 it("rejects topics with embedded slash", async () => {
37 const res = await app.request(
38 "/live-events/" + encodeURIComponent("repo:foo/bar")
39 );
40 expect(res.status).toBe(400);
41 });
42
43 it("accepts the canonical single-segment form (repo:<uuid>)", async () => {
44 const uuid = "00000000-0000-0000-0000-000000000000";
45 const res = await app.request(`/live-events/repo:${uuid}`, {
46 redirect: "manual",
47 });
48 // Either 404 (repo not found in DB) or some auth response — but
49 // critically not 400. The grammar must accept this shape.
50 expect(res.status).not.toBe(400);
51 expect(NON_400_OK).toContain(res.status);
52 });
53
54 it("accepts a multi-segment topic (repo:<uuid>:issue:7)", async () => {
55 const uuid = "00000000-0000-0000-0000-000000000000";
56 const res = await app.request(
57 `/live-events/repo:${uuid}:issue:7`,
58 { redirect: "manual" }
59 );
60 expect(res.status).not.toBe(400);
61 expect(NON_400_OK).toContain(res.status);
62 });
63
64 it("accepts a multi-segment PR topic (repo:<uuid>:pr:9)", async () => {
65 const uuid = "00000000-0000-0000-0000-000000000000";
66 const res = await app.request(
67 `/live-events/repo:${uuid}:pr:9`,
68 { redirect: "manual" }
69 );
70 expect(res.status).not.toBe(400);
71 expect(NON_400_OK).toContain(res.status);
72 });
73
74 it("accepts a non-repo kind (passes the read-gate)", async () => {
75 // `user:42` is not a repo so the auth-gate is bypassed; should
76 // open a stream (200 + text/event-stream) or fail later.
77 const res = await app.request(`/live-events/user:42`, {
78 redirect: "manual",
79 });
80 expect(res.status).not.toBe(400);
81 });
82});
Addedsrc/__tests__/mcp.test.ts+212−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/mcp.ts (router) + src/lib/mcp-tools.ts (tools).
3 *
4 * The DB-touching tool runs require live Postgres; we focus on:
5 * - JSON-RPC envelope shape (initialize / tools/list / unknown method)
6 * - Notification handling (no `id` → no response)
7 * - tools/call validation (missing name, unknown tool)
8 * - Tool manifest shape (every default tool has a name + inputSchema)
9 * - Pure helper edge cases (argString / argNumber)
10 * - The HTTP route's discovery GET + JSON-RPC POST shape
11 */
12
13import { describe, it, expect } from "bun:test";
14import {
15 routeMcpRequest,
16 MCP_PROTOCOL_VERSION,
17 MCP_SERVER_NAME,
18 ERR_METHOD_NOT_FOUND,
19 ERR_INVALID_REQUEST,
20 ERR_INVALID_PARAMS,
21 McpError,
22} from "../lib/mcp";
23import { defaultTools, __test } from "../lib/mcp-tools";
24import app from "../app";
25
26const tools = defaultTools();
27const ctx = { userId: null };
28
29describe("routeMcpRequest — initialize", () => {
30 it("returns the protocol version + server info", async () => {
31 const r = await routeMcpRequest(
32 { jsonrpc: "2.0", id: 1, method: "initialize" },
33 { ctx, tools }
34 );
35 expect(r).not.toBeNull();
36 if (!r || "error" in r) throw new Error("expected success");
37 const result = r.result as any;
38 expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
39 expect(result.serverInfo.name).toBe(MCP_SERVER_NAME);
40 expect(result.capabilities.tools).toBeDefined();
41 });
42});
43
44describe("routeMcpRequest — invalid input", () => {
45 it("rejects a non-JSON-RPC envelope", async () => {
46 const r = await routeMcpRequest({ method: "x" } as any, { ctx, tools });
47 if (!r || "result" in r) throw new Error("expected error");
48 expect(r.error.code).toBe(ERR_INVALID_REQUEST);
49 });
50
51 it("rejects an unknown method", async () => {
52 const r = await routeMcpRequest(
53 { jsonrpc: "2.0", id: 1, method: "no_such_method" },
54 { ctx, tools }
55 );
56 if (!r || "result" in r) throw new Error("expected error");
57 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
58 });
59
60 it("returns null for a notification (no `id`)", async () => {
61 const r = await routeMcpRequest(
62 { jsonrpc: "2.0", method: "notifications/initialized" },
63 { ctx, tools }
64 );
65 expect(r).toBeNull();
66 });
67});
68
69describe("routeMcpRequest — tools/list", () => {
70 it("returns the full tool manifest", async () => {
71 const r = await routeMcpRequest(
72 { jsonrpc: "2.0", id: 2, method: "tools/list" },
73 { ctx, tools }
74 );
75 if (!r || "error" in r) throw new Error("expected success");
76 const result = r.result as { tools: Array<{ name: string; inputSchema: any }> };
77 expect(Array.isArray(result.tools)).toBe(true);
78 expect(result.tools.length).toBeGreaterThanOrEqual(4);
79 for (const t of result.tools) {
80 expect(typeof t.name).toBe("string");
81 expect(t.name).toMatch(/^gluecron_/);
82 expect(t.inputSchema.type).toBe("object");
83 }
84 });
85});
86
87describe("routeMcpRequest — tools/call validation", () => {
88 it("rejects calls without a name", async () => {
89 const r = await routeMcpRequest(
90 { jsonrpc: "2.0", id: 3, method: "tools/call", params: {} },
91 { ctx, tools }
92 );
93 if (!r || "result" in r) throw new Error("expected error");
94 expect(r.error.code).toBe(ERR_INVALID_PARAMS);
95 });
96
97 it("rejects calls with an unknown tool name", async () => {
98 const r = await routeMcpRequest(
99 {
100 jsonrpc: "2.0",
101 id: 4,
102 method: "tools/call",
103 params: { name: "no_such_tool", arguments: {} },
104 },
105 { ctx, tools }
106 );
107 if (!r || "result" in r) throw new Error("expected error");
108 expect(r.error.code).toBe(ERR_METHOD_NOT_FOUND);
109 });
110});
111
112describe("argString / argNumber pure helpers", () => {
113 it("returns the trimmed string when present", () => {
114 expect(__test.argString({ x: " hi " }, "x")).toBe("hi");
115 });
116
117 it("falls back when missing", () => {
118 expect(__test.argString({}, "x", "default")).toBe("default");
119 });
120
121 it("throws McpError when missing without fallback", () => {
122 expect(() => __test.argString({}, "x")).toThrow(McpError);
123 });
124
125 it("argNumber accepts numeric strings", () => {
126 expect(__test.argNumber({ n: "42" }, "n")).toBe(42);
127 });
128
129 it("argNumber falls back on non-numeric input", () => {
130 expect(__test.argNumber({ n: "abc" }, "n", 7)).toBe(7);
131 });
132});
133
134describe("tool manifest shape", () => {
135 for (const handler of Object.values(tools)) {
136 it(`${handler.tool.name} — required fields populated`, () => {
137 expect(handler.tool.name).toMatch(/^gluecron_/);
138 expect(typeof handler.tool.description).toBe("string");
139 expect(handler.tool.description.length).toBeGreaterThan(10);
140 expect(handler.tool.inputSchema.type).toBe("object");
141 expect(typeof handler.tool.inputSchema.properties).toBe("object");
142 });
143 }
144});
145
146describe("HTTP route — GET /mcp discovery", () => {
147 it("returns server info + tool count", async () => {
148 const res = await app.request("/mcp");
149 expect(res.status).toBe(200);
150 const body = (await res.json()) as any;
151 expect(body.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
152 expect(body.serverInfo.name).toBe(MCP_SERVER_NAME);
153 expect(body.toolCount).toBeGreaterThanOrEqual(4);
154 });
155});
156
157describe("HTTP route — POST /mcp JSON-RPC", () => {
158 it("answers initialize over the wire", async () => {
159 const res = await app.request("/mcp", {
160 method: "POST",
161 headers: { "content-type": "application/json" },
162 body: JSON.stringify({
163 jsonrpc: "2.0",
164 id: 1,
165 method: "initialize",
166 }),
167 });
168 expect(res.status).toBe(200);
169 const body = (await res.json()) as any;
170 expect(body.jsonrpc).toBe("2.0");
171 expect(body.id).toBe(1);
172 expect(body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
173 });
174
175 it("returns 400 on invalid JSON", async () => {
176 const res = await app.request("/mcp", {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: "not json",
180 });
181 expect(res.status).toBe(400);
182 });
183
184 it("returns 204 for a single notification (no `id`)", async () => {
185 const res = await app.request("/mcp", {
186 method: "POST",
187 headers: { "content-type": "application/json" },
188 body: JSON.stringify({
189 jsonrpc: "2.0",
190 method: "notifications/initialized",
191 }),
192 });
193 expect(res.status).toBe(204);
194 });
195
196 it("supports a batched request", async () => {
197 const res = await app.request("/mcp", {
198 method: "POST",
199 headers: { "content-type": "application/json" },
200 body: JSON.stringify([
201 { jsonrpc: "2.0", id: 1, method: "initialize" },
202 { jsonrpc: "2.0", id: 2, method: "tools/list" },
203 ]),
204 });
205 expect(res.status).toBe(200);
206 const body = (await res.json()) as any[];
207 expect(body.length).toBe(2);
208 expect(body[0].id).toBe(1);
209 expect(body[1].id).toBe(2);
210 expect(body[1].result.tools.length).toBeGreaterThanOrEqual(4);
211 });
212});
Addedsrc/__tests__/pr-triage.test.ts+172−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/pr-triage.ts.
3 *
4 * Pure helpers (renderTriageComment) covered exhaustively. The
5 * DB-touching paths (alreadyTriaged, loadAvailableLabels,
6 * loadCandidateReviewers, buildDiffSummary) are exercised via the
7 * fail-open contracts — they must return reasonable values when no
8 * DB / no repo on disk, never throw.
9 *
10 * triggerPrTriage itself is verified to be a no-throw fire-and-forget
11 * even when called with garbage inputs and no API key.
12 */
13
14import { describe, it, expect } from "bun:test";
15import {
16 PR_TRIAGE_MARKER,
17 triggerPrTriage,
18 __test,
19} from "../lib/pr-triage";
20import type { PrTriage } from "../lib/ai-generators";
21
22const triageFixture = (overrides: Partial<PrTriage> = {}): PrTriage => ({
23 suggestedLabels: ["bug", "ui"],
24 suggestedReviewerUsernames: ["alice", "bob"],
25 priority: "medium",
26 riskArea: "frontend",
27 summary: "Fix login form colour contrast.",
28 ...overrides,
29});
30
31describe("PR_TRIAGE_MARKER", () => {
32 it("is a stable HTML comment so future searches keep working", () => {
33 expect(PR_TRIAGE_MARKER).toBe("<!-- gluecron-pr-triage:summary -->");
34 expect(PR_TRIAGE_MARKER.startsWith("<!--")).toBe(true);
35 expect(PR_TRIAGE_MARKER.endsWith("-->")).toBe(true);
36 });
37});
38
39describe("renderTriageComment", () => {
40 it("includes the marker, summary, priority, risk area, labels, reviewers", () => {
41 const out = __test.renderTriageComment(triageFixture());
42 expect(out).toContain(PR_TRIAGE_MARKER);
43 expect(out).toContain("## AI Triage");
44 expect(out).toContain("Fix login form colour contrast.");
45 expect(out).toContain("**Priority:** medium");
46 expect(out).toContain("**Risk area:** frontend");
47 expect(out).toContain("`bug`");
48 expect(out).toContain("`ui`");
49 expect(out).toContain("@alice");
50 expect(out).toContain("@bob");
51 });
52
53 it("uses an italic placeholder when there are no label suggestions", () => {
54 const out = __test.renderTriageComment(triageFixture({ suggestedLabels: [] }));
55 expect(out).toContain("_(no label suggestions)_");
56 });
57
58 it("uses an italic placeholder when there are no reviewer suggestions", () => {
59 const out = __test.renderTriageComment(
60 triageFixture({ suggestedReviewerUsernames: [] })
61 );
62 expect(out).toContain("_(no reviewer suggestions)_");
63 });
64
65 it("renders an italic placeholder when summary is missing/whitespace", () => {
66 const a = __test.renderTriageComment(triageFixture({ summary: "" }));
67 const b = __test.renderTriageComment(triageFixture({ summary: " " }));
68 expect(a).toContain("_(no summary)_");
69 expect(b).toContain("_(no summary)_");
70 });
71
72 it("ends with the suggestions-only disclaimer", () => {
73 const out = __test.renderTriageComment(triageFixture());
74 expect(out.trimEnd().endsWith(
75 "_Suggestions only — nothing has been applied. The PR author stays in control._"
76 )).toBe(true);
77 });
78
79 it("formats critical priority literally (no pictographic emoji)", () => {
80 // Reminder: tests guard the "no emoji" rule. We use the tighter
81 // Extended_Pictographic class so legitimate punctuation (em-dash,
82 // asterisks) doesn't match.
83 const out = __test.renderTriageComment(
84 triageFixture({ priority: "critical" })
85 );
86 expect(out).toContain("**Priority:** critical");
87 expect(/\p{Extended_Pictographic}/u.test(out)).toBe(false);
88 });
89});
90
91describe("__test fail-open contracts", () => {
92 it("alreadyTriaged returns false when no DB / unknown PR", async () => {
93 const out = await __test.alreadyTriaged(
94 "00000000-0000-0000-0000-000000000000"
95 );
96 expect(out).toBe(false);
97 });
98
99 it("loadAvailableLabels returns [] for an unknown repo", async () => {
100 const out = await __test.loadAvailableLabels(
101 "00000000-0000-0000-0000-000000000000"
102 );
103 expect(Array.isArray(out)).toBe(true);
104 });
105
106 it("loadCandidateReviewers returns [] for an unknown repo", async () => {
107 const out = await __test.loadCandidateReviewers(
108 "00000000-0000-0000-0000-000000000000",
109 "00000000-0000-0000-0000-000000000000"
110 );
111 expect(Array.isArray(out)).toBe(true);
112 });
113
114 it("buildDiffSummary returns '' for an unknown repo", async () => {
115 const out = await __test.buildDiffSummary(
116 "definitely-not-a-real-owner",
117 "definitely-not-a-real-repo",
118 "main",
119 "feature"
120 );
121 expect(out).toBe("");
122 });
123});
124
125describe("triggerPrTriage — fire-and-forget never throws", () => {
126 const before = process.env.ANTHROPIC_API_KEY;
127
128 it("returns cleanly when API key is absent", async () => {
129 delete process.env.ANTHROPIC_API_KEY;
130 try {
131 let threw = false;
132 try {
133 await triggerPrTriage({
134 ownerName: "alice",
135 repoName: "demo",
136 repositoryId: "00000000-0000-0000-0000-000000000000",
137 prId: "00000000-0000-0000-0000-000000000000",
138 prAuthorId: "00000000-0000-0000-0000-000000000000",
139 title: "Test",
140 body: "",
141 baseBranch: "main",
142 headBranch: "feature",
143 });
144 } catch {
145 threw = true;
146 }
147 expect(threw).toBe(false);
148 } finally {
149 if (before) process.env.ANTHROPIC_API_KEY = before;
150 }
151 });
152
153 it("never throws on empty/garbage inputs", async () => {
154 let threw = false;
155 try {
156 await triggerPrTriage({
157 ownerName: "",
158 repoName: "",
159 repositoryId: "not-a-uuid",
160 prId: "not-a-uuid",
161 prAuthorId: "not-a-uuid",
162 title: "",
163 body: "",
164 baseBranch: "",
165 headBranch: "",
166 });
167 } catch {
168 threw = true;
169 }
170 expect(threw).toBe(false);
171 });
172});
Addedsrc/__tests__/pulls-ai-description.test.ts+48−0View fileUnifiedSplit
1/**
2 * Smoke tests for the new AI-PR-description endpoint:
3 * POST /:owner/:repo/ai/pr-description
4 *
5 * The route requires write access, so unauthenticated callers should
6 * never see a 200/JSON body. Authenticated paths require a live DB +
7 * git checkout, so we focus on the auth-guard contract.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12
13describe("POST /:owner/:repo/ai/pr-description — auth guard", () => {
14 it("redirects to /login when unauthenticated (no bearer)", async () => {
15 const res = await app.request(
16 "/alice/demo/ai/pr-description",
17 {
18 method: "POST",
19 headers: { "content-type": "application/x-www-form-urlencoded" },
20 body: "title=Test&base=main&head=feature",
21 redirect: "manual",
22 }
23 );
24 // Either a 302 to /login (cookie flow), or a 4xx/5xx if requireAuth
25 // / requireRepoAccess fail-closed earlier. The one thing we MUST NOT
26 // see is a 200 with a leaked body.
27 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
28 if (res.status === 302 || res.status === 303 || res.status === 307) {
29 const loc = res.headers.get("location") || "";
30 expect(loc).toContain("/login");
31 }
32 });
33
34 it("rejects bogus bearer tokens with 401", async () => {
35 const res = await app.request(
36 "/alice/demo/ai/pr-description",
37 {
38 method: "POST",
39 headers: {
40 "content-type": "application/x-www-form-urlencoded",
41 authorization: "Bearer glct_definitely-not-valid",
42 },
43 body: "title=Test&base=main&head=feature",
44 }
45 );
46 expect([401, 403, 404, 503]).toContain(res.status);
47 });
48});
Addedsrc/__tests__/pulls-ai-rereview.test.ts+87−0View fileUnifiedSplit
1/**
2 * Smoke tests for the new on-demand AI re-review endpoint:
3 * POST /:owner/:repo/pulls/:number/ai-rereview
4 *
5 * Write-access only. Verifies auth-guard contracts.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10
11describe("POST /:owner/:repo/pulls/:number/ai-rereview — auth guard", () => {
12 it("redirects to /login when unauthenticated", async () => {
13 const res = await app.request(
14 "/alice/demo/pulls/1/ai-rereview",
15 {
16 method: "POST",
17 headers: { "content-type": "application/x-www-form-urlencoded" },
18 body: "",
19 redirect: "manual",
20 }
21 );
22 expect([301, 302, 303, 307, 401, 403, 404, 503]).toContain(res.status);
23 if (res.status === 302 || res.status === 303 || res.status === 307) {
24 const loc = res.headers.get("location") || "";
25 expect(loc).toContain("/login");
26 }
27 });
28
29 it("rejects bogus bearer tokens", async () => {
30 const res = await app.request(
31 "/alice/demo/pulls/1/ai-rereview",
32 {
33 method: "POST",
34 headers: {
35 "content-type": "application/x-www-form-urlencoded",
36 authorization: "Bearer glct_definitely-not-valid",
37 },
38 body: "",
39 }
40 );
41 expect([401, 403, 404, 503]).toContain(res.status);
42 });
43});
44
45describe("triggerAiReview — force option (idempotency bypass)", () => {
46 it("accepts and propagates the force flag without throwing", async () => {
47 const { triggerAiReview } = await import("../lib/ai-review");
48 let threw = false;
49 try {
50 // No API key in test env → should bail at isAiReviewEnabled check.
51 // The force flag is just a parameter pass-through; we verify it
52 // doesn't change the never-throw contract.
53 await triggerAiReview(
54 "alice",
55 "demo",
56 "00000000-0000-0000-0000-000000000000",
57 "Title",
58 "Body",
59 "main",
60 "feature",
61 { force: true }
62 );
63 } catch {
64 threw = true;
65 }
66 expect(threw).toBe(false);
67 });
68
69 it("default options (no force) still works", async () => {
70 const { triggerAiReview } = await import("../lib/ai-review");
71 let threw = false;
72 try {
73 await triggerAiReview(
74 "alice",
75 "demo",
76 "00000000-0000-0000-0000-000000000000",
77 "Title",
78 "Body",
79 "main",
80 "feature"
81 );
82 } catch {
83 threw = true;
84 }
85 expect(threw).toBe(false);
86 });
87});
Addedsrc/__tests__/push-policy.test.ts+123−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/push-policy.ts.
3 *
4 * The DB-touching paths (matchProtectedTag, listRulesetsForRepo,
5 * canBypassProtectedTag) require a live DB; we don't have one in unit
6 * tests. Instead we cover:
7 *
8 * - The pure formatPolicyError formatter — exhaustive.
9 * - The fail-open guarantee on bad input (empty refs, missing repo id).
10 * - Module shape (imports don't throw, expected exports are present).
11 *
12 * Real end-to-end coverage of the policy decisions runs in the existing
13 * rulesets + protected-tags test suites (which exercise evaluatePush and
14 * matchGlob directly). This file verifies that the new wrapper preserves
15 * fail-open semantics, which is the property that protects production
16 * pushes from a Postgres hiccup.
17 */
18
19import { describe, it, expect } from "bun:test";
20import {
21 evaluatePushPolicy,
22 formatPolicyError,
23 ZERO_SHA,
24} from "../lib/push-policy";
25
26describe("ZERO_SHA constant", () => {
27 it("is exactly 40 zeros", () => {
28 expect(ZERO_SHA).toBe("0000000000000000000000000000000000000000");
29 expect(ZERO_SHA.length).toBe(40);
30 });
31});
32
33describe("formatPolicyError", () => {
34 it("returns a generic message when violations is empty", () => {
35 expect(formatPolicyError([])).toBe("Push rejected by Gluecron policy.");
36 });
37
38 it("returns a generic message when violations is null/undefined", () => {
39 expect(formatPolicyError(null as any)).toBe(
40 "Push rejected by Gluecron policy."
41 );
42 expect(formatPolicyError(undefined as any)).toBe(
43 "Push rejected by Gluecron policy."
44 );
45 });
46
47 it("renders one violation as a bulleted list", () => {
48 const out = formatPolicyError(['tag "v1.0" is protected']);
49 expect(out).toContain("Push rejected by Gluecron policy:");
50 expect(out).toContain(' - tag "v1.0" is protected');
51 });
52
53 it("renders multiple violations on separate lines", () => {
54 const out = formatPolicyError([
55 'tag "v1.0" is protected',
56 'ruleset "no-prod-pushes" rule branch_name_pattern: blocked',
57 ]);
58 const lines = out.trimEnd().split("\n");
59 expect(lines[0]).toBe("Push rejected by Gluecron policy:");
60 expect(lines[1]).toBe(' - tag "v1.0" is protected');
61 expect(lines[2]).toBe(
62 ' - ruleset "no-prod-pushes" rule branch_name_pattern: blocked'
63 );
64 });
65
66 it("ends with a newline (so git surfaces the body cleanly)", () => {
67 expect(formatPolicyError(["one violation"]).endsWith("\n")).toBe(true);
68 });
69});
70
71describe("evaluatePushPolicy — fail-open on bad input", () => {
72 it("returns allowed=true for an empty refs list", async () => {
73 const r = await evaluatePushPolicy({
74 repositoryId: "repo-1",
75 refs: [],
76 pusherUserId: null,
77 });
78 expect(r.allowed).toBe(true);
79 expect(r.violations).toEqual([]);
80 });
81
82 it("returns allowed=true for missing repositoryId", async () => {
83 const r = await evaluatePushPolicy({
84 repositoryId: "" as any,
85 refs: [
86 {
87 oldSha: ZERO_SHA,
88 newSha: "a".repeat(40),
89 refName: "refs/tags/v1.0",
90 },
91 ],
92 pusherUserId: null,
93 });
94 expect(r.allowed).toBe(true);
95 });
96
97 it("returns allowed=true when DB is unreachable (refs target a nonexistent repo)", async () => {
98 // No real repo exists with id "definitely-not-a-real-repo-id"; the
99 // matchProtectedTag + listRulesetsForRepo callers catch their own
100 // errors and return empty arrays, so the wrapper returns allowed.
101 const r = await evaluatePushPolicy({
102 repositoryId: "definitely-not-a-real-repo-id",
103 refs: [
104 {
105 oldSha: ZERO_SHA,
106 newSha: "a".repeat(40),
107 refName: "refs/heads/main",
108 },
109 ],
110 pusherUserId: null,
111 });
112 expect(r.allowed).toBe(true);
113 });
114});
115
116describe("evaluatePushPolicy — module shape", () => {
117 it("exports the functions the route depends on", async () => {
118 const mod = await import("../lib/push-policy");
119 expect(typeof mod.evaluatePushPolicy).toBe("function");
120 expect(typeof mod.formatPolicyError).toBe("function");
121 expect(typeof mod.ZERO_SHA).toBe("string");
122 });
123});
Addedsrc/__tests__/scheduled-workflows.test.ts+126−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/scheduled-workflows.ts (pure helpers + the
3 * parser-side schedule extractor in workflow-parser.ts).
4 *
5 * The DB-touching pipeline (runScheduledWorkflowsTick) is exercised
6 * indirectly by the autopilot smoke test — here we focus on the
7 * pure decisions that drive it.
8 */
9
10import { describe, it, expect } from "bun:test";
11import {
12 schedulesFromParsedJson,
13 firstCronToFire,
14 runScheduledWorkflowsTick,
15 MAX_RUNS_PER_TICK,
16} from "../lib/scheduled-workflows";
17import { __test as parserTest } from "../lib/workflow-parser";
18
19describe("schedulesFromParsedJson", () => {
20 it("returns [] for empty / malformed input", () => {
21 expect(schedulesFromParsedJson("")).toEqual([]);
22 expect(schedulesFromParsedJson("{}")).toEqual([]);
23 expect(schedulesFromParsedJson("not json")).toEqual([]);
24 });
25
26 it("returns [] when schedules is missing or wrong type", () => {
27 expect(schedulesFromParsedJson('{"on":["push"]}')).toEqual([]);
28 expect(schedulesFromParsedJson('{"schedules":"0 * * * *"}')).toEqual([]);
29 expect(schedulesFromParsedJson('{"schedules":42}')).toEqual([]);
30 });
31
32 it("extracts a non-empty schedules array", () => {
33 const json = '{"schedules":["0 * * * *","30 9 * * 1"]}';
34 expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "30 9 * * 1"]);
35 });
36
37 it("filters out non-string and empty entries", () => {
38 const json = '{"schedules":["0 * * * *","",42,null,"15 14 * * *"]}';
39 expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "15 14 * * *"]);
40 });
41});
42
43describe("workflow-parser extractSchedules", () => {
44 const ex = parserTest.extractSchedules;
45
46 it("returns [] for non-mapping triggers", () => {
47 expect(ex("push")).toEqual([]);
48 expect(ex(["push"])).toEqual([]);
49 expect(ex(null)).toEqual([]);
50 expect(ex(undefined)).toEqual([]);
51 });
52
53 it("returns [] when the mapping has no schedule key", () => {
54 expect(ex({ push: { branches: ["main"] } })).toEqual([]);
55 });
56
57 it("extracts a single schedule entry (object form)", () => {
58 expect(ex({ schedule: { cron: "0 * * * *" } })).toEqual(["0 * * * *"]);
59 });
60
61 it("extracts an array of schedule entries", () => {
62 expect(
63 ex({ schedule: [{ cron: "0 * * * *" }, { cron: "30 12 * * 5" }] })
64 ).toEqual(["0 * * * *", "30 12 * * 5"]);
65 });
66
67 it("tolerates a bare string in schedule", () => {
68 expect(ex({ schedule: "0 * * * *" })).toEqual(["0 * * * *"]);
69 expect(ex({ schedule: ["0 * * * *", "30 9 * * 1"] })).toEqual([
70 "0 * * * *",
71 "30 9 * * 1",
72 ]);
73 });
74
75 it("ignores entries that are missing or non-string cron", () => {
76 expect(ex({ schedule: [{ cron: "" }, { cron: 42 }, {}] })).toEqual([]);
77 });
78});
79
80describe("firstCronToFire", () => {
81 const since = new Date("2026-04-30T12:00:00Z");
82 const until = new Date("2026-04-30T13:05:00Z");
83
84 it("returns null when schedules is empty", () => {
85 expect(firstCronToFire([], since, until)).toBeNull();
86 });
87
88 it("returns the first cron that fires in the window", () => {
89 // 0 13 * * * fires at 13:00 — in (12:00, 13:05]
90 expect(firstCronToFire(["0 13 * * *"], since, until)).toBe("0 13 * * *");
91 });
92
93 it("returns null when no cron in the list fires", () => {
94 // 0 23 fires at 23:00; not in our 12:00–13:05 window
95 expect(firstCronToFire(["0 23 * * *"], since, until)).toBeNull();
96 });
97
98 it("skips invalid cron strings without crashing", () => {
99 expect(
100 firstCronToFire(["@hourly", "0 13 * * *"], since, until)
101 ).toBe("0 13 * * *");
102 });
103
104 it("returns the first matching cron, not all of them", () => {
105 expect(
106 firstCronToFire(["0 13 * * *", "30 13 * * *"], since, until)
107 ).toBe("0 13 * * *");
108 });
109});
110
111describe("runScheduledWorkflowsTick — fail-open", () => {
112 it("returns a result object with the expected keys, never throws", async () => {
113 const r = await runScheduledWorkflowsTick(new Date("2026-04-30T13:00:00Z"));
114 expect(typeof r.considered).toBe("number");
115 expect(typeof r.fired).toBe("number");
116 expect(typeof r.errors).toBe("number");
117 expect(r.considered).toBeGreaterThanOrEqual(0);
118 expect(r.fired).toBeGreaterThanOrEqual(0);
119 expect(r.errors).toBeGreaterThanOrEqual(0);
120 });
121
122 it("MAX_RUNS_PER_TICK is a safe positive integer", () => {
123 expect(MAX_RUNS_PER_TICK).toBeGreaterThan(0);
124 expect(MAX_RUNS_PER_TICK).toBeLessThanOrEqual(1000);
125 });
126});
Modifiedsrc/__tests__/sse-client.test.ts+42−1View fileUnifiedSplit
11import { describe, it, expect } from "bun:test";
2import { liveSubscribeScript } from "../lib/sse-client";
2import {
3 liveSubscribeScript,
4 liveCommentBannerScript,
5} from "../lib/sse-client";
36
47describe("liveSubscribeScript", () => {
58 it("returns a string containing EventSource and the topic path", () => {
3740 expect(js).toContain("EventSource");
3841 });
3942});
43
44describe("liveCommentBannerScript", () => {
45 it("emits a self-invoking IIFE that opens an EventSource", () => {
46 const js = liveCommentBannerScript({
47 topic: "repo:r1:issue:7",
48 bannerElementId: "live-comment-banner",
49 });
50 expect(typeof js).toBe("string");
51 expect(js).toContain("EventSource");
52 expect(js).toContain("/live-events/");
53 expect(js).toContain('"repo:r1:issue:7"');
54 expect(js).toContain('"live-comment-banner"');
55 // Counter increment + show contract.
56 expect(js).toContain("n++");
57 expect(js).toContain("js-live-count");
58 expect(js).toContain("js-live-link");
59 });
60
61 it("escapes topic strings to prevent </script> injection", () => {
62 const malicious = '</script><script>alert(1)</script>';
63 const js = liveCommentBannerScript({
64 topic: malicious,
65 bannerElementId: "banner",
66 });
67 expect(js).not.toContain("</script>");
68 expect(js).not.toContain("<script>alert(1)");
69 expect(js).toContain("EventSource");
70 });
71
72 it("uses the current location for the reload link", () => {
73 const js = liveCommentBannerScript({
74 topic: "repo:r1:pr:9",
75 bannerElementId: "banner",
76 });
77 // Must reference window.location so a click reloads the same page.
78 expect(js).toContain("window.location");
79 });
80});
Addedsrc/__tests__/workflow-secrets-substitute.test.ts+113−0View fileUnifiedSplit
1/**
2 * Pure-helper tests for src/lib/workflow-secrets.ts substituteSecrets.
3 *
4 * loadSecretsContext / upsert / delete are DB-coupled so they're not
5 * exercised here — the crypto round-trip lives in
6 * workflow-secrets-crypto.test.ts. This file pins the pure substitution
7 * grammar so the runner's secret-injection contract can be relied on
8 * without instantiating Postgres.
9 */
10
11import { describe, it, expect } from "bun:test";
12import { substituteSecrets } from "../lib/workflow-secrets";
13
14describe("substituteSecrets — happy path", () => {
15 it("replaces a single token with the matching plaintext", () => {
16 const out = substituteSecrets(
17 'echo "$TOKEN_${{ secrets.TOKEN }}"',
18 { TOKEN: "abc123" }
19 );
20 expect(out).toBe('echo "$TOKEN_abc123"');
21 });
22
23 it("replaces multiple tokens in one template", () => {
24 const out = substituteSecrets(
25 "DEPLOY_KEY=${{ secrets.DEPLOY_KEY }} REGION=${{ secrets.REGION }}",
26 { DEPLOY_KEY: "k1", REGION: "us-east-1" }
27 );
28 expect(out).toBe("DEPLOY_KEY=k1 REGION=us-east-1");
29 });
30
31 it("tolerates whitespace variants inside the braces", () => {
32 const map = { X: "v" };
33 expect(substituteSecrets("${{secrets.X}}", map)).toBe("v");
34 expect(substituteSecrets("${{ secrets.X }}", map)).toBe("v");
35 expect(substituteSecrets("${{ secrets . X }}", map)).toBe("v");
36 });
37
38 it("repeats substitution when a name appears multiple times", () => {
39 const out = substituteSecrets(
40 "${{ secrets.X }} and again ${{ secrets.X }}",
41 { X: "yes" }
42 );
43 expect(out).toBe("yes and again yes");
44 });
45});
46
47describe("substituteSecrets — leaves tokens intact when secret is missing", () => {
48 it("missing name → token unchanged (loud failure signal)", () => {
49 const tpl = "echo ${{ secrets.MISSING }}";
50 expect(substituteSecrets(tpl, {})).toBe(tpl);
51 expect(substituteSecrets(tpl, { OTHER: "x" })).toBe(tpl);
52 });
53
54 it("substitutes the matching tokens and leaves the missing ones", () => {
55 const out = substituteSecrets(
56 "${{ secrets.A }} / ${{ secrets.B }} / ${{ secrets.C }}",
57 { A: "a", C: "c" }
58 );
59 expect(out).toBe("a / ${{ secrets.B }} / c");
60 });
61});
62
63describe("substituteSecrets — strict name grammar", () => {
64 it("rejects lowercase names (matches GitHub Actions grammar)", () => {
65 const tpl = "echo ${{ secrets.lower }}";
66 expect(substituteSecrets(tpl, { lower: "x" })).toBe(tpl);
67 });
68
69 it("rejects names starting with a digit", () => {
70 const tpl = "echo ${{ secrets.1ABC }}";
71 expect(substituteSecrets(tpl, { "1ABC": "x" })).toBe(tpl);
72 });
73
74 it("accepts underscore-only names + names with digits", () => {
75 expect(
76 substituteSecrets("${{ secrets.A_B_C }}", { A_B_C: "v" })
77 ).toBe("v");
78 expect(
79 substituteSecrets("${{ secrets._UNDER }}", { _UNDER: "v" })
80 ).toBe("v");
81 expect(
82 substituteSecrets("${{ secrets.X1Y2 }}", { X1Y2: "v" })
83 ).toBe("v");
84 });
85});
86
87describe("substituteSecrets — defensive on bad input", () => {
88 it("returns '' for non-string template", () => {
89 expect(substituteSecrets(undefined as any, {})).toBe("");
90 expect(substituteSecrets(null as any, {})).toBe("");
91 expect(substituteSecrets(42 as any, {})).toBe("");
92 });
93
94 it("returns the template untouched when secrets is null/undefined", () => {
95 expect(substituteSecrets("hello", null as any)).toBe("hello");
96 expect(substituteSecrets("hello", undefined as any)).toBe("hello");
97 });
98
99 it("returns '' when both inputs are empty", () => {
100 expect(substituteSecrets("", {})).toBe("");
101 });
102
103 it("ignores prototype-pollution probes (uses hasOwnProperty)", () => {
104 const tpl = "${{ secrets.TO_STRING }}";
105 // {}.toString exists on the prototype; substitution must NOT pick it up.
106 expect(substituteSecrets(tpl, {} as any)).toBe(tpl);
107 });
108
109 it("does not alter unrelated `${{ ... }}` syntax (env, vars)", () => {
110 const tpl = "${{ env.FOO }} ${{ vars.BAR }}";
111 expect(substituteSecrets(tpl, { FOO: "v" })).toBe(tpl);
112 });
113});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
7373import gatesRoutes from "./routes/gates";
7474import gistsRoutes from "./routes/gists";
7575import graphqlRoutes from "./routes/graphql";
76import mcpRoutes from "./routes/mcp";
7677import marketplaceRoutes from "./routes/marketplace";
7778import mergeQueueRoutes from "./routes/merge-queue";
7879import mirrorsRoutes from "./routes/mirrors";
286287app.route("/", gatesRoutes);
287288app.route("/", gistsRoutes);
288289app.route("/", graphqlRoutes);
290app.route("/", mcpRoutes);
289291app.route("/", marketplaceRoutes);
290292app.route("/", mergeQueueRoutes);
291293app.route("/", mirrorsRoutes);
Modifiedsrc/db/schema.ts+7−1View fileUnifiedSplit
397397 environment: text("environment").notNull().default("production"),
398398 commitSha: text("commit_sha").notNull(),
399399 ref: text("ref").notNull(),
400 status: text("status").notNull(), // pending, running, success, failed, blocked
400 status: text("status").notNull(), // pending, running, success, failed, blocked, waiting_timer
401401 blockedReason: text("blocked_reason"),
402402 target: text("target"), // e.g. "crontech", "fly.io"
403403 triggeredBy: uuid("triggered_by").references(() => users.id),
404 /**
405 * Set when an approved deploy is held by `environments.wait_timer_minutes`.
406 * NULL = no wait; non-null = autopilot flips status from "waiting_timer"
407 * to "pending" once `now() >= ready_after`.
408 */
409 readyAfter: timestamp("ready_after"),
404410 createdAt: timestamp("created_at").defaultNow().notNull(),
405411 completedAt: timestamp("completed_at"),
406412 },
Modifiedsrc/lib/ai-generators.ts+1−1View fileUnifiedSplit
102102 return extractText(message).trim();
103103}
104104
105interface IssueTriage {
105export interface IssueTriage {
106106 suggestedLabels: string[];
107107 duplicateOfIssueNumber: number | null;
108108 priority: "critical" | "high" | "medium" | "low";
Modifiedsrc/lib/ai-review.ts+199−10View fileUnifiedSplit
66 */
77
88import Anthropic from "@anthropic-ai/sdk";
9import { eq, and, like } from "drizzle-orm";
10import { db } from "../db";
11import { pullRequests, prComments } from "../db/schema";
12import { getRepoPath } from "../git/repository";
913import { config } from "./config";
1014
1115interface ReviewComment {
2024 approved: boolean;
2125}
2226
27/**
28 * Marker we drop into the AI summary comment body. Used to detect a
29 * prior review and short-circuit duplicate runs (e.g. when a PR is
30 * marked draft → ready → draft → ready).
31 */
32export const AI_REVIEW_MARKER = "<!-- gluecron-ai-review:summary -->";
33
34/** Max bytes of diff we send to Claude. Matches reviewDiff's internal cap. */
35const DIFF_BYTE_CAP = 100_000;
36
2337let _client: Anthropic | null = null;
2438
2539function getClient(): Anthropic {
125139}
126140
127141/**
128 * Fire-and-forget AI review trigger. Callers .catch() failures.
129 * Currently a stub that defers to reviewDiff once the diff is available.
142 * Compute the merge-base diff between two branches in a bare repo.
143 * Returns "" on any error so callers can no-op cleanly. Uses the
144 * three-dot `base...head` form so the diff is what changed on `head`
145 * relative to the common ancestor with `base` (which is the PR
146 * conventional view, not a literal range diff).
147 */
148async function diffBetweenBranches(
149 ownerName: string,
150 repoName: string,
151 baseBranch: string,
152 headBranch: string
153): Promise<string> {
154 try {
155 const cwd = getRepoPath(ownerName, repoName);
156 const proc = Bun.spawn(
157 [
158 "git",
159 "diff",
160 `${baseBranch}...${headBranch}`,
161 "--",
162 ],
163 { cwd, stdout: "pipe", stderr: "pipe" }
164 );
165 const text = await new Response(proc.stdout).text();
166 await proc.exited;
167 return text;
168 } catch {
169 return "";
170 }
171}
172
173/**
174 * Has this PR already been reviewed by the AI? Detected by an existing
175 * PR comment carrying our summary marker. Cheap LIKE query — if it
176 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
177 * idempotent at worst (a duplicate summary), never destructive.
178 */
179async function alreadyReviewed(prId: string): Promise<boolean> {
180 try {
181 const [row] = await db
182 .select({ id: prComments.id })
183 .from(prComments)
184 .where(
185 and(
186 eq(prComments.pullRequestId, prId),
187 eq(prComments.isAiReview, true),
188 like(prComments.body, `%${AI_REVIEW_MARKER}%`)
189 )
190 )
191 .limit(1);
192 return !!row;
193 } catch {
194 return false;
195 }
196}
197
198/**
199 * Real AI review trigger. Replaces the previous stub. Pipeline:
200 *
201 * 1. Idempotency check — bail if a prior review summary exists.
202 * 2. Compute the base...head diff via the bare repo.
203 * 3. Call reviewDiff for a structured response (summary + per-file
204 * comments + approved boolean).
205 * 4. Persist:
206 * - one summary comment (isAiReview=true, marker embedded), and
207 * - one comment per inline finding (isAiReview=true, filePath +
208 * lineNumber populated).
209 *
210 * Always fire-and-forget at the call site (`.catch(...)`); this
211 * function still never throws so the catch is belt-and-braces. AI
212 * comments are authored by the PR author so the existing comment
213 * rendering can group them naturally — there is no synthetic bot user
214 * yet (tracked alongside H2 app-bot identity work).
130215 */
131216export async function triggerAiReview(
132217 ownerName: string,
133218 repoName: string,
134 _prId: string,
135 _title: string,
136 _body: string,
137 _baseBranch: string,
138 _headBranch: string,
219 prId: string,
220 title: string,
221 body: string,
222 baseBranch: string,
223 headBranch: string,
224 options: { force?: boolean } = {}
139225): Promise<void> {
140 if (!isAiReviewEnabled()) return;
141 if (process.env.DEBUG_AI_REVIEW === "1") {
142 console.log("[ai-review] queued", ownerName, repoName, _prId);
226 try {
227 if (!isAiReviewEnabled()) return;
228 if (!options.force && (await alreadyReviewed(prId))) return;
229
230 const [pr] = await db
231 .select({ id: pullRequests.id, authorId: pullRequests.authorId })
232 .from(pullRequests)
233 .where(eq(pullRequests.id, prId))
234 .limit(1);
235 if (!pr) return;
236
237 let diffText = await diffBetweenBranches(
238 ownerName,
239 repoName,
240 baseBranch,
241 headBranch
242 );
243 if (!diffText.trim()) return;
244 if (diffText.length > DIFF_BYTE_CAP) {
245 diffText = diffText.slice(0, DIFF_BYTE_CAP);
246 }
247
248 let result: ReviewResult;
249 try {
250 result = await reviewDiff(
251 `${ownerName}/${repoName}`,
252 title,
253 body || null,
254 baseBranch,
255 headBranch,
256 diffText
257 );
258 } catch (err) {
259 // Anthropic API failure — degrade to a single advisory comment so
260 // PR authors see the attempt rather than silence.
261 const reason = err instanceof Error ? err.message : "unknown error";
262 await db
263 .insert(prComments)
264 .values({
265 pullRequestId: prId,
266 authorId: pr.authorId,
267 isAiReview: true,
268 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
269 })
270 .catch(() => {});
271 return;
272 }
273
274 const verdict = result.approved
275 ? "**AI review:** no blocking issues found."
276 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
277 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
278 await db
279 .insert(prComments)
280 .values({
281 pullRequestId: prId,
282 authorId: pr.authorId,
283 isAiReview: true,
284 body: summaryBody,
285 })
286 .catch(() => {});
287
288 for (const c of result.comments) {
289 if (!c || !c.body) continue;
290 const filePath =
291 typeof c.filePath === "string" && c.filePath ? c.filePath : null;
292 const lineNumber =
293 Number.isInteger(c.lineNumber) && (c.lineNumber as number) > 0
294 ? (c.lineNumber as number)
295 : null;
296 await db
297 .insert(prComments)
298 .values({
299 pullRequestId: prId,
300 authorId: pr.authorId,
301 isAiReview: true,
302 body: c.body,
303 filePath,
304 lineNumber,
305 })
306 .catch(() => {});
307 }
308
309 if (process.env.DEBUG_AI_REVIEW === "1") {
310 console.log(
311 "[ai-review] reviewed",
312 ownerName,
313 repoName,
314 prId,
315 `comments=${result.comments.length}`,
316 `approved=${result.approved}`
317 );
318 }
319 } catch (err) {
320 // Belt-and-braces: never escape into the request path.
321 if (process.env.DEBUG_AI_REVIEW === "1") {
322 console.error("[ai-review] crashed:", err);
323 }
143324 }
144325}
326
327/**
328 * Test-only export: the internal helpers. Not part of the public API.
329 */
330export const __test = {
331 diffBetweenBranches,
332 alreadyReviewed,
333};
Modifiedsrc/lib/autopilot.ts+14−0View fileUnifiedSplit
1616import { peekHead } from "./merge-queue";
1717import { sendDigestsToAll } from "./email-digest";
1818import { scanRepositoryForAlerts } from "./advisories";
19import { releaseExpiredWaitTimers } from "./environments";
20import { runScheduledWorkflowsTick } from "./scheduled-workflows";
1921
2022export interface AutopilotTaskResult {
2123 name: string;
7981 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
8082 },
8183 },
84 {
85 name: "wait-timer-release",
86 run: async () => {
87 await releaseExpiredWaitTimers();
88 },
89 },
90 {
91 name: "scheduled-workflows",
92 run: async () => {
93 await runScheduledWorkflowsTick();
94 },
95 },
8296 ];
8397}
8498
Addedsrc/lib/cron.ts+227−0View fileUnifiedSplit
1/**
2 * Tiny cron-expression parser + matcher.
3 *
4 * Standard 5-field UNIX cron:
5 *
6 * minute hour day-of-month month day-of-week
7 * 0-59 0-23 1-31 1-12 0-6 (Sun=0; Sat=6; 7 also accepted as Sun)
8 *
9 * Supports: literal numbers, `*`, ranges (`a-b`), step (`*\/n` or `a-b/n`),
10 * and comma-lists (`1,3,5`). No named months / weekdays, no `@hourly`,
11 * no `L`/`W`/`#` — those raise a parse error so callers can surface a
12 * useful message rather than silently never firing.
13 *
14 * Day-of-month and day-of-week interact via OR (POSIX semantics): if both
15 * are restricted, the schedule fires when EITHER matches. If one is `*`
16 * we conjoin with the other (the practical common case).
17 *
18 * Pure module — no DB, no clock side effects. Callers pass a Date.
19 */
20
21export type CronField = number[]; // sorted, deduped
22
23export type ParsedCron = {
24 minute: CronField;
25 hour: CronField;
26 dom: CronField;
27 month: CronField;
28 dow: CronField;
29 /** Original expression after trimming + collapsing whitespace. */
30 raw: string;
31};
32
33export type CronParseResult =
34 | { ok: true; cron: ParsedCron }
35 | { ok: false; error: string };
36
37const FIELD_BOUNDS: Record<string, [number, number]> = {
38 minute: [0, 59],
39 hour: [0, 23],
40 dom: [1, 31],
41 month: [1, 12],
42 dow: [0, 7], // 7 normalised to 0 below
43};
44
45/**
46 * Parse one field of a cron expression against [lo, hi]. Returns a sorted,
47 * de-duplicated list of valid integers, or `null` if the field is
48 * malformed. Supports `*`, `a`, `a-b`, `*\/n`, `a-b/n`, `a,b,c`.
49 */
50function parseField(
51 raw: string,
52 lo: number,
53 hi: number
54): CronField | null {
55 const out = new Set<number>();
56 const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
57 if (parts.length === 0) return null;
58
59 for (const part of parts) {
60 let baseLo = lo;
61 let baseHi = hi;
62 let step = 1;
63 let body = part;
64
65 const slash = body.indexOf("/");
66 if (slash >= 0) {
67 const stepStr = body.slice(slash + 1);
68 const stepN = Number.parseInt(stepStr, 10);
69 if (!Number.isInteger(stepN) || stepN <= 0) return null;
70 step = stepN;
71 const before = body.slice(0, slash);
72 // Empty before-slash (e.g. "/5") is a syntax error — must be either
73 // "*" or a literal range. Don't silently default to "*".
74 if (before === "") return null;
75 body = before;
76 }
77
78 if (body === "*") {
79 // baseLo / baseHi already span the full field.
80 } else if (body.includes("-")) {
81 const [aS, bS] = body.split("-");
82 const a = Number.parseInt(aS, 10);
83 const b = Number.parseInt(bS, 10);
84 if (!Number.isInteger(a) || !Number.isInteger(b)) return null;
85 if (a < lo || b > hi || a > b) return null;
86 baseLo = a;
87 baseHi = b;
88 } else {
89 const n = Number.parseInt(body, 10);
90 if (!Number.isInteger(n) || n < lo || n > hi) return null;
91 if (step !== 1) return null; // n/step makes no sense without a range
92 out.add(n);
93 continue;
94 }
95
96 for (let v = baseLo; v <= baseHi; v += step) {
97 out.add(v);
98 }
99 }
100
101 return [...out].sort((a, b) => a - b);
102}
103
104/**
105 * Parse a 5-field cron expression. Returns `{ok:true, cron}` or
106 * `{ok:false, error}`. Whitespace-tolerant; rejects unsupported syntax.
107 */
108export function parseCron(expr: string): CronParseResult {
109 const raw = (expr || "").replace(/\s+/g, " ").trim();
110 if (!raw) return { ok: false, error: "empty cron expression" };
111
112 // Fail fast on syntax we don't yet support so the user gets a clear
113 // error instead of a schedule that silently never fires.
114 if (/^@/.test(raw)) {
115 return {
116 ok: false,
117 error: `cron alias '${raw.split(" ")[0]}' is not supported (use 5-field expression)`,
118 };
119 }
120 if (/[LW#?]/i.test(raw)) {
121 return {
122 ok: false,
123 error: "cron contains unsupported characters (L, W, #, ?)",
124 };
125 }
126
127 const fields = raw.split(" ");
128 if (fields.length !== 5) {
129 return {
130 ok: false,
131 error: `cron must have 5 space-separated fields, got ${fields.length}`,
132 };
133 }
134
135 const [mField, hField, domField, monField, dowField] = fields;
136
137 const minute = parseField(mField, ...FIELD_BOUNDS.minute);
138 const hour = parseField(hField, ...FIELD_BOUNDS.hour);
139 const dom = parseField(domField, ...FIELD_BOUNDS.dom);
140 const month = parseField(monField, ...FIELD_BOUNDS.month);
141 const dowRaw = parseField(dowField, ...FIELD_BOUNDS.dow);
142
143 if (!minute) return { ok: false, error: `invalid minute field: ${mField}` };
144 if (!hour) return { ok: false, error: `invalid hour field: ${hField}` };
145 if (!dom) return { ok: false, error: `invalid day-of-month field: ${domField}` };
146 if (!month) return { ok: false, error: `invalid month field: ${monField}` };
147 if (!dowRaw) return { ok: false, error: `invalid day-of-week field: ${dowField}` };
148
149 // Normalise dow so 7 → 0 (both mean Sunday).
150 const dow = [...new Set(dowRaw.map((d) => (d === 7 ? 0 : d)))].sort(
151 (a, b) => a - b
152 );
153
154 return {
155 ok: true,
156 cron: {
157 raw,
158 minute,
159 hour,
160 dom,
161 month,
162 dow,
163 },
164 };
165}
166
167/**
168 * Does the cron fire on this exact minute (UTC)? `date` is rounded down
169 * to the start of its minute internally so callers can pass any Date.
170 */
171export function cronMatches(cron: ParsedCron, date: Date): boolean {
172 if (!cron) return false;
173 const m = date.getUTCMinutes();
174 const h = date.getUTCHours();
175 const d = date.getUTCDate();
176 const mo = date.getUTCMonth() + 1; // JS is 0-indexed
177 const dw = date.getUTCDay(); // 0..6, Sun=0
178
179 if (!cron.minute.includes(m)) return false;
180 if (!cron.hour.includes(h)) return false;
181 if (!cron.month.includes(mo)) return false;
182
183 // POSIX OR semantics for dom & dow: if either is restricted (not full
184 // wildcard), match if EITHER matches. If both unrestricted, both pass.
185 const domRestricted = cron.dom.length !== 31;
186 const dowRestricted = cron.dow.length !== 7;
187 const domMatch = cron.dom.includes(d);
188 const dowMatch = cron.dow.includes(dw);
189
190 if (!domRestricted && !dowRestricted) return true;
191 if (domRestricted && dowRestricted) return domMatch || dowMatch;
192 if (domRestricted) return domMatch;
193 return dowMatch;
194}
195
196/**
197 * Did the cron fire at any minute in the half-open interval (since, until]?
198 * Useful for "did this schedule trip in the last tick?" checks where
199 * `since` is the prior tick wall and `until` is the current wall. Caps
200 * the loop at 1 day so a misconfigured `since` from 2010 can't blow up.
201 */
202export function cronFiredBetween(
203 cron: ParsedCron,
204 since: Date,
205 until: Date
206): boolean {
207 if (!cron) return false;
208 const startMs = Math.floor(since.getTime() / 60000) * 60000 + 60000;
209 const endMs = Math.floor(until.getTime() / 60000) * 60000;
210 if (endMs < startMs) return false;
211
212 const ONE_DAY_MIN = 24 * 60;
213 const minuteCount = (endMs - startMs) / 60000 + 1;
214 const cap = Math.min(minuteCount, ONE_DAY_MIN);
215
216 for (let i = 0; i < cap; i++) {
217 const t = new Date(startMs + i * 60000);
218 if (cronMatches(cron, t)) return true;
219 }
220 return false;
221}
222
223/**
224 * Test-only export of the field parser so unit tests can pin parsing
225 * edge cases without going through parseCron's plumbing.
226 */
227export const __test = { parseField };
Modifiedsrc/lib/environments.ts+100−4View fileUnifiedSplit
77 * - if `reviewers` is empty, the repo owner is treated as the implicit reviewer.
88 * - `allowedBranches` is a JSON array of glob patterns. When non-empty only
99 * refs matching at least one pattern may deploy through the environment.
10 * - `waitTimerMinutes` is stored but NOT enforced in v1 (stub).
10 * - `waitTimerMinutes` is now enforced: approval flips status to
11 * "waiting_timer" + populates `deployments.ready_after`; the autopilot
12 * ticker sweeps timers that have elapsed and flips them to "pending".
1113 *
1214 * All DB calls are wrapped in try/catch so the caller gets well-defined
1315 * shapes even when the database is unreachable (keeps the hot push path safe).
1416 */
1517
16import { and, desc, eq } from "drizzle-orm";
18import { and, desc, eq, isNotNull, lte, sql } from "drizzle-orm";
1719import { db } from "../db";
1820import {
1921 environments,
2022 deploymentApprovals,
23 deployments,
2124 repositories,
2225} from "../db/schema";
2326import type { Environment, DeploymentApproval } from "../db/schema";
226229 return { approved, rejected, decided };
227230}
228231
232/**
233 * Pure helper — return the timestamp of the most recent "approved"
234 * decision, or null if there are no approvals. Used as the basis for
235 * wait-timer enforcement.
236 */
237export function latestApprovalAt(
238 decided: DeploymentApproval[]
239): Date | null {
240 let latest: Date | null = null;
241 for (const d of decided) {
242 if (d.decision !== "approved") continue;
243 const t = d.createdAt ? new Date(d.createdAt) : null;
244 if (!t || isNaN(t.getTime())) continue;
245 if (!latest || t.getTime() > latest.getTime()) latest = t;
246 }
247 return latest;
248}
249
250/**
251 * Pure helper — given an environment + the current approvals + a "now"
252 * reference, return the Date when the deploy may proceed, or null if
253 * either the env has no wait timer (waitTimerMinutes <= 0) or there are
254 * no approvals yet. Returning a past timestamp is deliberate — caller
255 * can compare `readyAfter <= now` to decide whether to skip the
256 * "waiting_timer" state entirely.
257 */
258export function computeReadyAfter(
259 env: Pick<Environment, "waitTimerMinutes">,
260 decided: DeploymentApproval[]
261): Date | null {
262 const minutes = Number(env.waitTimerMinutes || 0);
263 if (!Number.isFinite(minutes) || minutes <= 0) return null;
264 const last = latestApprovalAt(decided);
265 if (!last) return null;
266 return new Date(last.getTime() + minutes * 60_000);
267}
268
229269/**
230270 * v1 semantics: approved = at least one approval exists and no rejection.
231271 * rejected = at least one rejection exists.
272 *
273 * waitTimerMinutes enforcement: when the env carries a positive timer
274 * the response includes `readyAfter` — the wall-clock the deployer
275 * should not run before. Callers compare against `now`:
276 * - readyAfter == null → proceed immediately on approval
277 * - readyAfter <= now → proceed (timer already elapsed)
278 * - readyAfter > now → set deployment.status="waiting_timer" and
279 * deployment.readyAfter=readyAfter, then let
280 * the autopilot ticker (`releaseExpiredWaitTimers`)
281 * flip it to "pending" once the wall passes.
232282 */
233283export async function computeApprovalState(
234284 deploymentId: string,
235 _env: Environment
285 env: Environment
236286): Promise<{
237287 approved: boolean;
238288 rejected: boolean;
239289 decided: DeploymentApproval[];
290 readyAfter: Date | null;
240291}> {
241292 const decided = await listApprovals(deploymentId);
242 return reduceApprovalState(decided);
293 const base = reduceApprovalState(decided);
294 const readyAfter = base.approved ? computeReadyAfter(env, decided) : null;
295 return { ...base, readyAfter };
243296}
244297
245298/**
302355 if (env.requireApproval) return { required: true, env };
303356 return { required: false, env };
304357}
358
359// ---------------------------------------------------------------------------
360// Wait-timer sweeper
361// ---------------------------------------------------------------------------
362
363/**
364 * Flip every "waiting_timer" deployment whose `ready_after` has passed to
365 * "pending" so the deployer picks it up. Called from the autopilot ticker.
366 *
367 * Returns the number of deployments released. Best-effort — DB hiccups
368 * resolve to `0` rather than throw, so the autopilot loop never wedges.
369 */
370export async function releaseExpiredWaitTimers(
371 now: Date = new Date()
372): Promise<number> {
373 try {
374 const rows = await db
375 .update(deployments)
376 .set({ status: "pending" })
377 .where(
378 and(
379 eq(deployments.status, "waiting_timer"),
380 isNotNull(deployments.readyAfter),
381 lte(deployments.readyAfter, sql`${now.toISOString()}::timestamptz`)
382 )
383 )
384 .returning({ id: deployments.id });
385 return rows ? rows.length : 0;
386 } catch (err) {
387 console.error("[environments] releaseExpiredWaitTimers failed:", err);
388 return 0;
389 }
390}
391
392/**
393 * Test-only: pure helpers + the sweeper exposed without DB. The sweeper is
394 * already async-DB; we mainly want the pure helpers reachable for tests
395 * that don't have access to a Postgres.
396 */
397export const __test = {
398 latestApprovalAt,
399 computeReadyAfter,
400};
Addedsrc/lib/git-push-auth.ts+197−0View fileUnifiedSplit
1/**
2 * Identify the pusher on a git-receive-pack request.
3 *
4 * Smart-HTTP push doesn't ride the cookie/session middleware (git CLI
5 * doesn't keep cookies). It sends `Authorization: Basic <b64(user:secret)>`
6 * or `Authorization: Bearer <token>`. We accept three secret shapes:
7 *
8 * - `glc_*` — personal access token (Block C2)
9 * - `glct_*` — OAuth access token (Block B6)
10 * - `ghi_*` — installation token for an app-bot (Block H2). The
11 * token resolves to the synthetic users row created by
12 * `createApp(...)` (`<slug>[bot]` username). Legacy bots
13 * created before the synthetic-user back-fill landed
14 * fail soft and resolve as anonymous.
15 *
16 * Best-effort only: returns null (anonymous) on any failure. The caller
17 * must decide what anonymous can do (current policy: anonymous can
18 * push to non-protected refs; protected refs always require auth).
19 */
20
21import { eq } from "drizzle-orm";
22import { db } from "../db";
23import {
24 users,
25 apiTokens,
26 oauthAccessTokens,
27 appInstallTokens,
28 appInstallations,
29 appBots,
30} from "../db/schema";
31import { sha256Hex } from "./oauth";
32
33export type ResolvedPusher = {
34 userId: string;
35 username: string;
36 source: "pat" | "oauth" | "install_token";
37};
38
39/** Decode a `Basic` auth header → `{user, secret}` or null on malformed. */
40export function decodeBasicAuth(
41 header: string | null | undefined
42): { user: string; secret: string } | null {
43 if (!header) return null;
44 const m = /^\s*Basic\s+(.+)$/i.exec(header);
45 if (!m) return null;
46 let decoded: string;
47 try {
48 decoded = Buffer.from(m[1].trim(), "base64").toString("utf8");
49 } catch {
50 return null;
51 }
52 const colon = decoded.indexOf(":");
53 if (colon < 0) return null;
54 return {
55 user: decoded.slice(0, colon),
56 secret: decoded.slice(colon + 1),
57 };
58}
59
60/** Decode a `Bearer` auth header → token string or null. */
61export function decodeBearerAuth(
62 header: string | null | undefined
63): string | null {
64 if (!header) return null;
65 const m = /^\s*Bearer\s+(.+)$/i.exec(header);
66 if (!m) return null;
67 const tok = m[1].trim();
68 return tok || null;
69}
70
71async function resolveByPat(token: string): Promise<ResolvedPusher | null> {
72 if (!token.startsWith("glc_")) return null;
73 try {
74 const hash = await sha256Hex(token);
75 const [row] = await db
76 .select()
77 .from(apiTokens)
78 .where(eq(apiTokens.tokenHash, hash))
79 .limit(1);
80 if (!row) return null;
81 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
82 const [u] = await db
83 .select({ id: users.id, username: users.username })
84 .from(users)
85 .where(eq(users.id, row.userId))
86 .limit(1);
87 if (!u) return null;
88 return { userId: u.id, username: u.username, source: "pat" };
89 } catch {
90 return null;
91 }
92}
93
94async function resolveByOauth(token: string): Promise<ResolvedPusher | null> {
95 if (!token.startsWith("glct_")) return null;
96 try {
97 const hash = await sha256Hex(token);
98 const [row] = await db
99 .select()
100 .from(oauthAccessTokens)
101 .where(eq(oauthAccessTokens.accessTokenHash, hash))
102 .limit(1);
103 if (!row) return null;
104 if (row.revokedAt) return null;
105 if (new Date(row.expiresAt) < new Date()) return null;
106 const [u] = await db
107 .select({ id: users.id, username: users.username })
108 .from(users)
109 .where(eq(users.id, row.userId))
110 .limit(1);
111 if (!u) return null;
112 return { userId: u.id, username: u.username, source: "oauth" };
113 } catch {
114 return null;
115 }
116}
117
118async function resolveByInstallToken(
119 token: string
120): Promise<ResolvedPusher | null> {
121 if (!token.startsWith("ghi_")) return null;
122 try {
123 const hash = await sha256Hex(token);
124 // Look up the install token + its installation + the app's bot
125 // username in one round-trip via the join shape.
126 const [row] = await db
127 .select({
128 tokenId: appInstallTokens.id,
129 revokedAt: appInstallTokens.revokedAt,
130 expiresAt: appInstallTokens.expiresAt,
131 suspendedAt: appInstallations.suspendedAt,
132 uninstalledAt: appInstallations.uninstalledAt,
133 botUsername: appBots.username,
134 })
135 .from(appInstallTokens)
136 .innerJoin(
137 appInstallations,
138 eq(appInstallTokens.installationId, appInstallations.id)
139 )
140 .innerJoin(appBots, eq(appBots.appId, appInstallations.appId))
141 .where(eq(appInstallTokens.tokenHash, hash))
142 .limit(1);
143 if (!row) return null;
144 if (row.revokedAt) return null;
145 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
146 if (row.suspendedAt) return null;
147 if (row.uninstalledAt) return null;
148
149 // Find the synthetic users row that createApp inserts for the bot.
150 // Legacy bots (created before the back-fill landed) won't have one;
151 // those resolve as anonymous, which fails closed on every protected
152 // ref but doesn't break public-repo writes.
153 const [u] = await db
154 .select({ id: users.id, username: users.username })
155 .from(users)
156 .where(eq(users.username, row.botUsername))
157 .limit(1);
158 if (!u) return null;
159 return { userId: u.id, username: u.username, source: "install_token" };
160 } catch {
161 return null;
162 }
163}
164
165/**
166 * Resolve the pusher from an Authorization header. Tries Bearer (PAT,
167 * OAuth, or install token) then Basic (where the secret in the password
168 * field can also be any of the three). git CLI sends
169 * `credential.helper` output as username + password; users typically
170 * paste the token as the password.
171 */
172export async function resolvePusher(
173 authHeader: string | null | undefined
174): Promise<ResolvedPusher | null> {
175 if (!authHeader) return null;
176
177 const bearer = decodeBearerAuth(authHeader);
178 if (bearer) {
179 return (
180 (await resolveByPat(bearer)) ||
181 (await resolveByOauth(bearer)) ||
182 (await resolveByInstallToken(bearer))
183 );
184 }
185
186 const basic = decodeBasicAuth(authHeader);
187 if (basic) {
188 const secret = basic.secret;
189 return (
190 (await resolveByPat(secret)) ||
191 (await resolveByOauth(secret)) ||
192 (await resolveByInstallToken(secret))
193 );
194 }
195
196 return null;
197}
Addedsrc/lib/issue-triage.ts+187−0View fileUnifiedSplit
1/**
2 * Issue triage — fire-and-forget hook on issue create. Mirrors the
3 * PR-triage pattern in src/lib/pr-triage.ts:
4 *
5 * 1. Idempotency check via ISSUE_TRIAGE_MARKER (HTML comment).
6 * 2. Loads available labels + recent issues for context.
7 * 3. Calls triageIssue() from ai-generators.ts.
8 * 4. Posts a "## AI Triage" comment with suggested labels, priority,
9 * one-line summary, and an "Possible duplicate of #N" callout
10 * when the AI flags one with confidence.
11 *
12 * Suggestions only — nothing is auto-applied. The author stays in
13 * control. Wired from the issue-create handler in src/routes/issues.tsx.
14 *
15 * Failure modes (DB hiccup, missing API key, AI parse error) all
16 * funnel through fallbacks / try-catch so this never throws into the
17 * caller.
18 */
19
20import { and, desc, eq, like } from "drizzle-orm";
21import { db } from "../db";
22import {
23 issueComments,
24 issues,
25 labels,
26} from "../db/schema";
27import { triageIssue, type IssueTriage } from "./ai-generators";
28import { isAiAvailable } from "./ai-client";
29
30export const ISSUE_TRIAGE_MARKER = "<!-- gluecron-issue-triage:summary -->";
31
32const RECENT_ISSUES_LIMIT = 30;
33
34export interface IssueTriageInput {
35 ownerName: string;
36 repoName: string;
37 repositoryId: string;
38 issueId: string;
39 issueNumber: number;
40 authorId: string;
41 title: string;
42 body: string;
43}
44
45async function alreadyTriaged(issueId: string): Promise<boolean> {
46 try {
47 const [row] = await db
48 .select({ id: issueComments.id })
49 .from(issueComments)
50 .where(
51 and(
52 eq(issueComments.issueId, issueId),
53 like(issueComments.body, `%${ISSUE_TRIAGE_MARKER}%`)
54 )
55 )
56 .limit(1);
57 return !!row;
58 } catch {
59 return false;
60 }
61}
62
63async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
64 try {
65 const rows = await db
66 .select({ name: labels.name })
67 .from(labels)
68 .where(eq(labels.repositoryId, repositoryId));
69 return rows.map((r) => r.name);
70 } catch {
71 return [];
72 }
73}
74
75async function loadRecentIssues(
76 repositoryId: string,
77 excludeIssueId: string
78): Promise<Array<{ number: number; title: string }>> {
79 try {
80 const rows = await db
81 .select({
82 id: issues.id,
83 number: issues.number,
84 title: issues.title,
85 })
86 .from(issues)
87 .where(eq(issues.repositoryId, repositoryId))
88 .orderBy(desc(issues.createdAt))
89 .limit(RECENT_ISSUES_LIMIT + 1);
90 return rows
91 .filter((r) => r.id !== excludeIssueId)
92 .slice(0, RECENT_ISSUES_LIMIT)
93 .map((r) => ({ number: r.number, title: r.title }));
94 } catch {
95 return [];
96 }
97}
98
99/**
100 * Pure helper: render the "## AI Triage" markdown body. Exported for
101 * unit tests so the format can be pinned without an Anthropic call.
102 */
103export function renderIssueTriageComment(t: IssueTriage): string {
104 const labels = t.suggestedLabels.length
105 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
106 : "_(no label suggestions)_";
107 const summary = t.summary?.trim() || "_(no summary)_";
108
109 const lines: string[] = [
110 ISSUE_TRIAGE_MARKER,
111 "## AI Triage",
112 "",
113 `> ${summary}`,
114 "",
115 `**Priority:** ${t.priority}`,
116 `**Suggested labels:** ${labels}`,
117 ];
118
119 if (
120 typeof t.duplicateOfIssueNumber === "number" &&
121 t.duplicateOfIssueNumber > 0
122 ) {
123 lines.push("");
124 lines.push(
125 `**Possible duplicate of:** #${t.duplicateOfIssueNumber}`
126 );
127 }
128
129 lines.push("");
130 lines.push(
131 "_Suggestions only — nothing has been applied. The author stays in control._"
132 );
133 return lines.join("\n");
134}
135
136export async function triggerIssueTriage(
137 input: IssueTriageInput,
138 options: { force?: boolean } = {}
139): Promise<void> {
140 try {
141 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
142 console.log(
143 "[issue-triage] queued",
144 input.ownerName,
145 input.repoName,
146 input.issueNumber
147 );
148 }
149 if (!isAiAvailable()) return;
150 if (!options.force && (await alreadyTriaged(input.issueId))) return;
151
152 const [availableLabels, recent] = await Promise.all([
153 loadAvailableLabels(input.repositoryId),
154 loadRecentIssues(input.repositoryId, input.issueId),
155 ]);
156
157 const triage = await triageIssue(
158 input.title,
159 input.body,
160 availableLabels,
161 recent
162 );
163
164 const body = renderIssueTriageComment(triage);
165
166 await db
167 .insert(issueComments)
168 .values({
169 issueId: input.issueId,
170 authorId: input.authorId,
171 body,
172 })
173 .catch(() => {});
174 } catch (err) {
175 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
176 console.error("[issue-triage] crashed:", err);
177 }
178 }
179}
180
181/** Test-only export of internals so DB-less tests can reach the helpers. */
182export const __test = {
183 alreadyTriaged,
184 loadAvailableLabels,
185 loadRecentIssues,
186 renderIssueTriageComment,
187};
Modifiedsrc/lib/marketplace.ts+32−1View fileUnifiedSplit
211211 .returning();
212212 if (!row) return null;
213213 // Create matching bot account
214 const botUname = botUsername(slug);
214215 await db.insert(appBots).values({
215216 appId: row.id,
216 username: botUsername(slug),
217 username: botUname,
217218 displayName: `${args.name} (bot)`,
218219 avatarUrl: args.iconUrl,
219220 });
221 // Create a synthetic users row so install-token push auth has a real
222 // users.id to attribute to. Email + passwordHash are deliberately
223 // unusable so bots cannot log in via password — auth is install-token
224 // only via Authorization: Bearer ghi_*. Idempotent: onConflictDoNothing
225 // keeps re-runs safe (e.g. if the apps insert succeeds but the bot
226 // row had to be retried).
227 const botEmail = `${botUname}@gluecron.local`;
228 try {
229 await db
230 .insert(users)
231 .values({
232 username: botUname,
233 email: botEmail,
234 displayName: `${args.name} (bot)`,
235 // Random non-functional hash. Anyone trying to "guess" it
236 // would still fail bcrypt-compare since no password makes it.
237 passwordHash: `!bot:${randomBytes(24).toString("hex")}`,
238 avatarUrl: args.iconUrl,
239 // Bots opt out of all email notifications by default.
240 notifyEmailOnMention: false,
241 notifyEmailOnAssign: false,
242 notifyEmailOnGateFail: false,
243 })
244 .onConflictDoNothing?.();
245 } catch {
246 // Bot user already exists or onConflictDoNothing isn't supported on
247 // this driver — either way, the install-token resolver will fail
248 // soft on lookup and treat the call as anonymous. Do not let this
249 // block app creation.
250 }
220251 return row;
221252 } catch (err: any) {
222253 if (String(err?.message || "").includes("duplicate")) continue;
Addedsrc/lib/mcp-tools.ts+382−0View fileUnifiedSplit
1/**
2 * MCP tool handlers — read-only v1 set.
3 *
4 * Each handler returns either a string (auto-wrapped to text content)
5 * or the full MCP `{content: [...]}` shape. Errors throw `McpError` from
6 * `mcp.ts` so the router can surface them as JSON-RPC -32xxx codes.
7 *
8 * Tool surface (v1, all read-only):
9 * - gluecron_repo_search — search public repos by keyword
10 * - gluecron_repo_read_file — read a file from a repo at a ref
11 * - gluecron_repo_list_issues — list open issues for a repo
12 * - gluecron_repo_explain_codebase — return cached AI explanation
13 *
14 * v2 will add write tools (create_issue, post_comment, run_workflow)
15 * gated on `userId` + write-access on the target repo.
16 */
17
18import { and, asc, desc, eq, like, or } from "drizzle-orm";
19import { db } from "../db";
20import {
21 issues,
22 repositories,
23 users,
24 codebaseExplanations,
25} from "../db/schema";
26import { getBlob, repoExists } from "../git/repository";
27import { computeHealthScore } from "./intelligence";
28import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
29import type { McpContext } from "./mcp";
30
31export type McpTool = {
32 name: string;
33 description: string;
34 inputSchema: {
35 type: "object";
36 properties: Record<string, { type: string; description?: string }>;
37 required?: string[];
38 };
39};
40
41export type McpToolHandler = {
42 tool: McpTool;
43 run: (
44 args: Record<string, unknown>,
45 ctx: McpContext
46 ) => Promise<unknown>;
47};
48
49const argString = (
50 args: Record<string, unknown>,
51 key: string,
52 fallback?: string
53): string => {
54 const v = args[key];
55 if (typeof v === "string" && v.trim().length > 0) return v.trim();
56 if (fallback !== undefined) return fallback;
57 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' is required`);
58};
59
60const argNumber = (
61 args: Record<string, unknown>,
62 key: string,
63 fallback?: number
64): number => {
65 const v = args[key];
66 if (typeof v === "number" && Number.isFinite(v)) return v;
67 if (typeof v === "string" && /^\d+$/.test(v)) return Number.parseInt(v, 10);
68 if (fallback !== undefined) return fallback;
69 throw new McpError(ERR_INVALID_PARAMS, `argument '${key}' must be a number`);
70};
71
72// ---------------------------------------------------------------------------
73// gluecron_repo_search
74// ---------------------------------------------------------------------------
75
76const repoSearch: McpToolHandler = {
77 tool: {
78 name: "gluecron_repo_search",
79 description:
80 "Search public Gluecron repositories by keyword. Matches against name + description. Returns up to 20 results.",
81 inputSchema: {
82 type: "object",
83 properties: {
84 query: { type: "string", description: "Search keyword (1-100 chars)" },
85 limit: { type: "number", description: "Max results, default 20" },
86 },
87 required: ["query"],
88 },
89 },
90 async run(args) {
91 const q = argString(args, "query");
92 if (q.length > 100) {
93 throw new McpError(ERR_INVALID_PARAMS, "query too long (max 100 chars)");
94 }
95 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 20)));
96 const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
97 const rows = await db
98 .select({
99 id: repositories.id,
100 name: repositories.name,
101 description: repositories.description,
102 ownerName: users.username,
103 stars: repositories.starCount,
104 })
105 .from(repositories)
106 .innerJoin(users, eq(repositories.ownerId, users.id))
107 .where(
108 and(
109 eq(repositories.isPrivate, false),
110 or(
111 like(repositories.name, pattern),
112 like(repositories.description, pattern)
113 )
114 )
115 )
116 .orderBy(desc(repositories.starCount))
117 .limit(limit);
118 return {
119 total: rows.length,
120 repos: rows.map((r) => ({
121 fullName: `${r.ownerName}/${r.name}`,
122 description: r.description || "",
123 stars: r.stars,
124 })),
125 };
126 },
127};
128
129// ---------------------------------------------------------------------------
130// gluecron_repo_read_file
131// ---------------------------------------------------------------------------
132
133const repoReadFile: McpToolHandler = {
134 tool: {
135 name: "gluecron_repo_read_file",
136 description:
137 "Read a single file from a public repository at a given ref (branch / tag / commit). Returns the text content (binary files rejected).",
138 inputSchema: {
139 type: "object",
140 properties: {
141 owner: { type: "string", description: "Repo owner username" },
142 repo: { type: "string", description: "Repo name" },
143 ref: { type: "string", description: "Branch / tag / commit (default: main)" },
144 path: { type: "string", description: "File path within the repo" },
145 },
146 required: ["owner", "repo", "path"],
147 },
148 },
149 async run(args) {
150 const owner = argString(args, "owner");
151 const repo = argString(args, "repo");
152 const ref = argString(args, "ref", "main");
153 const path = argString(args, "path");
154
155 // Visibility check — public-only for v1. (Authed users see private
156 // repos in v2 once we extend the args.)
157 const [r] = await db
158 .select({ isPrivate: repositories.isPrivate })
159 .from(repositories)
160 .innerJoin(users, eq(repositories.ownerId, users.id))
161 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
162 .limit(1);
163 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
164 if (r.isPrivate) {
165 throw new McpError(
166 ERR_METHOD_NOT_FOUND,
167 `${owner}/${repo} is private; v1 MCP read tool is public-only`
168 );
169 }
170
171 const blob = await getBlob(owner, repo, ref, path);
172 if (!blob) {
173 throw new McpError(
174 ERR_METHOD_NOT_FOUND,
175 `path not found: ${owner}/${repo}@${ref}:${path}`
176 );
177 }
178 return {
179 content: [
180 {
181 type: "text",
182 text: blob.content,
183 },
184 ],
185 };
186 },
187};
188
189// ---------------------------------------------------------------------------
190// gluecron_repo_list_issues
191// ---------------------------------------------------------------------------
192
193const repoListIssues: McpToolHandler = {
194 tool: {
195 name: "gluecron_repo_list_issues",
196 description:
197 "List open issues for a public repository. Returns up to 50 ordered by most-recent.",
198 inputSchema: {
199 type: "object",
200 properties: {
201 owner: { type: "string", description: "Repo owner username" },
202 repo: { type: "string", description: "Repo name" },
203 limit: { type: "number", description: "Max results, default 25" },
204 },
205 required: ["owner", "repo"],
206 },
207 },
208 async run(args) {
209 const owner = argString(args, "owner");
210 const repo = argString(args, "repo");
211 const limit = Math.max(1, Math.min(50, argNumber(args, "limit", 25)));
212
213 const [r] = await db
214 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
215 .from(repositories)
216 .innerJoin(users, eq(repositories.ownerId, users.id))
217 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
218 .limit(1);
219 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
220 if (r.isPrivate) {
221 throw new McpError(
222 ERR_METHOD_NOT_FOUND,
223 `${owner}/${repo} is private; v1 MCP read tool is public-only`
224 );
225 }
226
227 const rows = await db
228 .select({
229 number: issues.number,
230 title: issues.title,
231 body: issues.body,
232 state: issues.state,
233 createdAt: issues.createdAt,
234 })
235 .from(issues)
236 .where(and(eq(issues.repositoryId, r.id), eq(issues.state, "open")))
237 .orderBy(desc(issues.createdAt))
238 .limit(limit);
239 return {
240 total: rows.length,
241 issues: rows.map((i) => ({
242 number: i.number,
243 title: i.title,
244 body: i.body || "",
245 state: i.state,
246 createdAt: i.createdAt,
247 })),
248 };
249 },
250};
251
252// ---------------------------------------------------------------------------
253// gluecron_repo_explain_codebase
254// ---------------------------------------------------------------------------
255
256const repoExplain: McpToolHandler = {
257 tool: {
258 name: "gluecron_repo_explain_codebase",
259 description:
260 "Return the cached AI 'explain this codebase' Markdown for a public repo (most recent commit). Returns null when no cached explanation exists yet.",
261 inputSchema: {
262 type: "object",
263 properties: {
264 owner: { type: "string", description: "Repo owner username" },
265 repo: { type: "string", description: "Repo name" },
266 },
267 required: ["owner", "repo"],
268 },
269 },
270 async run(args) {
271 const owner = argString(args, "owner");
272 const repo = argString(args, "repo");
273 const [r] = await db
274 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
275 .from(repositories)
276 .innerJoin(users, eq(repositories.ownerId, users.id))
277 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
278 .limit(1);
279 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
280 if (r.isPrivate) {
281 throw new McpError(
282 ERR_METHOD_NOT_FOUND,
283 `${owner}/${repo} is private; v1 MCP read tool is public-only`
284 );
285 }
286 const [row] = await db
287 .select({
288 commitSha: codebaseExplanations.commitSha,
289 markdown: codebaseExplanations.markdown,
290 createdAt: codebaseExplanations.createdAt,
291 })
292 .from(codebaseExplanations)
293 .where(eq(codebaseExplanations.repositoryId, r.id))
294 .orderBy(desc(codebaseExplanations.createdAt))
295 .limit(1);
296 if (!row) {
297 return { explanation: null };
298 }
299 return {
300 commitSha: row.commitSha,
301 generatedAt: row.createdAt,
302 markdown: row.markdown,
303 };
304 },
305};
306
307// ---------------------------------------------------------------------------
308// gluecron_repo_health
309// ---------------------------------------------------------------------------
310
311const repoHealth: McpToolHandler = {
312 tool: {
313 name: "gluecron_repo_health",
314 description:
315 "Compute the current health report for a public repo: overall score (0-100), letter grade, per-category breakdown (security/testing/complexity/dependencies/documentation/activity), and a list of insights to fix next. Backed by computeHealthScore in src/lib/intelligence.ts.",
316 inputSchema: {
317 type: "object",
318 properties: {
319 owner: { type: "string", description: "Repo owner username" },
320 repo: { type: "string", description: "Repo name" },
321 },
322 required: ["owner", "repo"],
323 },
324 },
325 async run(args) {
326 const owner = argString(args, "owner");
327 const repo = argString(args, "repo");
328
329 const [r] = await db
330 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
331 .from(repositories)
332 .innerJoin(users, eq(repositories.ownerId, users.id))
333 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
334 .limit(1);
335 if (!r) throw new McpError(ERR_METHOD_NOT_FOUND, `repo not found: ${owner}/${repo}`);
336 if (r.isPrivate) {
337 throw new McpError(
338 ERR_METHOD_NOT_FOUND,
339 `${owner}/${repo} is private; v1 MCP tools are public-only`
340 );
341 }
342 if (!(await repoExists(owner, repo))) {
343 throw new McpError(
344 ERR_METHOD_NOT_FOUND,
345 `${owner}/${repo} has no on-disk git data yet`
346 );
347 }
348 const report = await computeHealthScore(owner, repo);
349 return {
350 score: report.score,
351 grade: report.grade,
352 breakdown: report.breakdown,
353 insights: report.insights,
354 generatedAt: report.generatedAt,
355 };
356 },
357};
358
359// ---------------------------------------------------------------------------
360// Default tool registry
361// ---------------------------------------------------------------------------
362
363export function defaultTools(): Record<string, McpToolHandler> {
364 return {
365 [repoSearch.tool.name]: repoSearch,
366 [repoReadFile.tool.name]: repoReadFile,
367 [repoListIssues.tool.name]: repoListIssues,
368 [repoExplain.tool.name]: repoExplain,
369 [repoHealth.tool.name]: repoHealth,
370 };
371}
372
373/** Test-only export of internal helpers + per-tool handlers. */
374export const __test = {
375 argString,
376 argNumber,
377 repoSearch,
378 repoReadFile,
379 repoListIssues,
380 repoExplain,
381 repoHealth,
382};
Addedsrc/lib/mcp.ts+209−0View fileUnifiedSplit
1/**
2 * Model Context Protocol (MCP) server — minimal JSON-RPC 2.0 router.
3 *
4 * MCP: https://spec.modelcontextprotocol.io/
5 *
6 * v1 scope (this file):
7 * - Streamable HTTP transport at POST /mcp
8 * - initialize / initialized handshake
9 * - tools/list (static manifest)
10 * - tools/call dispatching to a small set of read-only tools
11 *
12 * Out of scope for v1 (future moves):
13 * - resources/list + resources/read (the bible files, repo READMEs)
14 * - prompts/* (saved-replies as MCP prompts)
15 * - server-sent notifications (logs streaming)
16 * - oauth flow + per-call auth (we accept PAT + OAuth bearer for now)
17 *
18 * Pure JSON-RPC routing here — the per-tool handlers live in
19 * `mcp-tools.ts`. This split lets the router be tested without DB.
20 */
21
22import type { McpToolHandler, McpTool } from "./mcp-tools";
23
24export const MCP_PROTOCOL_VERSION = "2025-06-18";
25export const MCP_SERVER_NAME = "gluecron";
26export const MCP_SERVER_VERSION = "1.0";
27
28export type JsonRpcRequest = {
29 jsonrpc: "2.0";
30 id?: string | number | null;
31 method: string;
32 params?: unknown;
33};
34
35export type JsonRpcResponse =
36 | {
37 jsonrpc: "2.0";
38 id: string | number | null;
39 result: unknown;
40 }
41 | {
42 jsonrpc: "2.0";
43 id: string | number | null;
44 error: { code: number; message: string; data?: unknown };
45 };
46
47// JSON-RPC standard error codes
48export const ERR_PARSE = -32700;
49export const ERR_INVALID_REQUEST = -32600;
50export const ERR_METHOD_NOT_FOUND = -32601;
51export const ERR_INVALID_PARAMS = -32602;
52export const ERR_INTERNAL = -32603;
53
54export type McpContext = {
55 /** Authenticated user id; null for anonymous (only allowed for some calls). */
56 userId: string | null;
57};
58
59export type McpRouterArgs = {
60 ctx: McpContext;
61 tools: Record<string, McpToolHandler>;
62};
63
64function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
65 return (
66 !!v &&
67 typeof v === "object" &&
68 (v as JsonRpcRequest).jsonrpc === "2.0" &&
69 typeof (v as JsonRpcRequest).method === "string"
70 );
71}
72
73/**
74 * Route a single JSON-RPC request. Returns the response shape (or null
75 * for notifications, which have no `id`). Never throws — internal
76 * exceptions are caught and re-shaped as `-32603 internal error`.
77 */
78export async function routeMcpRequest(
79 req: unknown,
80 args: McpRouterArgs
81): Promise<JsonRpcResponse | null> {
82 if (!isJsonRpcRequest(req)) {
83 return {
84 jsonrpc: "2.0",
85 id: null,
86 error: { code: ERR_INVALID_REQUEST, message: "Invalid JSON-RPC request" },
87 };
88 }
89
90 const id = req.id ?? null;
91 const isNotification = req.id === undefined;
92
93 try {
94 const result = await dispatch(req.method, req.params, args);
95 if (isNotification) return null;
96 return { jsonrpc: "2.0", id, result };
97 } catch (err) {
98 if (isNotification) return null;
99 if (err instanceof McpError) {
100 return {
101 jsonrpc: "2.0",
102 id,
103 error: { code: err.code, message: err.message, data: err.data },
104 };
105 }
106 return {
107 jsonrpc: "2.0",
108 id,
109 error: {
110 code: ERR_INTERNAL,
111 message: err instanceof Error ? err.message : "internal error",
112 },
113 };
114 }
115}
116
117export class McpError extends Error {
118 code: number;
119 data?: unknown;
120 constructor(code: number, message: string, data?: unknown) {
121 super(message);
122 this.code = code;
123 this.data = data;
124 }
125}
126
127async function dispatch(
128 method: string,
129 params: unknown,
130 args: McpRouterArgs
131): Promise<unknown> {
132 switch (method) {
133 case "initialize":
134 return handleInitialize();
135 case "notifications/initialized":
136 // Client → server notification, no response.
137 return null;
138 case "tools/list":
139 return handleToolsList(args.tools);
140 case "tools/call":
141 return handleToolsCall(params, args);
142 case "ping":
143 return {};
144 default:
145 throw new McpError(
146 ERR_METHOD_NOT_FOUND,
147 `Method not supported: ${method}`
148 );
149 }
150}
151
152function handleInitialize() {
153 return {
154 protocolVersion: MCP_PROTOCOL_VERSION,
155 capabilities: {
156 tools: { listChanged: false },
157 },
158 serverInfo: {
159 name: MCP_SERVER_NAME,
160 version: MCP_SERVER_VERSION,
161 },
162 };
163}
164
165function handleToolsList(
166 tools: Record<string, McpToolHandler>
167): { tools: McpTool[] } {
168 return {
169 tools: Object.values(tools).map((t) => t.tool),
170 };
171}
172
173async function handleToolsCall(
174 params: unknown,
175 args: McpRouterArgs
176): Promise<unknown> {
177 if (!params || typeof params !== "object") {
178 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires {name, arguments}");
179 }
180 const p = params as { name?: unknown; arguments?: unknown };
181 const name = typeof p.name === "string" ? p.name : "";
182 if (!name) {
183 throw new McpError(ERR_INVALID_PARAMS, "tools/call requires `name`");
184 }
185 const handler = args.tools[name];
186 if (!handler) {
187 throw new McpError(ERR_METHOD_NOT_FOUND, `Unknown tool: ${name}`);
188 }
189 const toolArgs =
190 p.arguments && typeof p.arguments === "object"
191 ? (p.arguments as Record<string, unknown>)
192 : {};
193 // The MCP tools/call result shape is `{ content: [{type, text}], isError? }`.
194 // Tool handlers may either return that shape directly or just a value
195 // we wrap. Errors from handlers re-throw as McpError so the wrapper
196 // can re-shape them.
197 const out = await handler.run(toolArgs, args.ctx);
198 if (out && typeof out === "object" && Array.isArray((out as any).content)) {
199 return out;
200 }
201 return {
202 content: [
203 {
204 type: "text",
205 text: typeof out === "string" ? out : JSON.stringify(out, null, 2),
206 },
207 ],
208 };
209}
Modifiedsrc/lib/pr-triage.ts+237−11View fileUnifiedSplit
11/**
2 * PR triage — fire-and-forget hook that would suggest labels and reviewers.
3 * Stub implementation: logs the request but does not call out to the AI yet.
2 * PR triage — fire-and-forget hook on PR open. Runs the AI-driven
3 * triage helper from ai-generators.ts and posts a "## AI Triage"
4 * comment with suggestions (labels, reviewers, priority, risk area,
5 * one-line summary).
6 *
7 * Suggestions only — nothing is applied automatically. The PR author
8 * stays in control. The Bible §3 D3 documents this contract.
9 *
10 * Idempotency: every comment carries an HTML-comment marker so a second
11 * trigger (e.g. draft → ready toggle) won't re-post the same advice.
12 *
13 * Failure modes (DB hiccup, missing key, AI parse error) are all
14 * funnelled through fallback / try-catch paths so the autopilot loop
15 * and the route handler never see a thrown promise.
416 */
517
18import { and, asc, eq, like } from "drizzle-orm";
19import { db } from "../db";
20import {
21 labels,
22 prComments,
23 pullRequests,
24 repositories,
25 users,
26} from "../db/schema";
27import { getRepoPath } from "../git/repository";
28import { triagePullRequest, type PrTriage } from "./ai-generators";
29import { isAiAvailable } from "./ai-client";
30
31export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->";
32
33const DIFF_BYTE_CAP = 8_000;
34
635export interface PrTriageInput {
736 ownerName: string;
837 repoName: string;
1544 headBranch: string;
1645}
1746
47async function alreadyTriaged(prId: string): Promise<boolean> {
48 try {
49 const [row] = await db
50 .select({ id: prComments.id })
51 .from(prComments)
52 .where(
53 and(
54 eq(prComments.pullRequestId, prId),
55 eq(prComments.isAiReview, true),
56 like(prComments.body, `%${PR_TRIAGE_MARKER}%`)
57 )
58 )
59 .limit(1);
60 return !!row;
61 } catch {
62 return false;
63 }
64}
65
66async function loadAvailableLabels(repositoryId: string): Promise<string[]> {
67 try {
68 const rows = await db
69 .select({ name: labels.name })
70 .from(labels)
71 .where(eq(labels.repositoryId, repositoryId))
72 .orderBy(asc(labels.name));
73 return rows.map((r) => r.name);
74 } catch {
75 return [];
76 }
77}
78
79/**
80 * Candidate reviewers v1: the repo owner + recent contributors via the
81 * `pull_requests.authorId` join (last 50 PRs). Excludes the current PR
82 * author. Truncated to 12 entries to keep the prompt small.
83 */
84async function loadCandidateReviewers(
85 repositoryId: string,
86 excludeUserId: string
87): Promise<string[]> {
88 try {
89 const out = new Set<string>();
90
91 const [repoRow] = await db
92 .select({ ownerId: repositories.ownerId })
93 .from(repositories)
94 .where(eq(repositories.id, repositoryId))
95 .limit(1);
96 if (repoRow && repoRow.ownerId !== excludeUserId) {
97 const [owner] = await db
98 .select({ username: users.username })
99 .from(users)
100 .where(eq(users.id, repoRow.ownerId))
101 .limit(1);
102 if (owner) out.add(owner.username);
103 }
104
105 const recent = await db
106 .select({ authorId: pullRequests.authorId })
107 .from(pullRequests)
108 .where(eq(pullRequests.repositoryId, repositoryId))
109 .limit(50);
110 const ids = [
111 ...new Set(
112 recent
113 .map((r) => r.authorId)
114 .filter((id): id is string => !!id && id !== excludeUserId)
115 ),
116 ];
117 if (ids.length > 0) {
118 const us = await db
119 .select({ id: users.id, username: users.username })
120 .from(users);
121 const byId = new Map(us.map((u) => [u.id, u.username]));
122 for (const id of ids) {
123 const u = byId.get(id);
124 if (u) out.add(u);
125 if (out.size >= 12) break;
126 }
127 }
128 return [...out];
129 } catch {
130 return [];
131 }
132}
133
134/**
135 * Build a tiny diff summary suitable for the prompt — file path, +/-
136 * lines per file. Skips bodies entirely (we only need shape, not
137 * content). Cap total size at DIFF_BYTE_CAP. Empty string on failure.
138 */
139async function buildDiffSummary(
140 ownerName: string,
141 repoName: string,
142 baseBranch: string,
143 headBranch: string
144): Promise<string> {
145 try {
146 const cwd = getRepoPath(ownerName, repoName);
147 const proc = Bun.spawn(
148 [
149 "git",
150 "diff",
151 "--numstat",
152 `${baseBranch}...${headBranch}`,
153 "--",
154 ],
155 { cwd, stdout: "pipe", stderr: "pipe" }
156 );
157 let text = await new Response(proc.stdout).text();
158 await proc.exited;
159 text = text.trim();
160 if (!text) return "";
161 if (text.length > DIFF_BYTE_CAP) {
162 text = text.slice(0, DIFF_BYTE_CAP) + "\n...(truncated)";
163 }
164 return text;
165 } catch {
166 return "";
167 }
168}
169
170/**
171 * Pure helper: render the "## AI Triage" comment body. Exported for
172 * unit tests so we can pin the format without an Anthropic dependency.
173 */
174export function renderTriageComment(t: PrTriage): string {
175 const labels = t.suggestedLabels.length
176 ? t.suggestedLabels.map((l) => `\`${l}\``).join(", ")
177 : "_(no label suggestions)_";
178 const reviewers = t.suggestedReviewerUsernames.length
179 ? t.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")
180 : "_(no reviewer suggestions)_";
181 const summary = t.summary?.trim() || "_(no summary)_";
182
183 return [
184 PR_TRIAGE_MARKER,
185 "## AI Triage",
186 "",
187 `> ${summary}`,
188 "",
189 `**Priority:** ${t.priority}`,
190 `**Risk area:** ${t.riskArea}`,
191 "",
192 `**Suggested labels:** ${labels}`,
193 `**Suggested reviewers:** ${reviewers}`,
194 "",
195 "_Suggestions only — nothing has been applied. The PR author stays in control._",
196 ].join("\n");
197}
198
18199export async function triggerPrTriage(input: PrTriageInput): Promise<void> {
19 // Intentionally a no-op for now; downstream consumers only await the
20 // promise for catch handlers. The implementation will be filled in
21 // alongside the AI triage pipeline.
22 if (process.env.DEBUG_PR_TRIAGE === "1") {
23 console.log(
24 "[pr-triage] queued",
25 input.ownerName,
26 input.repoName,
27 input.prId,
200 try {
201 if (process.env.DEBUG_PR_TRIAGE === "1") {
202 console.log(
203 "[pr-triage] queued",
204 input.ownerName,
205 input.repoName,
206 input.prId
207 );
208 }
209 if (!isAiAvailable()) return;
210 if (await alreadyTriaged(input.prId)) return;
211
212 const [diffSummary, availableLabels, candidateReviewers] = await Promise.all([
213 buildDiffSummary(
214 input.ownerName,
215 input.repoName,
216 input.baseBranch,
217 input.headBranch
218 ),
219 loadAvailableLabels(input.repositoryId),
220 loadCandidateReviewers(input.repositoryId, input.prAuthorId),
221 ]);
222
223 const triage = await triagePullRequest(
224 input.title,
225 input.body,
226 diffSummary,
227 availableLabels,
228 candidateReviewers
28229 );
230
231 const body = renderTriageComment(triage);
232
233 await db
234 .insert(prComments)
235 .values({
236 pullRequestId: input.prId,
237 authorId: input.prAuthorId,
238 body,
239 isAiReview: true,
240 })
241 .catch(() => {});
242 } catch (err) {
243 if (process.env.DEBUG_PR_TRIAGE === "1") {
244 console.error("[pr-triage] crashed:", err);
245 }
29246 }
30247}
248
249/** Test-only export of internals so DB-less tests can reach the helpers. */
250export const __test = {
251 alreadyTriaged,
252 loadAvailableLabels,
253 loadCandidateReviewers,
254 buildDiffSummary,
255 renderTriageComment,
256};
Addedsrc/lib/push-policy.ts+166−0View fileUnifiedSplit
1/**
2 * Push-policy enforcement — runs at the HTTP layer before git-receive-pack
3 * actually accepts the pack.
4 *
5 * Until now Gluecron's protected-tag and ruleset surfaces were advisory:
6 * `src/hooks/post-receive.ts` would log audit entries after the push had
7 * already landed. This module flips them to truly blocking by evaluating
8 * a list of refs at receive time and returning {allowed:false, violations}
9 * so the route can short-circuit with a 403.
10 *
11 * Refs evaluable from name+sha alone (no pack inspection):
12 * - protected_tags — tag pushes must come from owner/bypass
13 * - ruleset.tag_name_pattern — disallow tag names matching pattern
14 * - ruleset.branch_name_pattern — disallow branch names matching pattern
15 * - ruleset.forbid_force_push — heuristic: detected when oldSha was
16 * not the zero-SHA and newSha differs.
17 * True force-push detection requires a
18 * reachability check we don't run here;
19 * the existing `forcePush` boolean on
20 * PushContext is wired to false unless
21 * a smarter caller fills it in.
22 *
23 * Pure helpers + DB callers; never throws into the request path. On any
24 * unexpected failure we return {allowed:true} (fail-open) to preserve the
25 * existing no-policy behaviour rather than wedging legitimate pushes when
26 * Postgres hiccups.
27 */
28
29import {
30 matchProtectedTag,
31 canBypassProtectedTag,
32} from "./protected-tags";
33import {
34 listRulesetsForRepo,
35 evaluatePush,
36 type PushContext,
37} from "./rulesets";
38
39export type RefUpdate = {
40 oldSha: string;
41 newSha: string;
42 refName: string;
43};
44
45export type PushPolicyArgs = {
46 repositoryId: string;
47 refs: RefUpdate[];
48 pusherUserId: string | null;
49};
50
51export type PushPolicyResult = {
52 allowed: boolean;
53 violations: string[];
54};
55
56/** "0000000000000000000000000000000000000000" — 40 zeros. */
57export const ZERO_SHA = "0".repeat(40);
58
59const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
60
61/**
62 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
63 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
64 * as a branch since the evaluator's tag-only rules will gracefully no-op.
65 */
66function refType(refName: string): "branch" | "tag" {
67 return refName.startsWith("refs/tags/") ? "tag" : "branch";
68}
69
70/**
71 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
72 * and return the aggregated decision. Multiple violations across refs are
73 * concatenated so the user sees every problem in one push attempt rather
74 * than one-at-a-time.
75 */
76export async function evaluatePushPolicy(
77 args: PushPolicyArgs
78): Promise<PushPolicyResult> {
79 const { repositoryId, refs, pusherUserId } = args;
80 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
81
82 const violations: string[] = [];
83
84 // Protected tags — runs once per ref, only fires for tag refs.
85 for (const ref of refs) {
86 if (!ref.refName.startsWith("refs/tags/")) continue;
87 const tagName = ref.refName.slice("refs/tags/".length);
88 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
89 try {
90 protectedRule = await matchProtectedTag(repositoryId, tagName);
91 } catch {
92 protectedRule = null;
93 }
94 if (!protectedRule) continue;
95
96 // Anonymous pusher → never bypasses. Authenticated pusher must be the
97 // owner (or future tag-admin) for this repo.
98 let canBypass = false;
99 try {
100 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
101 } catch {
102 canBypass = false;
103 }
104 if (canBypass) continue;
105
106 const action =
107 ref.newSha === ZERO_SHA
108 ? "delete"
109 : ref.oldSha === ZERO_SHA
110 ? "create"
111 : "update";
112 violations.push(
113 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
114 );
115 }
116
117 // Rulesets — single DB call, evaluator runs purely on names.
118 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
119 try {
120 rulesets = await listRulesetsForRepo(repositoryId);
121 } catch {
122 rulesets = [];
123 }
124
125 if (rulesets && rulesets.length > 0) {
126 for (const ref of refs) {
127 const ctx: PushContext = {
128 kind: "push",
129 refType: refType(ref.refName),
130 refName: ref.refName,
131 commits: [],
132 // forcePush is left false — true detection requires a reachability
133 // check on the new commit, which is in the pack we haven't unpacked.
134 forcePush: false,
135 };
136 let result;
137 try {
138 result = evaluatePush(rulesets, ctx);
139 } catch {
140 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
141 }
142 if (!result.allowed && result.violations.length > 0) {
143 for (const v of result.violations) {
144 // Only "active" enforcement blocks; "evaluate" is dry-run.
145 if (v.enforcement !== "active") continue;
146 violations.push(
147 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
148 );
149 }
150 }
151 }
152 }
153
154 return violations.length === 0
155 ? ALLOW
156 : { allowed: false, violations };
157}
158
159/** Build a human-readable error body for the 403 response. */
160export function formatPolicyError(violations: string[]): string {
161 if (!violations || violations.length === 0) {
162 return "Push rejected by Gluecron policy.";
163 }
164 const lines = violations.map((v) => ` - ${v}`);
165 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
166}
Addedsrc/lib/scheduled-workflows.ts+257−0View fileUnifiedSplit
1/**
2 * Scheduled workflows — fires `on: schedule` triggers from the autopilot
3 * tick.
4 *
5 * Pipeline (per tick, default every 5 minutes via src/lib/autopilot.ts):
6 *
7 * 1. Select all non-disabled workflows whose serialised `parsed` JSON
8 * includes a non-empty `schedules` array. (Cheap LIKE filter — DB
9 * doesn't natively know about JSON keys here, and we want to avoid
10 * pulling all workflows on every tick.)
11 * 2. For each workflow:
12 * - Look up the latest schedule-triggered run (event="schedule").
13 * That row's queuedAt is the `since` boundary; absent → use
14 * (now - 6 minutes), so a freshly-imported workflow doesn't
15 * back-fire for hours of crons.
16 * - For each cron string, parse via src/lib/cron.ts and ask
17 * `cronFiredBetween(since, now)`. The first cron that fired
18 * wins — we enqueue exactly one run per workflow per tick.
19 * 3. enqueueRun(...) with event="schedule", ref=defaultBranch,
20 * commitSha=resolved-default-branch-HEAD. The existing runner
21 * (src/lib/workflow-runner.ts) picks it up exactly like a manual
22 * run.
23 *
24 * Fail-open: every step swallows DB errors and returns a result object
25 * so the autopilot ticker never wedges. Returns a per-call summary so
26 * callers (e.g. the admin dashboard) can show "last tick fired N runs."
27 *
28 * Safety guard: caps each tick at MAX_RUNS_PER_TICK so a misconfigured
29 * cron and an empty schedule-runs table cannot stampede the queue.
30 */
31
32import { and, desc, eq, isNull, sql } from "drizzle-orm";
33import { db } from "../db";
34import {
35 workflowRuns,
36 workflows,
37 repositories,
38} from "../db/schema";
39import { parseCron, cronFiredBetween } from "./cron";
40import { enqueueRun } from "./workflow-runner";
41import { resolveRef, getDefaultBranch } from "../git/repository";
42
43export const MAX_RUNS_PER_TICK = 50;
44const SINCE_FALLBACK_MS = 6 * 60_000; // 6 min — slightly > default 5-min tick
45
46export type ScheduledTickResult = {
47 considered: number;
48 fired: number;
49 errors: number;
50};
51
52type WorkflowRow = {
53 id: string;
54 repositoryId: string;
55 parsed: string;
56};
57
58/**
59 * Parse the `parsed` JSON column and return the cron expressions, or [].
60 * Defensive — never throws.
61 */
62export function schedulesFromParsedJson(parsedJson: string): string[] {
63 try {
64 const obj = JSON.parse(parsedJson || "{}");
65 const out = Array.isArray(obj?.schedules) ? obj.schedules : [];
66 return out.filter((s: unknown): s is string => typeof s === "string" && s.trim().length > 0);
67 } catch {
68 return [];
69 }
70}
71
72/**
73 * Pure decision helper — given a workflow's schedules + last fire wall +
74 * current wall, return the first cron that should fire (or null).
75 * Exposed for unit tests so the cron→fire wiring is verifiable without
76 * a DB.
77 */
78export function firstCronToFire(
79 schedules: string[],
80 since: Date,
81 until: Date
82): string | null {
83 for (const expr of schedules) {
84 const parsed = parseCron(expr);
85 if (!parsed.ok) continue;
86 if (cronFiredBetween(parsed.cron, since, until)) return expr;
87 }
88 return null;
89}
90
91async function lastScheduleRunQueuedAt(
92 workflowId: string
93): Promise<Date | null> {
94 try {
95 const [row] = await db
96 .select({ queuedAt: workflowRuns.queuedAt })
97 .from(workflowRuns)
98 .where(
99 and(
100 eq(workflowRuns.workflowId, workflowId),
101 eq(workflowRuns.event, "schedule")
102 )
103 )
104 .orderBy(desc(workflowRuns.queuedAt))
105 .limit(1);
106 return row ? new Date(row.queuedAt) : null;
107 } catch {
108 return null;
109 }
110}
111
112async function loadOwnerAndRepoName(
113 repositoryId: string
114): Promise<{ ownerName: string; repoName: string; defaultBranch: string } | null> {
115 try {
116 const [row] = await db
117 .select({
118 repoName: repositories.name,
119 ownerId: repositories.ownerId,
120 defaultBranch: repositories.defaultBranch,
121 })
122 .from(repositories)
123 .where(eq(repositories.id, repositoryId))
124 .limit(1);
125 if (!row) return null;
126 // Owner username lookup via the standard repositories.owner_id → users
127 // join. Performed lazily to avoid a join on the hot list query.
128 const { users } = await import("../db/schema");
129 const [owner] = await db
130 .select({ username: users.username })
131 .from(users)
132 .where(eq(users.id, row.ownerId))
133 .limit(1);
134 if (!owner) return null;
135 return {
136 ownerName: owner.username,
137 repoName: row.repoName,
138 defaultBranch: row.defaultBranch || "main",
139 };
140 } catch {
141 return null;
142 }
143}
144
145/**
146 * Walk every non-disabled workflow whose parsed JSON could include a
147 * schedules field, decide if any cron fired since the last schedule-run,
148 * and enqueue at most one run per workflow per tick.
149 */
150export async function runScheduledWorkflowsTick(
151 now: Date = new Date()
152): Promise<ScheduledTickResult> {
153 const result: ScheduledTickResult = { considered: 0, fired: 0, errors: 0 };
154
155 let candidates: WorkflowRow[] = [];
156 try {
157 candidates = await db
158 .select({
159 id: workflows.id,
160 repositoryId: workflows.repositoryId,
161 parsed: workflows.parsed,
162 })
163 .from(workflows)
164 .where(
165 and(
166 eq(workflows.disabled, false),
167 // Cheap pre-filter: only workflows whose parsed JSON contains
168 // the literal token "schedules" (presence implies non-empty
169 // array via the parser contract). This is intentionally a
170 // string-LIKE — JSON-aware operators are nice-to-have.
171 sql`${workflows.parsed} LIKE '%"schedules"%'`
172 )
173 );
174 } catch {
175 candidates = [];
176 result.errors += 1;
177 }
178
179 for (const w of candidates) {
180 if (result.fired >= MAX_RUNS_PER_TICK) break;
181 result.considered += 1;
182
183 const schedules = schedulesFromParsedJson(w.parsed);
184 if (schedules.length === 0) continue;
185
186 const lastQ = await lastScheduleRunQueuedAt(w.id);
187 const since = lastQ
188 ? lastQ
189 : new Date(now.getTime() - SINCE_FALLBACK_MS);
190
191 const expr = firstCronToFire(schedules, since, now);
192 if (!expr) continue;
193
194 const repoMeta = await loadOwnerAndRepoName(w.repositoryId);
195 if (!repoMeta) {
196 result.errors += 1;
197 continue;
198 }
199
200 let commitSha: string | null = null;
201 try {
202 commitSha = await resolveRef(
203 repoMeta.ownerName,
204 repoMeta.repoName,
205 repoMeta.defaultBranch
206 );
207 } catch {
208 commitSha = null;
209 }
210 if (!commitSha) {
211 // Try to recover the default branch via the on-disk repo if the DB
212 // value is stale — best-effort. If still unknown, skip.
213 try {
214 const def = await getDefaultBranch(
215 repoMeta.ownerName,
216 repoMeta.repoName
217 );
218 if (def) {
219 commitSha = await resolveRef(
220 repoMeta.ownerName,
221 repoMeta.repoName,
222 def
223 );
224 }
225 } catch {
226 commitSha = null;
227 }
228 }
229 if (!commitSha) {
230 result.errors += 1;
231 continue;
232 }
233
234 try {
235 await enqueueRun({
236 workflowId: w.id,
237 repositoryId: w.repositoryId,
238 event: "schedule",
239 ref: `refs/heads/${repoMeta.defaultBranch}`,
240 commitSha,
241 triggeredBy: null,
242 });
243 result.fired += 1;
244 } catch {
245 result.errors += 1;
246 }
247 }
248
249 return result;
250}
251
252/** Test-only exposed internals so DB-less test cases can pin behaviour. */
253export const __test = {
254 schedulesFromParsedJson,
255 firstCronToFire,
256 SINCE_FALLBACK_MS,
257};
Modifiedsrc/lib/sse-client.ts+36−0View fileUnifiedSplit
6666 );
6767}
6868
69/**
70 * Live comment-banner script: subscribe to a topic and increment a
71 * counter inside a banner element when new events arrive. Use on
72 * issue / PR detail pages to nudge the user to reload when remote
73 * comments land while their tab is open.
74 *
75 * Banner contract: the page renders a hidden element with the given
76 * id and an inner `<a class="js-live-count">` and `<a class="js-live-link">`.
77 * The script reveals the banner on the first event, updates the count
78 * text, and points the link at the current URL so a click reloads the
79 * page (cleanest way to pick up the new comment HTML server-side).
80 */
81export function liveCommentBannerScript(args: {
82 topic: string;
83 bannerElementId: string;
84}): string {
85 const topic = safeJsonForScript(args.topic);
86 const id = safeJsonForScript(args.bannerElementId);
87 return (
88 "(function(){try{" +
89 "if(typeof EventSource==='undefined')return;" +
90 "var t=" + topic + ",bid=" + id + ";" +
91 "var b=document.getElementById(bid);if(!b)return;" +
92 "var n=0;var es,delay=1000;" +
93 "function show(){if(b.style)b.style.display='';" +
94 "var c=b.querySelector('.js-live-count');" +
95 "if(c)c.textContent=String(n);" +
96 "var lk=b.querySelector('.js-live-link');" +
97 "if(lk)lk.setAttribute('href',window.location.pathname+window.location.search);}" +
98 "function connect(){try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
99 "es.onmessage=function(){n++;show();};" +
100 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
101 "}connect();}catch(e){}})();"
102 );
103}
104
69105/**
70106 * Live log-tail script: subscribe to a workflow-run topic, append step-log
71107 * chunks to a <pre>, and auto-close when 'run-done' arrives.
Modifiedsrc/lib/workflow-parser.ts+54−1View fileUnifiedSplit
4040export type ParsedWorkflow = {
4141 name: string;
4242 on: string[];
43 /**
44 * Cron expressions captured from `on: { schedule: [{cron: "..."}, ...] }`.
45 * Empty when the workflow has no schedule trigger (the common case).
46 * Strings are passed through verbatim — validation happens later when
47 * the scheduler tries to parse them via `src/lib/cron.ts`.
48 */
49 schedules?: string[];
4350 jobs: WorkflowJob[];
4451};
4552
497504 return null;
498505}
499506
507/**
508 * Extract cron expressions from the raw `on:` value when it is a mapping
509 * containing a `schedule:` key. Returns [] for any other shape so callers
510 * can unconditionally read `parsed.schedules ?? []`. Tolerant of:
511 * - schedule: [{cron: "0 * * * *"}, ...]
512 * - schedule: {cron: "0 * * * *"}
513 * - schedule: "0 * * * *" (legacy, not standard but seen in the wild)
514 *
515 * Pure helper — exported alongside the existing `__test` bundle.
516 */
517function extractSchedules(rawOn: unknown): string[] {
518 if (!rawOn || typeof rawOn !== "object" || Array.isArray(rawOn)) return [];
519 const m = rawOn as Record<string, unknown>;
520 const node = m.schedule;
521 if (node == null) return [];
522
523 const out: string[] = [];
524 const collect = (entry: unknown) => {
525 if (typeof entry === "string") {
526 const s = entry.trim();
527 if (s) out.push(s);
528 return;
529 }
530 if (entry && typeof entry === "object" && !Array.isArray(entry)) {
531 const cron = (entry as Record<string, unknown>).cron;
532 const s = typeof cron === "string" ? cron.trim() : "";
533 if (s) out.push(s);
534 }
535 };
536
537 if (Array.isArray(node)) {
538 for (const e of node) collect(e);
539 } else {
540 collect(node);
541 }
542 return out;
543}
544
500545function normaliseStep(
501546 raw: unknown,
502547 jobName: string,
575620 if (!on || on.length === 0) {
576621 return { ok: false, error: "workflow missing 'on' trigger" };
577622 }
623 const schedules = extractSchedules(doc.on);
578624
579625 const jobsRaw = doc.jobs;
580626 if (
594640 jobs.push(res.job);
595641 }
596642
597 return { ok: true, workflow: { name, on, jobs } };
643 const workflow: ParsedWorkflow = { name, on, jobs };
644 if (schedules.length > 0) workflow.schedules = schedules;
645 return { ok: true, workflow };
598646}
647
648/**
649 * Test-only export of the schedule extractor. Pure helper, no DB.
650 */
651export const __test = { extractSchedules };
Modifiedsrc/lib/workflow-runner.ts+36−3View fileUnifiedSplit
2727 workflowRuns,
2828 workflows,
2929} from "../db/schema";
30import {
31 loadSecretsContext,
32 substituteSecrets,
33} from "./workflow-secrets";
3034
3135// ---------------------------------------------------------------------------
3236// Tunables
190194 * Run a single step via `bash -c`. Captures stdout/stderr (capped), enforces
191195 * a hard timeout with SIGTERMSIGKILL escalation, and returns a StepResult
192196 * shaped for persistence.
197 *
198 * `secrets` is the per-run plaintext map produced by
199 * `loadSecretsContext(repoId)`. Default `{}` keeps the legacy callers
200 * working — when no secrets are loaded the substitution pass is a no-op
201 * because the regex matches nothing.
193202 */
194203async function runStep(
195204 step: ParsedStep,
196205 checkoutDir: string,
197 runId: string
206 runId: string,
207 secrets: Record<string, string> = {}
198208): Promise<StepResult> {
199209 const name =
200210 typeof step.name === "string" && step.name.length > 0
201211 ? step.name
202212 : (typeof step.run === "string" ? step.run.split("\n")[0] : "") ||
203213 "step";
204 const run = typeof step.run === "string" ? step.run : "";
214 const rawRun = typeof step.run === "string" ? step.run : "";
215 // Substitute `${{ secrets.NAME }}` only when the template actually
216 // contains a token — otherwise this is a free pass-through. The function
217 // is pure + safe for empty secret maps; the conditional avoids an
218 // unnecessary regex compile + scan on the hot path.
219 const run =
220 rawRun.indexOf("${{") >= 0
221 ? substituteSecrets(rawRun, secrets)
222 : rawRun;
205223 const started = Date.now();
206224
207225 if (!run) {
386404 job: ParsedJob;
387405 jobOrder: number;
388406 checkoutDir: string;
407 /** Per-run plaintext secrets. Default `{}` preserves the v1 contract
408 * for callers that haven't been updated to plumb secrets through. */
409 secrets?: Record<string, string>;
389410}): Promise<{ success: boolean }> {
390411 const { runId, jobKey, job, jobOrder, checkoutDir } = opts;
391412 const name = typeof job.name === "string" && job.name ? job.name : jobKey;
441462 });
442463 continue;
443464 }
444 const result = await runStep(step, checkoutDir, runId);
465 const result = await runStep(step, checkoutDir, runId, opts.secrets || {});
445466 stepResults.push(result);
446467 logParts.push(
447468 `==> ${result.name}\n$ ${result.run}\n${result.stdout}${
631652
632653 // --- Run jobs sequentially (v1 fallback) ---
633654 if (!handledByV2) {
655 // Load per-run secrets once. loadSecretsContext is fail-soft — empty
656 // map on missing master key, decrypt failures, or DB error — so this
657 // is always safe to call. A small overhead per run; secret-free
658 // workflows just see {} pass through.
659 let runSecrets: Record<string, string> = {};
660 try {
661 runSecrets = await loadSecretsContext(run.repositoryId);
662 } catch (err) {
663 console.error("[workflow-runner] loadSecretsContext threw:", err);
664 runSecrets = {};
665 }
634666 try {
635667 for (let i = 0; i < jobs.length; i++) {
636668 const { key, job } = jobs[i]!;
640672 job,
641673 jobOrder: i,
642674 checkoutDir,
675 secrets: runSecrets,
643676 });
644677 if (!result.success) {
645678 anyJobFailed = true;
Modifiedsrc/lib/workflow-secrets.ts+40−0View fileUnifiedSplit
182182 }
183183}
184184
185/**
186 * Substitute `${{ secrets.NAME }}` references inside a template string with
187 * the corresponding plaintext from `secrets`. Pure helper — no DB, no
188 * side effects. Designed to feed the workflow runner's per-step `run:`
189 * value before handing it to `bash -c`.
190 *
191 * Behaviour:
192 * - Whitespace inside the `{{ ... }}` is tolerated: `${{secrets.X}}`,
193 * `${{ secrets.X }}`, `${{ secrets . X }}`.
194 * - Names that don't appear in `secrets` are left intact (so the
195 * unsubstituted token shows up in logs and surfaces the misconfig
196 * loudly — matches the loadSecretsContext docstring contract).
197 * - Names that don't match the GitHub-Actions secret-name grammar
198 * (`[A-Z_][A-Z0-9_]*`) are also left intact, defence-in-depth
199 * against malformed YAML producing weird tokens.
200 * - The function never throws on bad input; non-string templates
201 * return `""`.
202 *
203 * Pure regex; no exec-on-string allocations beyond the single replace.
204 */
205export function substituteSecrets(
206 template: string,
207 secrets: Record<string, string>
208): string {
209 if (typeof template !== "string") return "";
210 if (!template || !secrets) return template || "";
211 // ${{ <ws>* secrets <ws>* . <ws>* NAME <ws>* }}
212 // We split the regex on the dot so we can tolerate whitespace either
213 // side of it; the NAME group is captured with the strict grammar so a
214 // malformed identifier won't accidentally substitute.
215 return template.replace(
216 /\$\{\{\s*secrets\s*\.\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g,
217 (match, name: string) => {
218 if (Object.prototype.hasOwnProperty.call(secrets, name)) {
219 return secrets[name];
220 }
221 return match;
222 }
223 );
224}
185225/**
186226 * Build the `{ NAME: plaintext }` map consumed by the runner's
187227 * `substituteSecrets(template, secrets)` calls.
Modifiedsrc/routes/admin.tsx+2−1View fileUnifiedSplit
631631 <h1 style="margin-bottom: 8px">Autopilot</h1>
632632 <p style="color: var(--text-muted); margin-bottom: 24px">
633633 Periodic platform-maintenance loop — mirror sync, merge-queue
634 progress, weekly digests, advisory rescans.
634 progress, weekly digests, advisory rescans, environment
635 wait-timer release, scheduled workflow triggers (cron).
635636 </p>
636637 {msg && (
637638 <div
Modifiedsrc/routes/dashboard.tsx+117−0View fileUnifiedSplit
352352 </>
353353 )}
354354
355 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
356 <HealthCoach repoData={repoData} username={user.username} />
357
355358 {/* ─── Live Activity (SSE) ─── */}
356359 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
357360
553556
554557// ─── COMPONENTS ──────────────────────────────────────────────
555558
559/**
560 * Pure helper: pick the bottom-N repos by health score and return a
561 * prioritized "fix this next" plan. Health=0 repos (couldn't be
562 * computed) are excluded so the coach doesn't recommend ghost repos.
563 *
564 * Exported under __test for unit testing without touching the DB.
565 */
566export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
567 repoData: T[],
568 topN = 3
569): T[] {
570 return repoData
571 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
572 .sort((a, b) => a.healthScore - b.healthScore)
573 .slice(0, topN);
574}
575
576/** Module-scoped color picker for grade chips. Mirrors the inner
577 * `gradeColor` defined in the request handler scope, exposed at module
578 * level so HealthCoach (also module-scope) can reach it. */
579function moduleGradeColor(grade: string): string {
580 if (grade === "A+" || grade === "A") return "var(--green)";
581 if (grade === "B") return "#58a6ff";
582 if (grade === "C") return "var(--yellow)";
583 if (grade === "?") return "var(--text-muted)";
584 return "var(--red)";
585}
586
587const HealthCoach = ({
588 repoData,
589 username,
590}: {
591 repoData: Array<{
592 repo: { name: string; description: string | null };
593 healthScore: number;
594 healthGrade: string;
595 }>;
596 username: string;
597}) => {
598 const picks = pickRepoCoachPicks(repoData, 3);
599 if (picks.length === 0) {
600 return (
601 <div
602 class="card"
603 style="margin-bottom: 32px; padding: 16px; background: rgba(63,185,80,0.08); border-color: var(--green)"
604 >
605 <h3 style="margin: 0 0 4px; font-size: 15px">
606 {"✨"} AI Health Coach
607 </h3>
608 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
609 All your repos are healthy (score &ge; 90). Nothing to triage.
610 </p>
611 </div>
612 );
613 }
614 return (
615 <div
616 class="card"
617 style="margin-bottom: 32px; padding: 0; overflow: hidden"
618 >
619 <div
620 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
621 >
622 <div>
623 <h3 style="margin: 0; font-size: 15px">
624 {"✨"} AI Health Coach
625 </h3>
626 <p
627 style="margin: 4px 0 0; color: var(--text-muted); font-size: 12px"
628 >
629 Top {picks.length} repos that would benefit from attention
630 this week.
631 </p>
632 </div>
633 </div>
634 <ul style="list-style: none; margin: 0; padding: 0">
635 {picks.map((p) => (
636 <li
637 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px"
638 >
639 <div
640 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
641 >
642 {p.healthGrade}
643 </div>
644 <div style="flex: 1; min-width: 0">
645 <a
646 href={`/${username}/${p.repo.name}`}
647 style="font-weight: 500"
648 >
649 {p.repo.name}
650 </a>
651 <div
652 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
653 >
654 Health score {p.healthScore}/100 — open the repo to see
655 breakdown + AI suggestions.
656 </div>
657 </div>
658 <a
659 href={`/${username}/${p.repo.name}/explain`}
660 class="btn"
661 style="font-size: 12px; padding: 4px 10px"
662 title="Run AI explain on this repo"
663 >
664 Coach me
665 </a>
666 </li>
667 ))}
668 </ul>
669 </div>
670 );
671};
672
556673const StatBox = ({
557674 label,
558675 value,
Modifiedsrc/routes/editor.tsx+139−1View fileUnifiedSplit
2323 getRepoPath,
2424 repoExists,
2525} from "../git/repository";
26import { generateCommitMessage } from "../lib/ai-generators";
27import { isAiAvailable } from "../lib/ai-client";
2628import { softAuth, requireAuth } from "../middleware/auth";
2729import type { AuthEnv } from "../middleware/auth";
2830import { requireRepoAccess } from "../middleware/repo-access";
3234
3335editor.use("*", softAuth);
3436
37/**
38 * Inline JS for the editor's "Suggest with AI" commit-message button.
39 * Picks up the textarea content + form-pinned ref/filePath, POSTs JSON
40 * to the suggest endpoint, fills the message Input on success.
41 *
42 * Built as a string so we don't need a bundler. JSON-escapes against
43 * </script> breakout. Defensive DOM lookups (silent no-op on absence).
44 */
45function AI_COMMIT_MSG_SCRIPT(args: {
46 endpoint: string;
47 ref: string;
48 filePath: string;
49}): string {
50 const safe = (v: string) =>
51 JSON.stringify(v)
52 .split("<").join("\\u003C")
53 .split(">").join("\\u003E")
54 .split("&").join("\\u0026");
55 const url = safe(args.endpoint);
56 const ref = safe(args.ref);
57 const filePath = safe(args.filePath);
58 return (
59 "(function(){try{" +
60 "var btn=document.getElementById('ai-commit-msg-btn');" +
61 "var status=document.getElementById('ai-commit-msg-status');" +
62 "var input=document.getElementById('commit-message-input');" +
63 "var ta=document.querySelector('textarea[name=\"content\"]');" +
64 "if(!btn||!input||!ta)return;" +
65 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
66 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
67 "var fd='ref='+encodeURIComponent(" + ref + ")+'&filePath='+encodeURIComponent(" + filePath + ")+'&content='+encodeURIComponent(ta.value||'');" +
68 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:fd,credentials:'same-origin'})" +
69 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
70 ".then(function(j){btn.disabled=false;" +
71 "if(j&&j.ok&&typeof j.message==='string'){" +
72 "if(input.value&&input.value.trim().length>0){if(!confirm('Replace existing message?')){if(status)status.textContent='Cancelled.';return;}}" +
73 "input.value=j.message;if(status)status.textContent='Filled from AI. Edit before committing.';" +
74 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
75 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
76 "});" +
77 "}catch(e){}})();"
78 );
79}
80
3581// New file form
3682editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
3783 const { owner, repo } = c.req.param();
231277 </FormGroup>
232278 <FormGroup label="Commit message">
233279 <Input
280 id="commit-message-input"
234281 name="message"
235282 placeholder={`Update ${filePath.split("/").pop()}`}
236283 required
237284 aria-label="Commit message"
238285 />
239286 </FormGroup>
240 <Flex gap={8}>
287 <Flex gap={8} align="center">
241288 <Button type="submit" variant="primary">
242289 Commit changes
243290 </Button>
291 <button
292 type="button"
293 id="ai-commit-msg-btn"
294 class="btn"
295 title="Generate a one-line commit message using Claude based on the diff"
296 >
297 Suggest with AI
298 </button>
299 <span
300 id="ai-commit-msg-status"
301 style="color:var(--text-muted);font-size:13px"
302 />
244303 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
245304 Cancel
246305 </LinkButton>
247306 </Flex>
307 <script
308 dangerouslySetInnerHTML={{
309 __html: AI_COMMIT_MSG_SCRIPT({
310 endpoint: `/${owner}/${repo}/ai/commit-message`,
311 ref,
312 filePath,
313 }),
314 }}
315 />
248316 </Form>
249317 </Container>
250318 </Layout>
251319 );
252320});
253321
322// AI-suggested commit message — JSON endpoint driven by the editor button.
323// Reads the on-disk blob at (ref, filePath), diffs against the submitted
324// new content, and asks generateCommitMessage() for a one-liner. Returns
325// {ok:true, message} on success, {ok:false, error} otherwise. Always 200.
326editor.post(
327 "/:owner/:repo/ai/commit-message",
328 requireAuth,
329 requireRepoAccess("write"),
330 async (c) => {
331 const { owner, repo } = c.req.param();
332 if (!isAiAvailable()) {
333 return c.json({
334 ok: false,
335 error: "AI is not available — set ANTHROPIC_API_KEY.",
336 });
337 }
338 const body = await c.req.parseBody();
339 const ref = String(body.ref || "").trim();
340 const filePath = String(body.filePath || "").trim();
341 const newContent = String(body.content || "");
342 if (!ref || !filePath) {
343 return c.json({ ok: false, error: "ref + filePath required" });
344 }
345
346 let oldContent = "";
347 try {
348 const blob = await getBlob(owner, repo, ref, filePath);
349 oldContent = blob?.content || "";
350 } catch {
351 oldContent = "";
352 }
353
354 if (oldContent === newContent) {
355 return c.json({
356 ok: false,
357 error: "No changes to summarise.",
358 });
359 }
360
361 // Build a minimal unified-diff-ish summary the AI helper can consume.
362 // generateCommitMessage was written for git diff text; we feed a
363 // header + truncated old/new sample so it has shape to summarise.
364 const truncate = (s: string) => (s.length > 4000 ? s.slice(0, 4000) + "\n…(truncated)" : s);
365 const diff =
366 `--- a/${filePath}\n+++ b/${filePath}\n` +
367 "## Old:\n" +
368 truncate(oldContent) +
369 "\n\n## New:\n" +
370 truncate(newContent);
371
372 let message = "";
373 try {
374 message = await generateCommitMessage(diff);
375 } catch (err) {
376 const msg = err instanceof Error ? err.message : "AI request failed.";
377 return c.json({ ok: false, error: msg });
378 }
379 if (!message.trim()) {
380 return c.json({
381 ok: false,
382 error: "AI returned an empty draft.",
383 });
384 }
385 // Cap to one line + 100 chars (commit-message convention).
386 const oneLine = message.split("\n")[0]!.trim();
387 const capped = oneLine.length > 100 ? oneLine.slice(0, 97) + "..." : oneLine;
388 return c.json({ ok: true, message: capped });
389 }
390);
391
254392// Save edited file
255393editor.post("/:owner/:repo/edit/:ref{.+$}", requireAuth, requireRepoAccess("write"), async (c) => {
256394 const { owner, repo } = c.req.param();
Modifiedsrc/routes/environments.tsx+19−3View fileUnifiedSplit
533533 comment,
534534 });
535535
536 // Re-read state and flip the deployment row accordingly.
536 // Re-read state and flip the deployment row accordingly. When the env
537 // carries a wait timer and the timer hasn't elapsed yet, we hold the
538 // deploy in status="waiting_timer" with `readyAfter` populated; the
539 // autopilot ticker (`releaseExpiredWaitTimers`) flips it later.
537540 const state = await computeApprovalState(deploymentId, env);
538541 let newStatus: string | null = null;
542 let readyAfter: Date | null = null;
543 let blockedReason: string | null = null;
539544 if (state.rejected) {
540545 newStatus = "rejected";
546 blockedReason = "rejected by reviewer";
541547 } else if (state.approved && deployment.status === "pending_approval") {
542 newStatus = "pending"; // hand off to existing deployer
548 const now = new Date();
549 if (state.readyAfter && state.readyAfter.getTime() > now.getTime()) {
550 newStatus = "waiting_timer";
551 readyAfter = state.readyAfter;
552 blockedReason = `wait_timer until ${state.readyAfter.toISOString()}`;
553 } else {
554 newStatus = "pending"; // hand off to existing deployer
555 }
543556 }
544557
545558 if (newStatus) {
548561 .update(deployments)
549562 .set({
550563 status: newStatus,
551 blockedReason: newStatus === "rejected" ? "rejected by reviewer" : null,
564 blockedReason,
565 // Always overwrite readyAfter — clears any prior value when the
566 // status flips to anything other than waiting_timer.
567 readyAfter,
552568 })
553569 .where(eq(deployments.id, deploymentId));
554570 } catch (err) {
Modifiedsrc/routes/git.ts+94−7View fileUnifiedSplit
55 */
66
77import { Hono } from "hono";
8import { and, eq } from "drizzle-orm";
9import { db } from "../db";
10import { repositories, users } from "../db/schema";
811import { getInfoRefs, serviceRpc } from "../git/protocol";
912import { repoExists } from "../git/repository";
1013import { onPostReceive } from "../hooks/post-receive";
1114import { invalidateRepoCache } from "../lib/cache";
1215import { trackByName } from "../lib/traffic";
16import {
17 evaluatePushPolicy,
18 formatPolicyError,
19} from "../lib/push-policy";
20import { resolvePusher } from "../lib/git-push-auth";
21import { audit } from "../lib/notify";
1322
1423const git = new Hono();
1524
6675
6776// Receive pack (push)
6877git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
69 const { owner, "repo.git": repo } = c.req.param();
70 if (!(await repoExists(owner, repo))) {
78 const { owner, "repo.git": repoRaw } = c.req.param();
79 const repo = (repoRaw || "").replace(/\.git$/, "");
80 if (!(await repoExists(owner, repoRaw))) {
7181 return c.text("Repository not found", 404);
7282 }
7383
74 // Parse the incoming refs from the request body before passing to git
84 // Read the body once; we parse refs from it for both pre-receive policy
85 // checks and the existing post-receive hook.
7586 const bodyBuffer = await c.req.arrayBuffer();
87 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
88
89 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
90 // on any DB hiccup (the helper returns {allowed:true} in that case).
91 if (refs.length > 0) {
92 try {
93 const repoRow = await loadRepoRow(owner, repo);
94 if (repoRow) {
95 const pusher = await resolvePusher(c.req.header("authorization"));
96 const decision = await evaluatePushPolicy({
97 repositoryId: repoRow.id,
98 refs,
99 pusherUserId: pusher?.userId || null,
100 });
101 if (!decision.allowed) {
102 // Audit the rejection so owners can see blocked-push attempts
103 // even though the request never reached the post-receive hook.
104 // Fire-and-forget — never block the 403.
105 audit({
106 userId: pusher?.userId || null,
107 repositoryId: repoRow.id,
108 action: "push.rejected",
109 targetType: "repository",
110 targetId: repoRow.id,
111 ip:
112 c.req.header("x-forwarded-for") ||
113 c.req.header("x-real-ip") ||
114 undefined,
115 userAgent: c.req.header("user-agent") || undefined,
116 metadata: {
117 violations: decision.violations,
118 refs: refs.map((r) => r.refName),
119 pusherSource: pusher?.source || "anonymous",
120 },
121 }).catch(() => {});
122 // Returning 403 with a plain-text body — git smart-HTTP clients
123 // surface the body to the user (`remote: ` prefix). Existing
124 // behaviour for repos with no policy is unchanged.
125 return c.text(formatPolicyError(decision.violations), 403);
126 }
127 }
128 } catch {
129 // Never wedge a legitimate push on enforcer failure.
130 }
131 }
132
76133 const response = await serviceRpc(
77134 owner,
78 repo,
135 repoRaw,
79136 "git-receive-pack",
80137 bodyBuffer
81138 );
83140 // Invalidate cached git data for this repo immediately
84141 invalidateRepoCache(owner, repo);
85142
86 // Fire post-receive hooks asynchronously (don't block response)
87 // We parse updated refs from the pkt-line protocol in the request
88 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
143 // Fire post-receive hooks asynchronously (don't block response).
89144 if (refs.length > 0) {
90145 onPostReceive(owner, repo, refs).catch((err) =>
91146 console.error("[post-receive] hook error:", err)
95150 return response;
96151});
97152
153/**
154 * Look up the repositories row keyed by owner username + repo name.
155 * Pure DB helper kept local to this file because it's only used by the
156 * push-policy gate; returns null on miss/error so the caller fails open.
157 */
158async function loadRepoRow(
159 ownerName: string,
160 repoName: string
161): Promise<{ id: string } | null> {
162 try {
163 const [ownerRow] = await db
164 .select({ id: users.id })
165 .from(users)
166 .where(eq(users.username, ownerName))
167 .limit(1);
168 if (!ownerRow) return null;
169 const [repoRow] = await db
170 .select({ id: repositories.id })
171 .from(repositories)
172 .where(
173 and(
174 eq(repositories.ownerId, ownerRow.id),
175 eq(repositories.name, repoName)
176 )
177 )
178 .limit(1);
179 return repoRow || null;
180 } catch {
181 return null;
182 }
183}
184
98185/**
99186 * Parse ref updates from git-receive-pack request body.
100187 * Format: <old-sha> <new-sha> <ref-name>
Modifiedsrc/routes/help.tsx+59−0View fileUnifiedSplit
4444 <a href="#webhooks">Webhooks</a> &middot;{" "}
4545 <a href="#tokens">Personal access tokens</a> &middot;{" "}
4646 <a href="#gates">Gates & AI review</a> &middot;{" "}
47 <a href="#ai-native">AI-native flow</a> &middot;{" "}
4748 <a href="#shortcuts">Keyboard shortcuts</a> &middot;{" "}
4849 <a href="#api">API</a>
4950 </nav>
264265 </div>
265266 </section>
266267
268 <section id="ai-native" style="margin-bottom: 32px">
269 <h2 style="margin-bottom: 12px; font-size: 20px">
270 AI-native flow
271 </h2>
272 <div class="panel">
273 <div class="panel-item">
274 <div>
275 <strong>Issue → PR in one click.</strong> Open any issue you
276 own and hit <em>Build with AI</em> in the header. The spec
277 form pre-fills with the issue title + body and a{" "}
278 <code>Closes #N</code> footer; Claude drafts the diff, opens
279 a draft PR, and the merge auto-closes the originating issue.
280 </div>
281 </div>
282 <div class="panel-item">
283 <div>
284 <strong>AI-drafted PR descriptions.</strong> The new-PR form
285 has a <em>Suggest description with AI</em> button that runs
286 <code> generatePrSummary</code> against{" "}
287 <code>git diff base...head</code> and fills the description
288 with a structured summary (Why · Key changes · Test plan ·
289 Risks).
290 </div>
291 </div>
292 <div class="panel-item">
293 <div>
294 <strong>Auto-review on PR open.</strong> Non-draft PRs get a
295 summary comment plus inline file/line annotations from the
296 AI reviewer. A second comment posts label + reviewer +
297 priority suggestions (the <em>AI Triage</em> block). All
298 suggestions; nothing applied automatically.
299 </div>
300 </div>
301 <div class="panel-item">
302 <div>
303 <strong>Repo-wide AI surfaces.</strong>{" "}
304 <a href="/help#explore">Explain</a> a codebase, run{" "}
305 <a href="/help#explore">semantic search</a>, ask the chat
306 anything about the repo, generate failing test stubs from a
307 source file (the <em>Tests</em> link in the repo nav), and
308 draft full PRs from a plain-English spec via{" "}
309 <em>Spec to PR</em>. All require{" "}
310 <code>ANTHROPIC_API_KEY</code>; without it the surfaces
311 degrade gracefully to deterministic fallbacks.
312 </div>
313 </div>
314 <div class="panel-item">
315 <div>
316 <strong>Scheduled workflows.</strong> Drop{" "}
317 <code>on: schedule: [{`{cron: "0 * * * *"}`}]</code> into any
318 <code> .gluecron/workflows/*.yml</code>. The autopilot
319 ticker fires the cron from the same node that handles your
320 pushes — no external scheduler needed.
321 </div>
322 </div>
323 </div>
324 </section>
325
267326 <section id="shortcuts" style="margin-bottom: 32px">
268327 <h2 style="margin-bottom: 12px; font-size: 20px">
269328 Keyboard shortcuts
Modifiedsrc/routes/issues.tsx+128−5View fileUnifiedSplit
1919import { summariseReactions } from "../lib/reactions";
2020import { loadIssueTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
22import { liveCommentBannerScript } from "../lib/sse-client";
23import { triggerIssueTriage } from "../lib/issue-triage";
2224import { softAuth, requireAuth } from "../middleware/auth";
2325import type { AuthEnv } from "../middleware/auth";
2426import { requireRepoAccess } from "../middleware/repo-access";
264266 .set({ issueCount: resolved.repo.issueCount + 1 })
265267 .where(eq(repositories.id, resolved.repo.id));
266268
269 // Fire-and-forget AI triage. Posts a "## AI Triage" comment with
270 // suggested labels, priority, summary, and a possible-duplicate
271 // callout. Suggestions only — nothing applied automatically.
272 triggerIssueTriage({
273 ownerName,
274 repoName,
275 repositoryId: resolved.repo.id,
276 issueId: issue.id,
277 issueNumber: issue.number,
278 authorId: user.id,
279 title,
280 body: issueBody,
281 }).catch(() => {});
282
267283 return c.redirect(
268284 `/${ownerName}/${repoName}/issues/${issue.number}`
269285 );
334350 const canManage =
335351 user &&
336352 (user.id === resolved.owner.id || user.id === issue.authorId);
353 const info = c.req.query("info");
337354
338355 return c.html(
339356 <Layout
342359 >
343360 <RepoHeader owner={ownerName} repo={repoName} />
344361 <IssueNav owner={ownerName} repo={repoName} active="issues" />
362 <div
363 id="live-comment-banner"
364 class="alert"
365 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
366 >
367 <strong class="js-live-count">0</strong> new comment(s) —{" "}
368 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
369 reload to view
370 </a>
371 </div>
372 <script
373 dangerouslySetInnerHTML={{
374 __html: liveCommentBannerScript({
375 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
376 bannerElementId: "live-comment-banner",
377 }),
378 }}
379 />
345380 <div class="issue-detail">
381 {info && (
382 <div style="margin: 12px 0; padding: 10px 14px; border-radius: 6px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); color: var(--text); font-size: 14px">
383 {decodeURIComponent(info)}
384 </div>
385 )}
346386 <h2>
347387 {issue.title}{" "}
348388 <span style="color:var(--text-muted);font-weight:400">
417457 : "Reopen issue"}
418458 </button>
419459 )}
460 {canManage && issue.state === "open" && (
461 <button
462 type="submit"
463 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
464 formnovalidate
465 class="btn"
466 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
467 >
468 Re-run AI triage
469 </button>
470 )}
420471 </div>
421472 </form>
422473 </div>
461512
462513 if (!issue) return c.redirect(`/${ownerName}/${repoName}/issues`);
463514
464 await db.insert(issueComments).values({
465 issueId: issue.id,
466 authorId: user.id,
467 body: commentBody,
468 });
515 const [inserted] = await db
516 .insert(issueComments)
517 .values({
518 issueId: issue.id,
519 authorId: user.id,
520 body: commentBody,
521 })
522 .returning();
523
524 // Live update: nudge any browser tabs subscribed to this issue. Pure
525 // fanout — never blocks the redirect, never throws into the request.
526 if (inserted) {
527 try {
528 const { publish } = await import("../lib/sse");
529 publish(`repo:${resolved.repo.id}:issue:${issueNum}`, {
530 event: "issue-comment",
531 data: {
532 issueId: issue.id,
533 commentId: inserted.id,
534 authorId: user.id,
535 authorUsername: user.username,
536 },
537 });
538 } catch {
539 /* SSE is best-effort */
540 }
541 }
469542
470543 return c.redirect(
471544 `/${ownerName}/${repoName}/issues/${issueNum}`
550623 />
551624);
552625
626// Re-run AI triage on demand (e.g. after the issue body has been edited).
627// Bypasses ISSUE_TRIAGE_MARKER via { force: true }. Write-access only.
628issueRoutes.post(
629 "/:owner/:repo/issues/:number/ai-retriage",
630 softAuth,
631 requireAuth,
632 requireRepoAccess("write"),
633 async (c) => {
634 const { owner: ownerName, repo: repoName } = c.req.param();
635 const issueNum = parseInt(c.req.param("number"), 10);
636 const user = c.get("user")!;
637 const resolved = await resolveRepo(ownerName, repoName);
638 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
639
640 const [issue] = await db
641 .select()
642 .from(issues)
643 .where(
644 and(
645 eq(issues.repositoryId, resolved.repo.id),
646 eq(issues.number, issueNum)
647 )
648 )
649 .limit(1);
650 if (!issue) {
651 return c.redirect(`/${ownerName}/${repoName}/issues`);
652 }
653
654 triggerIssueTriage(
655 {
656 ownerName,
657 repoName,
658 repositoryId: resolved.repo.id,
659 issueId: issue.id,
660 issueNumber: issue.number,
661 authorId: user.id,
662 title: issue.title,
663 body: issue.body || "",
664 },
665 { force: true }
666 ).catch(() => {});
667
668 return c.redirect(
669 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
670 "AI re-triage queued. The new comment will appear in 10-30s; reload to see it."
671 )}`
672 );
673 }
674);
675
553676export default issueRoutes;
554677export { IssueNav };
Modifiedsrc/routes/live-events.ts+22−5View fileUnifiedSplit
2929
3030const app = new Hono<AuthEnv>();
3131
32const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+$/;
32/**
33 * Topic shape — `kind:id(:segment)*`. The first colon separates the kind
34 * (lowercase, used for the read-gate dispatch) from the id; subsequent
35 * colon-segments are scoping suffixes the publisher chose, e.g.
36 * `repo:<uuid>:issue:7`. Each segment is alphanumerics + dash so the
37 * URL path stays predictable.
38 */
39const TOPIC_RE = /^[a-z]+:[a-zA-Z0-9\-]+(?::[a-zA-Z0-9\-]+)*$/;
3340const HEARTBEAT_MS = 25_000;
3441
3542app.get("/live-events/:topic", softAuth, async (c) => {
3946 }
4047
4148 const user = c.get("user") ?? null;
42 const colon = topic.indexOf(":");
43 const kind = topic.slice(0, colon);
44 const id = topic.slice(colon + 1);
49 // Topic is `kind:primaryId(:scope)*`. Slice on the first two colons so a
50 // multi-segment topic like `repo:<uuid>:issue:7` resolves to
51 // kind = "repo", primaryId = "<uuid>"
52 // and the trailing `:issue:7` is treated as scoping that the publisher
53 // chose (the broadcaster is keyed on the full topic string, so the suffix
54 // is preserved across publish/subscribe).
55 const firstColon = topic.indexOf(":");
56 const secondColon = topic.indexOf(":", firstColon + 1);
57 const kind = topic.slice(0, firstColon);
58 const primaryId =
59 secondColon === -1
60 ? topic.slice(firstColon + 1)
61 : topic.slice(firstColon + 1, secondColon);
4562
4663 // For repo topics, gate on read access. Other topic kinds pass through.
4764 if (kind === "repo") {
4966 const [repo] = await db
5067 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
5168 .from(repositories)
52 .where(eq(repositories.id, id))
69 .where(eq(repositories.id, primaryId))
5370 .limit(1);
5471
5572 if (!repo) {
Addedsrc/routes/mcp.ts+83−0View fileUnifiedSplit
1/**
2 * Model Context Protocol HTTP transport.
3 *
4 * POST /mcp — JSON-RPC 2.0 requests; body is a single request
5 * or an array (batch). Response shape mirrors.
6 * GET /mcp — Lightweight discovery: returns server info +
7 * protocol version + tool count.
8 *
9 * Auth: softAuth — `userId` in the McpContext is the cookie/PAT/OAuth
10 * user when present, null otherwise. v1 tools are read-only and public-
11 * only, so anonymous works; write tools (v2) will require requireAuth +
12 * write-access on the target repo.
13 *
14 * Streamable-HTTP-mode is the recommended MCP transport for stateless
15 * cloud servers. We don't emit server-sent notifications yet, so the
16 * route is plain JSON in / JSON out.
17 */
18
19import { Hono } from "hono";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 routeMcpRequest,
24 MCP_PROTOCOL_VERSION,
25 MCP_SERVER_NAME,
26 MCP_SERVER_VERSION,
27} from "../lib/mcp";
28import { defaultTools } from "../lib/mcp-tools";
29
30const mcp = new Hono<AuthEnv>();
31
32mcp.use("*", softAuth);
33
34mcp.get("/mcp", (c) => {
35 const tools = defaultTools();
36 return c.json({
37 protocolVersion: MCP_PROTOCOL_VERSION,
38 serverInfo: { name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
39 transport: "http",
40 toolCount: Object.keys(tools).length,
41 docs:
42 "POST /mcp with a JSON-RPC 2.0 envelope to call. See https://spec.modelcontextprotocol.io/",
43 });
44});
45
46mcp.post("/mcp", async (c) => {
47 const user = c.get("user") ?? null;
48 const ctx = { userId: user?.id ?? null };
49 const tools = defaultTools();
50
51 let body: unknown;
52 try {
53 body = await c.req.json();
54 } catch {
55 return c.json(
56 {
57 jsonrpc: "2.0",
58 id: null,
59 error: { code: -32700, message: "Parse error" },
60 },
61 400
62 );
63 }
64
65 if (Array.isArray(body)) {
66 // Batched request — pass each through, drop nulls (notifications).
67 const out = await Promise.all(
68 body.map((entry) => routeMcpRequest(entry, { ctx, tools }))
69 );
70 const filtered = out.filter((r): r is NonNullable<typeof r> => r !== null);
71 if (filtered.length === 0) return c.body(null, 204);
72 return c.json(filtered);
73 }
74
75 const result = await routeMcpRequest(body, { ctx, tools });
76 if (result === null) {
77 // Notification — no response body, 204.
78 return c.body(null, 204);
79 }
80 return c.json(result);
81});
82
83export default mcp;
Modifiedsrc/routes/pulls.tsx+252−8View fileUnifiedSplit
1919import { summariseReactions } from "../lib/reactions";
2020import { loadPrTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
22import { liveCommentBannerScript } from "../lib/sse-client";
2223import { softAuth, requireAuth } from "../middleware/auth";
2324import type { AuthEnv } from "../middleware/auth";
2425import { requireRepoAccess } from "../middleware/repo-access";
2526import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
2627import { triggerPrTriage } from "../lib/pr-triage";
28import { generatePrSummary } from "../lib/ai-generators";
29import { isAiAvailable } from "../lib/ai-client";
30import { getRepoPath } from "../git/repository";
2731import { runAllGateChecks } from "../lib/gate";
2832import type { GateCheckResult } from "../lib/gate";
2933import {
6670
6771const pulls = new Hono<AuthEnv>();
6872
73/**
74 * Tiny inline JS that drives the "Suggest description with AI" button.
75 * On click, gathers form values, POSTs JSON to the given endpoint, and
76 * pipes the response into the #pr-body textarea. All DOM lookups are
77 * defensive — element absence is a silent no-op.
78 *
79 * Built as a string template so it lives next to its server-side caller
80 * and there is no bundler dependency. The endpoint URL is JSON-escaped
81 * to avoid </script> breakouts.
82 */
83function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
84 const url = JSON.stringify(endpointUrl)
85 .split("<").join("\\u003C")
86 .split(">").join("\\u003E")
87 .split("&").join("\\u0026");
88 return (
89 "(function(){try{" +
90 "var btn=document.getElementById('ai-suggest-desc');" +
91 "var status=document.getElementById('ai-suggest-status');" +
92 "var body=document.getElementById('pr-body');" +
93 "var form=btn&&btn.closest&&btn.closest('form');" +
94 "if(!btn||!body||!form)return;" +
95 "btn.addEventListener('click',function(ev){ev.preventDefault();" +
96 "var fd=new FormData(form);" +
97 "var title=String(fd.get('title')||'').trim();" +
98 "var base=String(fd.get('base')||'').trim();" +
99 "var head=String(fd.get('head')||'').trim();" +
100 "if(!base||!head){if(status)status.textContent='Pick base + head first.';return;}" +
101 "btn.disabled=true;if(status)status.textContent='Drafting (10-30s)...';" +
102 "fetch(" + url + ",{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'title='+encodeURIComponent(title)+'&base='+encodeURIComponent(base)+'&head='+encodeURIComponent(head),credentials:'same-origin'})" +
103 ".then(function(r){return r.json().catch(function(){return {ok:false,error:'Server error.'};});})" +
104 ".then(function(j){btn.disabled=false;" +
105 "if(j&&j.ok&&typeof j.body==='string'){if(body.value&&body.value.trim().length>0){if(!confirm('Replace existing description?')){if(status)status.textContent='Cancelled.';return;}}" +
106 "body.value=j.body;if(status)status.textContent='Filled from AI. Review before submitting.';" +
107 "}else{if(status)status.textContent=(j&&j.error)||'AI unavailable.';}" +
108 "}).catch(function(){btn.disabled=false;if(status)status.textContent='Network error.';});" +
109 "});" +
110 "}catch(e){}})();"
111 );
112}
113
69114async function resolveRepo(ownerName: string, repoName: string) {
70115 const [owner] = await db
71116 .select()
254299 <FormGroup>
255300 <TextArea
256301 name="body"
302 id="pr-body"
257303 rows={8}
258304 placeholder="Description (Markdown supported)"
259305 mono
260306 />
261307 </FormGroup>
262 <Button type="submit" variant="primary">
263 Create pull request
264 </Button>
308 <Flex gap={8} align="center">
309 <Button type="submit" variant="primary">
310 Create pull request
311 </Button>
312 <button
313 type="button"
314 id="ai-suggest-desc"
315 class="btn"
316 style="font-weight:500"
317 title="Generate a Markdown PR description using Claude based on the diff between the selected branches"
318 >
319 Suggest description with AI
320 </button>
321 <span
322 id="ai-suggest-status"
323 style="color:var(--text-muted);font-size:13px"
324 />
325 </Flex>
265326 </Form>
327 <script
328 dangerouslySetInnerHTML={{
329 __html: AI_PR_DESC_SCRIPT(`/${ownerName}/${repoName}/ai/pr-description`),
330 }}
331 />
266332 </Container>
267333 </Layout>
268334 );
269335 }
270336);
271337
338// AI-suggested PR description — JSON endpoint driven by the form button.
339// Returns {ok:true, body} on success, {ok:false, error} otherwise. Always
340// 200; the inline script reads `ok` to decide what to do.
341pulls.post(
342 "/:owner/:repo/ai/pr-description",
343 softAuth,
344 requireAuth,
345 requireRepoAccess("write"),
346 async (c) => {
347 const { owner: ownerName, repo: repoName } = c.req.param();
348 if (!isAiAvailable()) {
349 return c.json({
350 ok: false,
351 error: "AI is not available — set ANTHROPIC_API_KEY.",
352 });
353 }
354 const body = await c.req.parseBody();
355 const title = String(body.title || "").trim();
356 const baseBranch = String(body.base || "").trim();
357 const headBranch = String(body.head || "").trim();
358 if (!baseBranch || !headBranch) {
359 return c.json({ ok: false, error: "Pick base + head branches first." });
360 }
361 if (baseBranch === headBranch) {
362 return c.json({ ok: false, error: "Base and head must differ." });
363 }
364
365 let diff = "";
366 try {
367 const cwd = getRepoPath(ownerName, repoName);
368 const proc = Bun.spawn(
369 [
370 "git",
371 "diff",
372 `${baseBranch}...${headBranch}`,
373 "--",
374 ],
375 { cwd, stdout: "pipe", stderr: "pipe" }
376 );
377 diff = await new Response(proc.stdout).text();
378 await proc.exited;
379 } catch {
380 diff = "";
381 }
382 if (!diff.trim()) {
383 return c.json({
384 ok: false,
385 error: "No diff between branches — nothing to summarise.",
386 });
387 }
388
389 let summary = "";
390 try {
391 summary = await generatePrSummary(title || "(untitled)", diff);
392 } catch (err) {
393 const msg = err instanceof Error ? err.message : "AI request failed.";
394 return c.json({ ok: false, error: msg });
395 }
396 if (!summary.trim()) {
397 return c.json({ ok: false, error: "AI returned an empty draft." });
398 }
399 return c.json({ ok: true, body: summary });
400 }
401);
402
272403// Create PR
273404pulls.post(
274405 "/:owner/:repo/pulls/new",
390521 (user.id === resolved.owner.id || user.id === pr.authorId);
391522
392523 const error = c.req.query("error");
524 const info = c.req.query("info");
393525
394526 // Get gate check status for open PRs
395527 let gateChecks: GateCheckResult[] = [];
449581 >
450582 <RepoHeader owner={ownerName} repo={repoName} />
451583 <PrNav owner={ownerName} repo={repoName} active="pulls" />
584 <div
585 id="live-comment-banner"
586 class="alert"
587 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
588 >
589 <strong class="js-live-count">0</strong> new comment(s) —{" "}
590 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
591 reload to view
592 </a>
593 </div>
594 <script
595 dangerouslySetInnerHTML={{
596 __html: liveCommentBannerScript({
597 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
598 bannerElementId: "live-comment-banner",
599 }),
600 }}
601 />
452602 <div class="issue-detail">
453603 <h2>
454604 {pr.title}{" "}
535685 </div>
536686 )}
537687
688 {info && (
689 <div style="margin-top: 16px; padding: 12px; background: rgba(56, 139, 253, 0.1); border: 1px solid var(--accent); border-radius: var(--radius); color: var(--text)">
690 {decodeURIComponent(info)}
691 </div>
692 )}
693
538694 {pr.state === "open" && gateChecks.length > 0 && (
539695 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
540696 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
586742 >
587743 Merge pull request
588744 </button>
745 {isAiReviewEnabled() && (
746 <button
747 type="submit"
748 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ai-rereview`}
749 formnovalidate
750 class="btn"
751 title="Re-run AI review (e.g. after a force-push). Posts a fresh summary + inline comments."
752 >
753 Re-run AI review
754 </button>
755 )}
589756 <Button
590757 type="submit"
591758 variant="danger"
639806
640807 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
641808
642 await db.insert(prComments).values({
643 pullRequestId: pr.id,
644 authorId: user.id,
645 body: commentBody,
646 });
809 const [inserted] = await db
810 .insert(prComments)
811 .values({
812 pullRequestId: pr.id,
813 authorId: user.id,
814 body: commentBody,
815 })
816 .returning();
817
818 // Live update: nudge any browser tabs subscribed to this PR.
819 if (inserted) {
820 try {
821 const { publish } = await import("../lib/sse");
822 publish(`repo:${resolved.repo.id}:pr:${prNum}`, {
823 event: "pr-comment",
824 data: {
825 pullRequestId: pr.id,
826 commentId: inserted.id,
827 authorId: user.id,
828 authorUsername: user.username,
829 },
830 });
831 } catch {
832 /* SSE is best-effort */
833 }
834 }
647835
648836 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
649837 }
9961184 }
9971185);
9981186
1187// Re-run AI review on demand (e.g. after a force-push). Bypasses the
1188// idempotency marker via { force: true }. Write-access only.
1189pulls.post(
1190 "/:owner/:repo/pulls/:number/ai-rereview",
1191 softAuth,
1192 requireAuth,
1193 requireRepoAccess("write"),
1194 async (c) => {
1195 const { owner: ownerName, repo: repoName } = c.req.param();
1196 const prNum = parseInt(c.req.param("number"), 10);
1197 const resolved = await resolveRepo(ownerName, repoName);
1198 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1199
1200 const [pr] = await db
1201 .select()
1202 .from(pullRequests)
1203 .where(
1204 and(
1205 eq(pullRequests.repositoryId, resolved.repo.id),
1206 eq(pullRequests.number, prNum)
1207 )
1208 )
1209 .limit(1);
1210 if (!pr) {
1211 return c.redirect(`/${ownerName}/${repoName}/pulls`);
1212 }
1213
1214 if (!isAiReviewEnabled()) {
1215 return c.redirect(
1216 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
1217 "AI review is not configured (ANTHROPIC_API_KEY)."
1218 )}`
1219 );
1220 }
1221
1222 // Fire-and-forget but with { force: true } to bypass the
1223 // already-reviewed marker. The function still never throws.
1224 triggerAiReview(
1225 ownerName,
1226 repoName,
1227 pr.id,
1228 pr.title || "",
1229 pr.body || "",
1230 pr.baseBranch,
1231 pr.headBranch,
1232 { force: true }
1233 ).catch(() => {});
1234
1235 return c.redirect(
1236 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
1237 "AI re-review queued. The new comment will appear in 10-30s; reload to see it."
1238 )}`
1239 );
1240 }
1241);
1242
9991243export default pulls;
Modifiedsrc/views/components.tsx+7−0View fileUnifiedSplit
181181 >
182182 {"\u2728"} Spec
183183 </a>
184 <a
185 href={`/${owner}/${repo}/ai/tests`}
186 style="color: #bc8cff"
187 title="AI Tests \u2014 generate failing test stubs from a source file"
188 >
189 {"\u2728"} Tests
190 </a>
184191 </div>
185192);
186193
187194