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

Merge branch 'main' into claude/github-audit-improvements-bDFr9

Dictation App committed on April 18, 2026Parents: 2e9136d 483c9eb
223 files changed+519333224e3f5953ce3b63037c5b23572c89888143ac95ae
223 changed files+51933−322
Added.dockerignore+49−0View fileUnifiedSplit
1# Version control
2.git
3.gitignore
4.gitattributes
5
6# Dependencies (reinstalled in Docker)
7node_modules
8
9# Bare git repositories (runtime data, not source)
10repos
11
12# Environment / secrets
13.env
14.env.*
15!.env.example
16
17# Build / generated output
18dist
19build
20*.js.map
21
22# Documentation
23*.md
24LICENSE
25
26# Tests
27src/__tests__
28**/*.test.ts
29**/*.spec.ts
30
31# Local tooling config
32drizzle.config.ts
33tsconfig.json
34.editorconfig
35.eslintrc*
36.prettierrc*
37biome.json
38
39# Docker files themselves
40Dockerfile
41.dockerignore
42
43# OS / editor artifacts
44.DS_Store
45Thumbs.db
46.idea
47.vscode
48*.swp
49*.swo
Modified.env.example+14−0View fileUnifiedSplit
22GIT_REPOS_PATH=./repos
33PORT=3000
44GATETEST_URL=https://gatetest.ai/api/scan/run
5GATETEST_API_KEY=
6# Inbound GateTest callback auth — set one so GateTest can POST results back.
7# See GATETEST_HOOK.md for payload + endpoint details.
8# Generate a secret with: openssl rand -hex 32
9GATETEST_CALLBACK_SECRET=
10GATETEST_HMAC_SECRET=
511CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
12ANTHROPIC_API_KEY=
13# Email (Block A8). Provider=log just writes to stderr (safe default).
14# Switch to "resend" in prod and set RESEND_API_KEY.
15EMAIL_PROVIDER=log
16EMAIL_FROM=gluecron <no-reply@gluecron.local>
17RESEND_API_KEY=
18# Used to build absolute URLs in outbound emails + webhooks.
19APP_BASE_URL=http://localhost:3000
AddedBUILD_BIBLE.md+557−0View fileUnifiedSplit
1# GLUECRON BUILD BIBLE
2
3**This file is the single source of truth for the GlueCron build.**
4
5**Every Claude agent MUST read this file in full before touching code. No exceptions.**
6
7GlueCron is a GitHub replacement — AI-native code intelligence, green ecosystem enforcement, git hosting, automated CI. It is production infrastructure for multiple downstream platforms. Production cannot stop.
8
9---
10
11## 1. AGENT POLICY (READ FIRST, FOLLOW ALWAYS)
12
13### 1.1 Required reads at session start
141. `BUILD_BIBLE.md` (this file) — complete
152. `CLAUDE.md` — stack + architecture
163. `README.md` — user-facing overview
174. Most recent commit on the current branch (`git log -1 --stat`)
18
19### 1.2 Do-not-undo rule
20- Anything listed in **§4 LOCKED BLOCKS** is shipped and must not be deleted, renamed, or semantically altered without the owner's explicit written permission in the current session.
21- "Refactor" is not permission. "Clean up" is not permission. "Simplify" is not permission.
22- If a locked file seems wrong, open an issue in the plan and keep going on a new block.
23
24### 1.3 Continuous-build rule
25- The owner runs many parallel projects. Do not stop work to ask for clarification that can be inferred from this file.
26- Default behaviour when a block is partially complete: **finish it, run tests, commit, push, start the next block**.
27- Only stop for genuinely blocking decisions: destructive operations, architectural reversals, requests outside this plan, or repeated test failures you can't diagnose.
28- Never stop because "the session might run out." Commit what works and keep building.
29
30### 1.4 Branch + commit rules
31- Development branch: whatever the current session was told (check session opening message). Fall back to `main` if none given.
32- One commit per completed block. Message format: `feat(BLOCK-ID): <summary>`.
33- Push after every commit with `git push -u origin <branch>`.
34- Never force-push. Never `--no-verify`. Never amend published commits.
35
36### 1.5 Quality bars (non-negotiable)
37- `bun test` must pass before every commit.
38- New features ship with tests in `src/__tests__/`.
39- New routes use `softAuth` or `requireAuth` middleware.
40- New DB tables have a corresponding migration in `drizzle/`.
41- AI features use `isAiAvailable()` guards and degrade gracefully without `ANTHROPIC_API_KEY`.
42- Every user-facing failure mode has a fallback — no 500s reach the UI.
43
44### 1.6 Green-ecosystem-by-default
45- Every new repo auto-configures: gates on, branch protection on, labels seeded, CODEOWNERS synced, welcome issue posted.
46- Users can opt out per feature but defaults are maximum-green.
47- Nothing broken ever reaches production, the website, or the customer.
48
49### 1.7 Parallelism rule (added per owner request)
50- **Default to spawning sub-agents whenever work can be parallelised.** Owner-cost of an idle main thread is high; owner-cost of an extra agent is near-zero.
51- Independent files = parallel agents. Schema-only edits, new route files, doc updates, test additions, codebase research — all of these run in parallel by default unless they collide.
52- Coordinate file ownership: one agent per file. Never let two agents edit the same file. Mounting + middleware integration stay on the main thread to avoid merge conflicts.
53- When launching multiple agents, send them in a single message with multiple Agent tool calls so they actually run concurrently.
54- The main thread is responsible for: reviewing each agent's output before integrating, running the test suite, and committing. Trust-but-verify — read the changes, don't just rely on the agent's summary.
55
56---
57
58## 2. GITHUB PARITY SCORECARD
59
60Legend: ✅ shipped · 🟡 partial · ❌ not built
61
62### 2.1 Repository hosting
63| Feature | Status | Notes |
64|---|---|---|
65| Git Smart HTTP (clone / push / fetch) | ✅ | `src/routes/git.ts`, `src/git/protocol.ts` |
66| SSH keys | ✅ | `ssh_keys` table, `src/routes/settings.tsx` |
67| Public / private visibility | ✅ | `repositories.isPrivate` |
68| Forking | ✅ | `src/routes/fork.ts` |
69| Stars | ✅ | `stars` table, `/:owner/:repo/star` |
70| Topics | ✅ | `repo_topics` table |
71| Archive / disable repo | ✅ | I1 — `src/routes/repo-settings.tsx` archive toggle; `RepoHeader` renders an "Archived" badge when `is_archived=true`. |
72| Repository transfer | ✅ | I3 — `src/routes/repo-settings.tsx` transfer form + `POST /:owner/:repo/settings/transfer`; ownership change recorded in `repo_transfers` audit table. Reject conflicts (target owner already has a repo by that name) with a redirect. |
73| Template repositories | ✅ | I2 — `drizzle/0022_repo_templates.sql` adds `is_template`. `src/routes/templates.ts` serves `POST /:owner/:repo/use-template` (git clone --bare into caller's namespace). "Use this template" CTA rendered on the public repo page. |
74| Repository mirroring | ✅ | I9 — pull-style mirror of an upstream git URL. `drizzle/0026_repo_mirrors.sql` adds `repo_mirrors` (one-per-repo config) + `repo_mirror_runs` (audit log). `src/lib/mirrors.ts` validates URLs (https/http/git only, rejects ssh/file/shell metacharacters), runs `git fetch --prune --tags` via `Bun.spawn` with a 5-min timeout + `GIT_TERMINAL_PROMPT=0`. `src/routes/mirrors.tsx` exposes `/:owner/:repo/settings/mirror` + `/admin/mirrors/sync-all`. |
75
76### 2.2 Code browsing
77| Feature | Status | Notes |
78|---|---|---|
79| File tree browser | ✅ | `src/routes/web.tsx` |
80| Syntax highlighting | ✅ | 40+ languages, `src/lib/highlight.ts` |
81| Commit history | ✅ | |
82| Diffs | ✅ | |
83| Blame | ✅ | |
84| Raw file download | ✅ | |
85| Branch switcher | ✅ | |
86| Tag listing | ✅ | new this build |
87| Code search (ILIKE) | ✅ | per-repo + global |
88| Semantic / embedding search | ✅ | D1 — `code_chunks` table + lexical fallback, optional Voyage `voyage-code-3`; `src/lib/semantic-search.ts`, `src/routes/semantic-search.tsx` |
89| Symbol / xref navigation | ✅ | I8 — `src/lib/symbols.ts` regex-based extractor for ts/js/py/rs/go/rb/java/kt/swift; on-demand indexer persists top-level definitions into `code_symbols` (0025). `src/routes/symbols.tsx` serves `/:owner/:repo/symbols` overview + A–Z list, `/:owner/:repo/symbols/search?q=` prefix search, `/:owner/:repo/symbols/:name` definition detail. Owner-only reindex. |
90| 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`. |
91| 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`. |
92| 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`. |
94| 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`. |
95
96### 2.3 Collaboration
97| Feature | Status | Notes |
98|---|---|---|
99| Issues (CRUD / comments / labels / close) | ✅ | |
100| Milestones | ✅ | `src/routes/insights.tsx` |
101| Pull requests (CRUD / review / merge) | ✅ | |
102| PR inline comments | ✅ | file+line anchored |
103| Draft PRs | ✅ | create as draft, ready-for-review toggle, dedicated tab, merge blocked until ready |
104| Reactions (emoji) | ✅ | 8 reactions, toggle via `POST /api/reactions/:t/:id/:emoji/toggle` on issues + PRs + comments |
105| Mentions + notifications | ✅ | `src/routes/notifications.tsx` |
106| Code owners | ✅ | `src/lib/codeowners.ts` |
107| Issue templates | ✅ | `.github/ISSUE_TEMPLATE.md` auto-prefills new issues; frontmatter stripped; `src/lib/templates.ts` |
108| PR templates | ✅ | `.github/PULL_REQUEST_TEMPLATE.md` auto-prefills new PRs; `src/lib/templates.ts` |
109| Saved replies | ✅ | per-user canned comments, unique-shortcut, `/settings/replies`, `/api/user/replies` |
110| Discussions / forums | ✅ | E2 — categorised threads, pinned/locked, q-and-a answers. `src/routes/discussions.tsx` + `drizzle/0013_discussions.sql` |
111| Wikis | ✅ | E3 — markdown pages per repo with revision history + revert. DB-backed v1. `src/routes/wikis.tsx` + `drizzle/0016_wikis.sql` |
112| Projects / kanban | ✅ | E1 — per-repo boards with auto-seeded To Do/In Progress/Done columns. Notes or linked issues/PRs. `src/routes/projects.tsx` + `drizzle/0015_projects.sql` |
113| AI incident responder | ✅ | D4 — auto-issues on deploy fail, `src/lib/ai-incident.ts` |
114| AI-generated test stubs | ✅ | D8 — `src/lib/ai-tests.ts`, `/:owner/:repo/ai/tests` |
115
116### 2.4 Automation + AI
117| Feature | Status | Notes |
118|---|---|---|
119| Webhooks (outbound, HMAC signed) | ✅ | `src/routes/webhooks.tsx` |
120| GateTest inbound callback | ✅ | `POST /api/hooks/gatetest`, bearer or HMAC |
121| Backup PAT-auth gate ingest | ✅ | `POST /api/v1/gate-runs` |
122| Gate runs (test / secret / AI review) | ✅ | `gate_runs` table, `src/routes/gates.tsx` |
123| Branch protection | ✅ | `branch_protection` table + UI |
124| Auto-repair engine | ✅ | `src/lib/auto-repair.ts` |
125| Secret scanner | ✅ | 15 patterns, `src/lib/security-scan.ts` |
126| AI security review | ✅ | Sonnet 4, `src/lib/security-scan.ts` |
127| AI commit messages | ✅ | `src/lib/ai-generators.ts` |
128| AI PR summaries | ✅ | |
129| 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` |
131| AI merge conflict resolver | ✅ | `src/lib/merge-resolver.ts` |
132| AI chat (global + repo) | ✅ | `src/routes/ask.tsx` |
133| 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` |
135| 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| 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`. |
137| 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. |
138| 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. |
139| Semantic code search | ✅ | D1 — see 2.2 |
140
141### 2.5 Platform
142| Feature | Status | Notes |
143|---|---|---|
144| Dashboard | ✅ | `src/routes/dashboard.tsx` |
145| Explore / discover | ✅ | |
146| Global search | ✅ | repos / users / issues / PRs |
147| Insights (graph, contributors, green rate) | ✅ | `src/routes/insights.tsx` |
148| Releases + tags | ✅ | AI changelog |
149| Personal access tokens | ✅ | SHA-256 hashed |
150| OAuth app provider | ✅ | `src/routes/oauth.tsx`, `src/routes/developer-apps.tsx`, `src/lib/oauth.ts`; `oauth_apps` + `oauth_authorizations` + `oauth_access_tokens` tables |
151| GitHub Apps equivalent | ✅ | H2 — `src/lib/marketplace.ts` `generateBearerToken`/`verifyInstallToken` (1h TTL, `ghi_` prefix, sha256 hashed). Each app gets a `<slug>[bot]` identity (`app_bots`). Permissions enforced via `hasPermission` (write implies read). |
152| GraphQL API | ✅ | G2 — see 2.6 |
153| Organizations + teams | ✅ | B1+B2+B3 shipped: `src/routes/orgs.tsx`, `src/lib/orgs.ts`; org-owned repos (`repositories.orgId`); team-based CODEOWNERS (`@org/team` resolution) |
154| Enterprise SAML / SSO | ✅ | I10 — OIDC (Okta / Azure AD / Auth0 / Google Workspace). `src/lib/sso.ts` + `src/routes/sso.tsx`, `drizzle/0027_sso_oidc.sql` (tables `sso_config` singleton + `sso_user_links`). Admin config at `/admin/sso`; `/login/sso` starts auth-code flow with state+nonce cookies; `/login/sso/callback` exchanges code, fetches userinfo, links by `sub` (or by email, or auto-creates). Optional email-domain allow-list + auto-create toggle. |
155| 2FA / TOTP | ✅ | `src/routes/settings-2fa.tsx`, `src/lib/totp.ts`; `user_totp` + `user_recovery_codes` tables |
156| Passkeys / WebAuthn | ✅ | `src/routes/passkeys.tsx`, `src/lib/webauthn.ts`; `user_passkeys` + `webauthn_challenges` tables |
157| Packages registry (npm / docker / etc) | ✅ | `src/lib/packages.ts`, `src/routes/packages-api.ts`, `src/routes/packages.tsx`; npm protocol (packument, tarball, publish, yank); PAT (`glc_`) auth via Authorization header; container registry deferred |
158| Pages / static hosting | ✅ | `src/lib/pages.ts`, `src/routes/pages.tsx`; serves blobs from bare git at latest `gh-pages` commit; per-repo settings (source branch/dir, custom domain); short-cache headers |
159| Gists | ✅ | E4 — multi-file tiny repos with per-revision JSON snapshots + stars. `src/routes/gists.tsx` + `drizzle/0014_gists.sql` |
160| Sponsors | ✅ | I6 — `src/routes/sponsors.tsx`, `drizzle/0023_sponsors.sql` (tables `sponsorship_tiers`, `sponsorships`). Public `/sponsors/:user` page with tier cards + recent public sponsors; maintainer view at `/settings/sponsors` with add/retire tiers. Payment rails deferred — captures intent + thank-you notes. |
161| Marketplace | ✅ | H1 — `src/routes/marketplace.tsx` + `src/lib/marketplace.ts`, `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Public `/marketplace` directory, `/marketplace/:slug` detail + install, `/settings/apps` personal installs, `/developer/apps-new` registration, `/developer/apps/:slug/manage` event log + token issuance. |
162| 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` |
163| 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. |
164| 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. |
165| 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). |
166
167### 2.6 Observability + safety
168| Feature | Status | Notes |
169|---|---|---|
170| Rate limiting | ✅ | `src/middleware/rate-limit.ts` |
171| Request-ID tracing | ✅ | `src/middleware/request-context.ts` |
172| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
173| Audit log (table) | ✅ | `audit_log` table |
174| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
175| Traffic analytics per repo | ✅ | F1 — `src/lib/traffic.ts` + `src/routes/traffic.tsx`, `drizzle/0020_analytics_and_admin.sql`; owner-only 7/14/30/90d windows, ascii-bar daily chart. SHA-256-truncated IP for unique visitors. Fire-and-forget wiring in `web.tsx` + `git.ts`. |
176| Org insights dashboard | ✅ | F2 — `src/routes/org-insights.tsx`; `computeOrgInsights(orgId)` rollup of gate green-rate + PR/issue counts + per-repo breakdown. `GET /orgs/:slug/insights`. |
177| Site admin panel | ✅ | F3 — `src/lib/admin.ts` + `src/routes/admin.tsx`, tables `site_admins` + `system_flags`. Bootstrap rule (oldest user wins until `site_admins` populated). Flags: registration_locked, site_banner_*, read_only_mode. |
178| Billing + quotas | ✅ | F4 — `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` seeded free/pro/team/enterprise. `/settings/billing` personal view + `/admin/billing` site-admin override. |
179| Email notifications | ✅ | opt-in per kind (mention/assign/gate-fail) via `/settings`; provider-pluggable `src/lib/email.ts` (log default, resend in prod) |
180| Email digest | ✅ | I7 — `src/lib/email-digest.ts` + `drizzle/0024_email_digest.sql` (`users.notify_email_digest_weekly` + `last_digest_sent_at`). `composeDigest` pulls notifications + failed/repaired gates + merged PRs over last 7d, renders text + escaped HTML. `/settings/digest/preview` for self-preview; `/admin/digests` dashboard + `POST /admin/digests/run` fires `sendDigestsToAll`; `POST /admin/digests/preview` sends to one user. Never throws. |
181| Mobile PWA | ✅ | G1 — `src/routes/pwa.ts` serves `/manifest.webmanifest` + `/sw.js` + `/icon.svg`; Layout injects manifest link + SW registration. Offline-capable (network-first for HTML). |
182| GraphQL API | ✅ | G2 — `src/lib/graphql.ts` parser + executor, `src/routes/graphql.ts` endpoint at `POST /api/graphql`, GraphiQL-lite explorer at `GET /api/graphql`. Queries only (viewer/user/repository/search/rateLimit). |
183| Official CLI | ✅ | G3 — `cli/gluecron.ts` Bun-compilable single binary. REST + GraphQL client, `~/.gluecron/config.json` 0600. |
184| VS Code extension | ✅ | G4 — `vscode-extension/` with commands for explain / open-on-web / semantic search / generate tests. |
185| Native mobile apps | ❌ | |
186| Dark mode | ✅ | default |
187| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
188| Keyboard shortcuts | ✅ | `/shortcuts` page |
189| Command palette | ✅ | I4 — `src/views/layout.tsx` injects a Cmd+K palette with ~20 canonical destinations, arrow-key navigation + fuzzy match. Backdrop click or Esc closes. |
190
191---
192
193## 3. BUILD PLAN (BLOCKS)
194
195Each block is a self-contained unit. Order matters for dependencies. Each block ends with tests + commit + push.
196
197### BLOCK A — Hardening the current surface
198Polish what's shipped before adding more. **Priority: do this first if parity gaps are minor.**
199- **A1** — Dark/light theme toggle (cookie, CSS variable swap) ✅
200- **A2** — Audit log UI page (`/settings/audit` + `/:owner/:repo/settings/audit`) ✅
201- **A3** — Reactions UI on issues / PRs / comments (data exists) ✅
202- **A4** — Draft PR toggle + filter ✅
203- **A5** — Issue + PR templates (`.github/*_TEMPLATE.md` auto-prefill) ✅
204- **A6** — Saved replies per user ✅
205- **A7** — Environments + deployment history UI (`deployments` table) ✅
206- **A8** — Email notifications (opt-in, provider-pluggable) ✅
207
208**BLOCK A COMPLETE.** Next: BLOCK B (Identity + orgs).
209
210### BLOCK B — Identity + orgs
211- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`) → ✅ shipped (`6563f0a`)
212 - Helpers in `src/lib/orgs.ts`: slug validation, role rank, reserved-slug set, loaders
213 - Routes in `src/routes/orgs.tsx`: list / create / profile / people / teams / team detail
214 - Role-based guards: admin adds members, owner grants owner, last-owner demote/remove blocked
215 - All sensitive actions `audit()`'d (org.create, member.add/role/remove, team.create, team.member.add/remove)
216- **B2** — Repos owned by orgs (nullable `repositories.orgId`) → ✅ shipped (`7437605`)
217- **B3** — Team-based CODEOWNERS (`@org/team` resolution) → ✅ shipped (`40d3e3f`)
218- **B4** — 2FA / TOTP (enroll, recovery codes) → ✅ shipped (`7298a17`)
219- **B5** — WebAuthn / passkeys → ✅ shipped (`2df1f8c`)
220- **B6** — OAuth 2.0 provider (third-party apps can request access) → ✅ shipped (pending final commit)
221
222### BLOCK C — Runtime + hosting
223- **C1** — Actions-equivalent workflow runner → ✅ shipped (`eafe8c6`)
224 - Workflow YAML parser (`src/lib/workflow-parser.ts`) — hand-rolled subset
225 - Background worker (`src/lib/workflow-runner.ts`) — Bun.spawn, size-capped logs, SIGTERM→SIGKILL timeouts
226 - Auto-discovery from `.gluecron/workflows/*.yml` on default-branch push
227 - UI at `/:owner/:repo/actions` with manual trigger + cancel
228- **C2** — Package registry (npm protocol) → ✅ shipped
229 - Packument + tarball + publish + yank via `PUT /npm/<name>` + `GET /npm/<name>`
230 - PAT (`glc_`) bearer auth for CLI clients; add `//host/npm/:_authToken=<PAT>` to .npmrc
231 - Container registry deferred (schema ready for it)
232- **C3** — Pages / static hosting → ✅ shipped
233 - Serves `/:owner/:repo/pages/*` from the latest successful `pages_deployments` row
234 - Auto-records on push to the repo's configured source branch (default `gh-pages`)
235 - Settings UI at `/:owner/:repo/settings/pages` + manual redeploy
236- **C4** — Environments with protected approvals → ✅ shipped
237 - Per-repo `environments` with reviewer list + branch-glob allowlist
238 - Auto-deploy on main is gated by `requiresApprovalFor()`; pending rows show status `pending_approval`
239 - Approve/reject at `POST /:owner/:repo/deployments/:id/approve|reject`
240
241### BLOCK D — AI-native differentiation
242This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
243- **D1** — Semantic code search → ✅ shipped. `src/lib/semantic-search.ts` + `src/routes/semantic-search.tsx`. `code_chunks` table stores chunk embeddings as JSON (upgrade path to `pgvector`). Embedding provider: Voyage AI `voyage-code-3` when `VOYAGE_API_KEY` is set, otherwise deterministic 512-dim hashing fallback. Index via `POST /:owner/:repo/search/semantic/reindex` (owner-only).
244- **D2** — AI dependency updater → ✅ shipped. `src/lib/dep-updater.ts` + `src/routes/dep-updater.tsx`. `dep_update_runs` table tracks run history. Parses `package.json`, queries `registry.npmjs.org`, plans bumps (skips workspace/github specs + downgrades), writes an `gluecron/dep-update-<ts>` branch via git plumbing (`hash-object` + `mktree` + `commit-tree` + `update-ref`), inserts a pull_requests row with a markdown bump table. Settings UI at `/:owner/:repo/settings/dep-updater` with "Run now".
245- **D3** — AI PR triage → ✅ shipped. `triagePullRequest` in `src/lib/ai-generators.ts`; hooked into PR create in `src/routes/pulls.tsx` (fire-and-forget). Posts a non-applied "## AI Triage" comment with suggested labels, reviewers, priority, and risk area. Suggestions only — PR author stays in control.
246- **D4** — AI incident responder → ✅ shipped. `src/lib/ai-incident.ts` exports `onDeployFailure(args)` — on deploy-fail hooks, samples ~10 recent commits, calls Sonnet 4 for a structured root-cause JSON, opens an issue (number via `serial`), best-effort attaches `incident` label, sets `deployments.blockedReason="auto-issue #N"`. Wired from `src/hooks/post-receive.ts triggerCrontechDeploy` (fire-and-forget) and from `POST /:owner/:repo/deployments/:id/retry-incident` (owner-only re-run button on the deployment detail page). Never throws; degrades to deterministic body when no `ANTHROPIC_API_KEY`.
247- **D5** — AI code reviewer blocks merges → ✅ shipped. `src/lib/branch-protection.ts` exports `matchProtection(repoId, branch)` (exact > glob, reuses `matchGlob` from environments.ts), `evaluateProtection(rule, ctx)` pure decision helper (checks `requireAiApproval` / `requireGreenGates` / `requireHumanReview` / `requiredApprovals`), and `countHumanApprovals(prId)` (LGTM/`+1`/approved heuristic on non-AI PR comments). Wired into `src/routes/pulls.tsx` merge handler after the existing hard-gate filter — blocks merge with readable reasons when rule fails. 8 unit tests in `src/__tests__/branch-protection.test.ts`.
248- **D6** — AI "explain this codebase" → ✅ shipped. `src/lib/ai-explain.ts` + `src/routes/ai-explain.tsx`. Samples up to ~25 representative files (~60KB cap), generates a Markdown explanation via Sonnet 4, caches per (repo, commit sha) in `codebase_explanations`. `GET /:owner/:repo/explain` + owner-only `POST /:owner/:repo/explain/regenerate`. Explain link added to `RepoNav`.
249- **D7** — AI changelog for every commit range → ✅ shipped. `src/routes/ai-changelog.tsx`. `GET /:owner/:repo/ai/changelog?from=&to=(&format=markdown)` — runs `git log` on the range, calls existing `generateChangelog`, renders form + rendered Markdown + copy-box; `format=markdown` returns `text/markdown` for CLI/CI consumers. Caps at 500 commits.
250- **D8** — AI-generated test suite → ✅ shipped. `src/lib/ai-tests.ts` exports `detectLanguage(path)`, `detectTestFramework(repo tree)`, `buildTestsPrompt(...)`, `suggestedTestPath(...)`, `generateTestStub({path, content, framework, language})` (returns `{code:"", framework:"fallback"}` when AI unavailable), `contentTypeFor(path)`. Route `src/routes/ai-tests.tsx` adds `GET /:owner/:repo/ai/tests` (form + file picker), `GET /:owner/:repo/ai/tests?format=raw` (raw text with correct MIME), `POST /:owner/:repo/ai/tests/generate` (requireAuth, renders highlighted source + generated failing test, copy button). Stubs are intentionally failing so the author fills them in.
251- **D9** — Copilot-style completion endpoint → ✅ shipped. `src/lib/ai-completion.ts` + `src/routes/copilot.ts`. `POST /api/copilot/completions` (requireAuth accepts PAT/OAuth/session), `GET /api/copilot/ping`. Claude Haiku; in-memory LRU (size 200, 5-min TTL); code-fence stripping; 60/min rate limit per caller.
252
253### BLOCK E — Collaboration parity
254- **E1** — Projects / kanban boards → ✅ shipped. `src/routes/projects.tsx`, tables `projects`/`project_columns`/`project_items` (migration 0015). Create creates three default columns (To Do/In Progress/Done); cards carry note or issue/pr link; one-click move between columns; owner-only close.
255- **E2** — Discussions (forum threads per repo) → ✅ shipped. `src/routes/discussions.tsx`, tables `discussions`/`discussion_comments` (migration 0013). Categorised (general/q-and-a/ideas/announcements/show-and-tell), pinnable, lockable, q-and-a answers.
256- **E3** — Wikis → ✅ shipped as DB-backed v1. `src/routes/wikis.tsx`, tables `wiki_pages`/`wiki_revisions` (migration 0016). Slug auto-derived; every edit bumps revision + appends a revision row; owner can revert. Git-backed mirror deferred.
257- **E4** — Gists → ✅ shipped. `src/routes/gists.tsx`, tables `gists`/`gist_files`/`gist_revisions`/`gist_stars` (migration 0014). Multi-file; each edit takes a JSON snapshot into `gist_revisions` keyed on revision number; stars toggle; secret gists hidden from non-owners.
258- **E5** — Merge queues → ✅ shipped. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, table `merge_queue_entries` (migration 0017). Per `(repo, base_branch)` FIFO queue; `POST /:owner/:repo/pulls/:n/enqueue` adds from the PR page; owner-only `POST /queue/process-next` re-runs gates against latest base before merging the head. Entries have queued | running | merged | failed | dequeued states.
259- **E6** — Required status checks matrix → ✅ shipped. `src/routes/required-checks.tsx`, table `branch_required_checks` (migration 0018); helpers `listRequiredChecks` + `passingCheckNames` in `src/lib/branch-protection.ts`. Settings UI at `/:owner/:repo/gates/protection/:id/checks`; merge handler (`src/routes/pulls.tsx`) loads required names + computes passing set from `gate_runs` (passed/repaired) + `workflow_runs` (success) and blocks if any required name is missing.
260- **E7** — Protected tags → ✅ shipped. `src/lib/protected-tags.ts`, `src/routes/protected-tags.tsx`, table `protected_tags` (migration 0019). Settings CRUD at `/:owner/:repo/settings/protected-tags`; patterns use same glob syntax as branch protection. v1 enforcement is advisory: post-receive logs audit entries (`protected_tags.{create|update|delete}_violation_candidate`) so owners can see violations; pre-receive blocking is future work.
261
262### BLOCK F — Observability + admin
263- **F1** — Traffic analytics per repo → ✅ shipped. `src/lib/traffic.ts` + `src/routes/traffic.tsx`, table `repo_traffic_events` (migration 0020). `track`/`trackView`/`trackClone`/`trackByName` are fire-and-forget; SHA-256 of IP truncated to 16 chars for unique-visitor approximation. Owner-only `GET /:owner/:repo/traffic` renders 7/14/30/90 day windows with an ascii-bar daily chart. Wired into `src/routes/web.tsx` repo overview + `src/routes/git.ts` git-upload-pack handler.
264- **F2** — Org-wide insights → ✅ shipped. `src/routes/org-insights.tsx` exports `computeOrgInsights(orgId)`. `GET /orgs/:slug/insights` requires org membership; aggregates gate green-rate, open/merged PR counts, open issue count, and per-repo rows sorted by activity. No new tables — live rollup across existing `repositories`, `gate_runs`, `pull_requests`, `issues`.
265- **F3** — Admin / superuser panel → ✅ shipped. `src/lib/admin.ts` + `src/routes/admin.tsx`, tables `site_admins` + `system_flags` (migration 0020). `isSiteAdmin(userId)` with bootstrap rule (empty `site_admins` table → oldest user wins); `KNOWN_FLAGS` = { registration_locked, site_banner_text, site_banner_level, read_only_mode }. Routes: `GET /admin` (dashboard), `GET /admin/users` + toggle grant/revoke, `GET /admin/repos` + nuclear delete, `GET /admin/flags` + save. All mutations audit-logged.
266- **F4** — Billing + quotas → ✅ shipped. `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` (migration 0020, seeded with free/pro/team/enterprise). `FALLBACK_PLANS` mirror the seeds so billing works pre-migration. Helpers: `getUserQuota` (auto-initialises free row on first read), `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`. Routes: `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin plan override), `POST /admin/billing/:userId/plan`.
267
268### BLOCK G — Mobile + client
269- **G1** — PWA manifest + service worker → ✅ shipped. `src/routes/pwa.ts` serves `/manifest.webmanifest`, `/sw.js`, `/icon.svg`; `Layout` injects `<link rel="manifest">` + a tiny SW registration script. Service worker is network-first for HTML + skips `.git/`/`/api/`/`/login*` routes.
270- **G2** — GraphQL API mirror of REST → ✅ shipped. `src/lib/graphql.ts` is a dependency-free recursive-descent parser + executor over a fixed schema (viewer, user, repository, search, rateLimit). `src/routes/graphql.ts` serves `POST /api/graphql` + a GraphiQL-lite explorer at `GET /api/graphql`. Queries only; writes stay on REST.
271- **G3** — Official CLI (`gluecron`) → ✅ shipped. `cli/gluecron.ts` is a Bun-compilable single-file CLI. Commands: `login`, `whoami`, `repo ls/show/create`, `issues ls`, `gql`, `host`, `version`. Config in `~/.gluecron/config.json` (0600). Talks to the server via REST + GraphQL.
272- **G4** — VS Code extension → ✅ shipped. `vscode-extension/` contains package.json + `src/extension.ts`. Commands: `gluecron.explainFile`, `gluecron.openOnWeb`, `gluecron.searchSemantic`, `gluecron.generateTests`. Detects Gluecron remotes via `git config remote.origin.url`. Settings: `gluecron.host` + `gluecron.token`.
273
274### BLOCK I — Filling parity gaps
275- **I1** — Archive / unarchive repository → ✅ shipped. `src/routes/repo-settings.tsx` archive/unarchive toggle (existing `repositories.is_archived` column). `RepoHeader` surfaces an "Archived" badge.
276- **I2** — Template repositories → ✅ shipped. `drizzle/0022_repo_templates.sql` adds `is_template` column + partial index. `src/routes/templates.ts` serves `POST /:owner/:repo/use-template` (git clone --bare into caller's namespace, fresh `activity_feed` entry). Settings UI gains a "Mark as template" toggle. Public repo page renders a prominent "Use this template" CTA for non-owners.
277- **I3** — Repository transfer → ✅ shipped. `drizzle/0022_repo_templates.sql` adds `repo_transfers` audit table. `src/routes/repo-settings.tsx` `POST /:owner/:repo/settings/transfer` (validate target user exists, reject name conflicts, update `owner_id`, log to `repo_transfers`).
278- **I4** — Generic command palette → ✅ shipped. `src/views/layout.tsx` injects a Cmd+K palette with ~20 canonical destinations (Dashboard, Explore, Notifications, Ask AI, Create repo, Marketplace, Installed apps, Register app, Shortcuts, Settings, 2FA, Passkeys, PATs, Billing, Audit, Gists, GraphQL, Admin, Theme). Fuzzy-match, arrow-key navigation, Esc/backdrop to close.
279- **I5** — Code scanning UI → ✅ shipped. `src/routes/code-scanning.tsx` `GET /:owner/:repo/security` aggregates `gate_runs` matching `%scan%`/`%security%` (last 100), computes latest-per-gate status, renders failed/repaired/total summary cards + per-scanner status list + recent-runs table. Private-repo visibility enforced. Zero new tables — pure surfacing layer.
280- **I6** — Sponsors → ✅ shipped. `drizzle/0023_sponsors.sql` adds `sponsorship_tiers` (maintainer_id, name, monthly_cents, one_time_allowed, is_active) + `sponsorships` (sponsor_id, maintainer_id, tier_id, amount_cents, kind, note, is_public, cancelled_at). `src/routes/sponsors.tsx` serves public `/sponsors/:username` (tier cards + recent public sponsors join) + maintainer `/settings/sponsors` (tier CRUD, soft-retire via is_active=false, activity list). Payment rails deferred — v1 captures intent + thank-you notes.
281- **I7** — Weekly email digest → ✅ shipped. `drizzle/0024_email_digest.sql` adds `users.notify_email_digest_weekly` + `last_digest_sent_at`. `src/lib/email-digest.ts` exposes `composeDigest`/`sendDigestForUser`/`sendDigestsToAll` (never-throws). Pulls notifications + failed/repaired gate_runs + merged PRs from the last 7d, composes escaped HTML + plaintext, and sends via the shared email provider. `/settings/digest/preview` renders the digest inline for self-preview; `/admin/digests` gives site admins a "Send now" trigger + single-user preview, audit-logged as `admin.digests.run`/`admin.digests.preview`.
282- **I8** — Symbol / xref navigation → ✅ shipped. `drizzle/0025_code_symbols.sql` adds a `code_symbols` table. `src/lib/symbols.ts` provides a regex-based top-level extractor for ts/js/py/rs/go/rb/java/kt/swift. On-demand indexing via `POST /:owner/:repo/symbols/reindex` walks the default-branch tree, caps at 2000 files/1MB each, replaces the prior set. Browse at `/:owner/:repo/symbols` (A–Z + per-kind counts), search via `/symbols/search?q=`, inspect at `/symbols/:name`. 14 new tests.
283- **I9** — Repository mirroring → ✅ shipped. `drizzle/0026_repo_mirrors.sql` adds `repo_mirrors` (one-per-repo config) + `repo_mirror_runs` (audit log). `src/lib/mirrors.ts` provides URL validation (https/http/git only, no ssh/file/paths/shell metas), credentials-redaction for logs, and `runMirrorSync` that shells out to `git fetch --prune --tags` with a 5-min timeout and `GIT_TERMINAL_PROMPT=0`. `src/routes/mirrors.tsx` serves owner-only `/:owner/:repo/settings/mirror` + site-admin `/admin/mirrors/sync-all`. 17 new tests.
284- **I10** — Enterprise SSO via OIDC → ✅ shipped. `drizzle/0027_sso_oidc.sql` adds `sso_config` (singleton `id='default'` row with issuer + authorize/token/userinfo endpoints + client credentials + scopes + optional email-domain allow-list + `auto_create_users` toggle) and `sso_user_links` (maps local user to IdP `sub`, unique per-subject). `src/lib/sso.ts` exposes `buildAuthorizeUrl`/`exchangeCode`/`fetchUserinfo`/`findOrCreateUserFromSso` pure helpers — plain OIDC auth-code flow, no XML / no signature verification dep. `src/routes/sso.tsx` serves site-admin `/admin/sso` config page, `/login/sso` initiator (state + nonce cookies, 10-min TTL), `/login/sso/callback` exchanger + session issuer, plus `POST /settings/sso/unlink` for users. `/login` renders "Sign in with &lt;provider name&gt;" when enabled. 24 new tests (pure helpers + route-auth smokes).
285
286### BLOCK J — Beyond-parity advanced features
287- **J1** — Dependency graph → ✅ shipped. `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies` (ecosystem + name + version_spec + manifest_path + is_dev + commit_sha) with indexes on `(repository_id, ecosystem)` + `(name)`. `src/lib/deps.ts` parses seven manifest formats (package.json, requirements.txt, pyproject.toml, go.mod, Cargo.toml, Gemfile, composer.json) without a TOML library — each parser is defensive and returns `[]` on malformed input. Walks default-branch tree (max 200 manifests, 1MB each), replaces the prior set on reindex. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` (grouped by ecosystem with per-ecosystem counts) + owner-only `POST /dependencies/reindex`. Reverse-dep lookup via `repositoriesDependingOn(ecosystem, name)` for future "who depends on me" network-graph UI. 21 new tests.
288- **J2** — Security advisories (Dependabot-style) → ✅ shipped. `drizzle/0029_security_advisories.sql` adds `security_advisories` (GHSA + CVE IDs, severity, affected range, fixed version) + `repo_advisory_alerts` (per-repo match state with open/dismissed/fixed, unique on `(repo, advisory, manifest_path)`). `src/lib/advisories.ts` ships a 12-entry seed list (log4shell, lodash, minimist, vm2, urllib3, jwt-go, etc.), a minimal version-range matcher (`satisfiesRange` + `rangeMatches`) that handles `<`/`<=`/`>`/`>=`/`=` clauses including compound ranges, and `scanRepositoryForAlerts(repoId)` which cross-references J1 dep rows against the advisory list — inserts new alerts, reopens fixed-then-regressed ones, auto-closes alerts whose dep went away. `src/routes/advisories.tsx` serves `/:owner/:repo/security/advisories` (open), `/all` (everything), owner-only `POST /scan`, and per-alert `POST /:id/dismiss` + `POST /:id/reopen` with audit-log entries. 27 new tests (version parser, range matcher, seed shape, route auth).
289- **J3** — Commit signature verification (GPG + SSH "Verified" badge) → ✅ shipped. `drizzle/0030_signing_keys.sql` adds `signing_keys` (per-user GPG/SSH pubkeys, unique on `(key_type, fingerprint)`) + `commit_verifications` (memoised per-commit result, unique on `(repo, sha)`). `src/lib/signatures.ts` extracts `gpgsig` / `gpgsig-sha256` from raw commit objects (`getRawCommitObject` added to `src/git/repository.ts`), unarmors PGP + SSH signature blobs, walks OpenPGP packet streams for Issuer Fingerprint (subpacket 33) / Issuer Key ID (subpacket 16), parses the SSHSIG inner publickey field, and SHA-256 fingerprints SSH wire-format keys. Identity matching via fingerprint → optional email check → cached. `src/routes/signing-keys.tsx` serves `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`, audit-logged. `CommitList` + single commit view render a green "Verified" badge when cached `verified=true`. 29 new tests (extraction, unarmor, packet walker, SSH fp, end-to-end fast paths, route auth).
290- **J4** — User following + personalised feed → ✅ shipped. `drizzle/0031_user_follows.sql` adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK constraint rejecting self-follows, reverse-lookup index on `following_id`). `src/lib/follows.ts` exposes `followUser/unfollowUser/isFollowing/listFollowers/listFollowing/followCounts` + `feedForUser(userId, limit)` which joins `activity_feed` against the follow set (bounded to 200 edges) and filters out private repos the viewer doesn't own. `src/routes/follows.tsx` serves `POST /:user/follow` + `/:user/unfollow` (auth-gated, audit-logged), public `GET /:user/followers` + `/:user/following`, and `GET /feed` (personalised timeline). Follow button + follower/following counts added to the user profile page via `src/routes/web.tsx`. Reserved-name set protects fixed paths (`login`, `settings`, `feed`, etc.). 8 new tests (verb table + route auth).
291- **J5** — Profile READMEs → ✅ shipped. User profile page at `/:owner` now attempts to render `<user>/<user>/README.md` (GitHub convention) or `<user>/.github/README.md` (org-style fallback) as the hero panel above the repo list. No schema changes — reuses `getReadme` / `renderMarkdown` + `repoExists` from the git layer. Failures are silent; missing repo just hides the panel. 2 smoke tests.
292- **J6** — Repository rulesets (push policy engine) → ✅ shipped. `drizzle/0032_repo_rulesets.sql` adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum active/evaluate/disabled) + `ruleset_rules` (JSON params). `src/lib/rulesets.ts` exposes six rule types (`commit_message_pattern`, `branch_name_pattern`, `tag_name_pattern`, `blocked_file_paths`, `max_file_size`, `forbid_force_push`) + the pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. Helpers: glob-lite matcher (`globToRegex`), defensive `parseParams`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `src/routes/rulesets.tsx` serves owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail, enforcement toggle, add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. 23 new tests covering each rule type, enforcement modes, glob edge cases, and route-auth redirects.
293- **J7** — Closing keywords auto-close issues on PR merge → ✅ shipped. `src/lib/close-keywords.ts` exports pure `extractClosingRefs(text)` and `extractClosingRefsMulti(sources[])` — scans for `(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[:|-]? #N` with case-insensitive, punctuation-tolerant, word-boundary-respecting matching. Rejects cross-repo refs (`owner/repo#N`), embedded-in-word verbs (`disclose`, `unresolved`), and non-positive numbers. Wired into the PR merge handler in `src/routes/pulls.tsx` — after a successful merge, scans `pr.title + pr.body`, looks up each referenced open issue in the same repo, closes it, and posts a "Closed by pull request #N" comment. Wrapped in try/catch so close-keyword failures never block the merge redirect. 14 new tests covering verb forms, punctuation variants, de-dup+sort, cross-repo rejection, embedded-word rejection, case-insensitivity, and multi-source merging. Total suite 749/749.
294- **J8** — Commit status API (external CI signals) → ✅ shipped. `drizzle/0033_commit_statuses.sql` adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error). `src/lib/commit-statuses.ts` exposes pure helpers (`isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`) and DB helpers (`setStatus` with delete-then-insert upsert, `listStatuses`, `combinedStatus`). `src/routes/commit-statuses.ts` serves `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth + owner check), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (list, private-repo visibility), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup). Commit detail view now renders a "Checks" pill row when statuses exist, colour-coded per state with clickable target URLs. 18 new tests covering the pure helpers + route auth + invalid-sha rejection. Total suite 767/767.
295
296### BLOCK H — Marketplace
297- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
298- **H2** — GitHub Apps equivalent (bot identities + installation tokens) → ✅ shipped. Same schema as H1: every app gets a `<slug>[bot]` row in `app_bots`. `generateBearerToken()` produces `ghi_`-prefixed bearers; `hashBearer` (sha256) is the only form persisted. `verifyInstallToken(token)` returns `{installation, app, botUsername, permissions}` or `null` (checks revoked/expired/uninstalled/suspended). Permission vocabulary: `contents:read/write`, `issues:read/write`, `pulls:read/write`, `checks:read/write`, `deployments:read/write`, `metadata:read``hasPermission` implements write→read implication.
299
300---
301
302## 4. LOCKED BLOCKS (DO NOT UNDO)
303
304Everything below is committed, tested, and load-bearing. **Do not delete, rename, or semantically change without owner permission.**
305
306### 4.1 Infrastructure (locked)
307- `src/app.tsx` — route composition, middleware order, error handlers
308- `src/index.ts` — Bun server entry
309- `src/lib/config.ts` — env getters (late-binding)
310- `src/db/schema.ts` — 95 tables. New tables only via new migration.
311- `src/db/index.ts` — lazy proxy DB connection
312- `src/db/migrate.ts` — migration runner
313- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
314- `drizzle/0004_org_owned_repos.sql` (Block B2) — migration, never edited in place
315- `drizzle/0005_totp_2fa.sql` (Block B4) — migration, never edited in place
316- `drizzle/0006_webauthn_passkeys.sql` (Block B5) — migration, never edited in place
317- `drizzle/0007_oauth_provider.sql` (Block B6) — migration, never edited in place
318- `drizzle/0008_workflows.sql` (Block C1) — migration, never edited in place
319- `drizzle/0009_packages.sql` (Block C2) — migration, never edited in place
320- `drizzle/0010_pages.sql` (Block C3) — migration, never edited in place
321- `drizzle/0011_environments.sql` (Block C4) — migration, never edited in place
322- `drizzle/0012_ai_native.sql` (Block D) — migration, never edited in place. Adds `codebase_explanations`, `dep_update_runs`, `code_chunks`.
323- `drizzle/0013_discussions.sql` (Block E2) — migration, never edited in place. Adds `discussions`, `discussion_comments`.
324- `drizzle/0014_gists.sql` (Block E4) — migration, never edited in place. Adds `gists`, `gist_files`, `gist_revisions`, `gist_stars`.
325- `drizzle/0015_projects.sql` (Block E1) — migration, never edited in place. Adds `projects`, `project_columns`, `project_items`.
326- `drizzle/0016_wikis.sql` (Block E3) — migration, never edited in place. Adds `wiki_pages`, `wiki_revisions`.
327- `drizzle/0017_merge_queue.sql` (Block E5) — migration, never edited in place. Adds `merge_queue_entries` (with partial unique index on `pull_request_id WHERE state IN ('queued','running')`).
328- `drizzle/0018_required_checks.sql` (Block E6) — migration, never edited in place. Adds `branch_required_checks`.
329- `drizzle/0019_protected_tags.sql` (Block E7) — migration, never edited in place. Adds `protected_tags`.
330- `drizzle/0020_analytics_and_admin.sql` (Block F) — migration, never edited in place. Adds `repo_traffic_events`, `system_flags`, `site_admins`, `billing_plans` (seeded free/pro/team/enterprise), `user_quotas`.
331- `drizzle/0021_marketplace_and_apps.sql` (Block H) — migration, never edited in place. Adds `apps`, `app_installations` (partial unique index on `(app_id, target_type, target_id) WHERE uninstalled_at IS NULL`), `app_bots` (one-per-app, `<slug>[bot]` username), `app_install_tokens` (sha256 hash, expires_at, revoked_at), `app_events` (audit trail).
332- `drizzle/0022_repo_templates.sql` (Block I2+I3) — migration, never edited in place. Adds `repositories.is_template` (partial index where true) + `repo_transfers` audit table.
333- `drizzle/0023_sponsors.sql` (Block I6) — migration, never edited in place. Adds `sponsorship_tiers` + `sponsorships` tables.
334- `drizzle/0024_email_digest.sql` (Block I7) — migration, never edited in place. Adds `users.notify_email_digest_weekly` + `users.last_digest_sent_at`.
335- `drizzle/0025_code_symbols.sql` (Block I8) — migration, never edited in place. Adds `code_symbols` table with indexes on `(repository_id, name)` + `(repository_id, path)`.
336- `drizzle/0026_repo_mirrors.sql` (Block I9) — migration, never edited in place. Adds `repo_mirrors` (unique on `repository_id`) + `repo_mirror_runs`.
337- `drizzle/0027_sso_oidc.sql` (Block I10) — migration, never edited in place. Adds `sso_config` singleton (`id='default'`) + `sso_user_links` (`subject` unique, FK to `users` with ON DELETE CASCADE).
338- `drizzle/0028_repo_dependencies.sql` (Block J1) — migration, never edited in place. Adds `repo_dependencies` with indexes on `(repository_id, ecosystem)` + `(name)`.
339- `drizzle/0029_security_advisories.sql` (Block J2) — migration, never edited in place. Adds `security_advisories` (`ghsa_id` unique) + `repo_advisory_alerts` (unique on `(repository_id, advisory_id, manifest_path)`, status index).
340- `drizzle/0030_signing_keys.sql` (Block J3) — migration, never edited in place. Adds `signing_keys` (unique on `(key_type, fingerprint)`) + `commit_verifications` (unique on `(repository_id, commit_sha)`).
341- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
342- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
343- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
344
345### 4.2 Git layer (locked)
346- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
347- `src/git/protocol.ts` — Smart HTTP pkt-line
348- `src/hooks/post-receive.ts` — CODEOWNERS sync, gates, auto-deploy, webhook fan-out
349
350### 4.3 Auth + security (locked)
351- `src/lib/auth.ts` — bcrypt, session tokens
352- `src/middleware/auth.ts` — softAuth + requireAuth. Accepts three auth inputs: session cookie (web), OAuth access token (`glct_` prefix, Block B6), and personal access token (`glc_` prefix, Block C2). Invalid bearer → 401 JSON. Cookie flow → /login redirect.
353- `src/middleware/rate-limit.ts` — fixed-window limiter
354- `src/middleware/request-context.ts` — request-ID
355- `src/lib/security-scan.ts``SECRET_PATTERNS` (exported) + `scanForSecrets` + `aiSecurityScan`
356- `src/lib/codeowners.ts` — parser + `ownersForPath` (last-match-wins); team expansion helpers for `@org/team` (Block B3)
357- `src/lib/totp.ts` (Block B4) — TOTP enroll / verify / recovery codes
358- `src/lib/webauthn.ts` (Block B5) — WebAuthn registration + assertion helpers
359- `src/lib/oauth.ts` (Block B6) — OAuth 2.0 provider: authorization code grant, token issuance, scope enforcement
360- `src/lib/workflow-parser.ts` (Block C1) — YAML subset parser for `.gluecron/workflows/*.yml`. Exports `parseWorkflow(src)` returning `{ ok, workflow | error }`. Never throws.
361- `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`.
362- `src/lib/packages.ts` (Block C2) — npm protocol helpers: `parsePackageName`, `computeShasum` (sha1), `computeIntegrity` (sha512 base64), `buildPackument`, `resolveRepoFromPackageJson`, `parseRepoUrl`, `tarballFilename`. Pure functions.
363- `src/lib/pages.ts` (Block C3) — `onPagesPush` (never throws), `resolvePagesPath` (probe list including pretty URLs + traversal strip), `contentTypeFor` (MIME).
364- `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.
365
366### 4.4 AI layer (locked)
367- `src/lib/ai-client.ts` — Anthropic client + model constants
368- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage / **pull-request-triage (D3)**
369- `src/lib/ai-chat.ts` — conversational chat
370- `src/lib/ai-review.ts` — PR code review
371- `src/lib/auto-repair.ts` — worktree-backed repair commits
372- `src/lib/merge-resolver.ts` — AI merge conflict resolution
373- `src/lib/ai-explain.ts` (Block D6) — `explainCodebase(...)` + `getCachedExplanation(...)`. Samples up to ~25 representative files (~60KB cap), Sonnet 4, upserts into `codebase_explanations`. Fallback to README-ish synthesis when no key. Never throws.
374- `src/lib/ai-completion.ts` (Block D9) — `completeCode({prefix, suffix?, language?, maxTokens?, repoHint?})` via Haiku. Inline LRU (size 200, 5-min TTL) keyed on sha256 of prefix+suffix+language. Code-fence stripping. Never throws. `__test` bundle exposed.
375- `src/lib/dep-updater.ts` (Block D2) — `parseManifest`, `queryNpmLatest`, `planUpdates` (injectable `fetchLatest`), `applyBumps`, `runDepUpdateRun`. Creates `gluecron/dep-update-<ts>` branch via git plumbing + opens a PR row. Never throws.
376- `src/lib/semantic-search.ts` (Block D1) — `tokenize`, `hashEmbed` (512-dim L2-normalised FNV-1a + sign trick), `embedBatch` (Voyage `voyage-code-3` when `VOYAGE_API_KEY` set, else fallback), `chunkFile`, `isCodeFile`, `indexRepository`, `searchRepository`, `cosine`, `isEmbeddingsProviderAvailable`, `__test` bundle.
377- `src/lib/ai-incident.ts` (Block D4) — `onDeployFailure({deploymentId, reason, logs?})` and pure helper `summariseCommitsForIncident(commits)`. Sonnet 4 structured JSON RCA → opens `issues` row, attaches `incident` label if present, sets `deployments.blockedReason`. Never throws; deterministic fallback body when no API key. Wired from `post-receive.ts triggerCrontechDeploy` + `deployments.tsx retry-incident`.
378- `src/lib/ai-tests.ts` (Block D8) — pure helpers `detectLanguage`, `detectTestFramework`, `buildTestsPrompt`, `suggestedTestPath`, `generateTestStub`, `contentTypeFor`. Returns `{code:"", framework:"fallback"}` on no API key. Never throws.
379- `src/lib/branch-protection.ts` (Block D5) — `matchProtection(repoId, branch)` (exact wins; deterministic glob sort), `evaluateProtection(rule, ctx)` (pure — checks `requireAiApproval | requireGreenGates | requireHumanReview | requiredApprovals`), `countHumanApprovals(prId)` (LGTM/+1/approved heuristic). Never throws. Enforcement is in `src/routes/pulls.tsx` merge handler, after existing hard-gate filter.
380
381### 4.5 Platform (locked)
382- `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.
383- `src/lib/email.ts` — provider-pluggable email sender (`log`|`resend`). `sendEmail()` never throws. `absoluteUrl()` joins paths against `APP_BASE_URL`.
384- `src/lib/templates.ts``loadIssueTemplate` / `loadPrTemplate`. Checks standard paths (`.github/`, `.gluecron/`, root, `docs/`) on the default branch, strips YAML frontmatter, 16KB cap, returns null on any failure.
385- `src/lib/unread.ts` — unread count helper (never throws)
386- `src/lib/repo-bootstrap.ts` — green defaults on repo creation
387- `src/lib/gate.ts` — gate orchestration + persistence
388- `src/lib/cache.ts` — LRU cache, git-cache invalidation
389- `src/lib/reactions.ts``summariseReactions`, `toggleReaction`, `ALLOWED_EMOJIS`, `EMOJI_GLYPH`, `isAllowedEmoji`, `isAllowedTarget`
390
391### 4.6 Routes (locked endpoints — behaviour must be preserved)
392- `src/routes/git.ts` — Smart HTTP (clone/push)
393- `src/routes/api.ts` — REST (`POST /api/repos`, `GET /api/users/:u/repos`, `GET /api/repos/:o/:n`, `POST /api/setup`)
394- `src/routes/hooks.ts``POST /api/hooks/gatetest` (bearer/HMAC), `GET /api/hooks/ping`, `POST /api/v1/gate-runs` (PAT backup), `GET /api/v1/gate-runs`. See `GATETEST_HOOK.md`.
395- `src/routes/theme.ts``GET /theme/toggle`, `GET /theme/set?mode=`. Writes `theme` cookie (`dark`|`light`, 1-year). Layout reads via pre-paint inline script.
396- `src/routes/audit.tsx``GET /settings/audit` (personal) + `GET /:owner/:repo/settings/audit` (owner-only).
397- `src/routes/saved-replies.tsx``GET/POST /settings/replies`, `POST /settings/replies/:id`, `POST /settings/replies/:id/delete`, `GET /api/user/replies`. Unique constraint `saved_replies_user_shortcut`.
398- `src/routes/deployments.tsx``GET /:owner/:repo/deployments` (grouped by env, success-rate rollup), `GET /:owner/:repo/deployments/:id` (detail).
399- `src/routes/reactions.ts``POST /api/reactions/:targetType/:targetId/:emoji/toggle` (authed, form- or fetch-compatible), `GET /api/reactions/:targetType/:targetId`. Targets: `issue|pr|issue_comment|pr_comment`. Emojis: 8 canonical.
400- `src/routes/auth.tsx` — register / login / logout
401- `src/routes/web.tsx` — home / new / browse / blob / commits / raw / blame / star / search / profile
402- `src/routes/issues.tsx` — issue CRUD + comments + labels + lock
403- `src/routes/pulls.tsx` — PR CRUD + review + merge + close
404- `src/routes/editor.tsx` — web file editor
405- `src/routes/compare.tsx` — base...head diff
406- `src/routes/settings.tsx` — profile + password + email notification preferences (`POST /settings/notifications`)
407- `src/routes/repo-settings.tsx` — repo settings + delete
408- `src/routes/webhooks.tsx` — webhook CRUD + test + `fireWebhooks`
409- `src/routes/fork.ts` — fork
410- `src/routes/explore.tsx` — discover
411- `src/routes/tokens.tsx` — personal access tokens
412- `src/routes/contributors.tsx` — contributor list
413- `src/routes/notifications.tsx` — inbox + unread API
414- `src/routes/dashboard.tsx` — authed home (`renderDashboard` exported)
415- `src/routes/ask.tsx` — global + repo AI chat + explain
416- `src/routes/releases.tsx` — tags + AI changelog
417- `src/routes/gates.tsx` — history + settings + branch protection UI
418- `src/routes/insights.tsx` — insights + milestones
419- `src/routes/search.tsx` — global search + `/shortcuts`
420- `src/routes/health.ts``/healthz` `/readyz` `/metrics`
421- `src/routes/orgs.tsx``/orgs` list, `/orgs/new` create, `/orgs/:slug` profile, `/orgs/:slug/people` + add/role/remove, `/orgs/:slug/teams` + create, `/orgs/:slug/teams/:teamSlug` + member add/remove. All require auth. Role guards via `orgRoleAtLeast`; last-owner cannot be demoted or removed; every write path `audit()`'d.
422- `src/lib/orgs.ts` (Block B1) — `isValidSlug` (rejects reserved + too-short/long + consecutive/leading/trailing hyphens), `normalizeSlug`, `orgRoleAtLeast` (owner>admin>member), `isValidOrgRole`, `isValidTeamRole`, `loadOrgForUser`, `listOrgsForUser`, `listOrgMembers`, `listTeamsForOrg`, `listTeamMembers`, `__test` export for unit tests.
423- `src/routes/settings-2fa.tsx` (Block B4) — TOTP enroll / verify / disable + recovery codes UI. All require auth.
424- `src/routes/passkeys.tsx` (Block B5) — WebAuthn passkey registration / assertion / management. All require auth.
425- `src/routes/oauth.tsx` (Block B6) — OAuth 2.0 authorize + token + userinfo endpoints.
426- `src/routes/developer-apps.tsx` (Block B6) — developer-facing OAuth app CRUD (`/settings/developer/apps`), client secret rotation, audit-logged.
427- `src/routes/workflows.tsx` (Block C1) — Actions UI. `GET /:owner/:repo/actions`, `GET /:owner/:repo/actions/runs/:runId`, `POST /:owner/:repo/actions/:workflowId/run` (auth+owner), `POST /:owner/:repo/actions/runs/:runId/cancel` (auth+owner). Manual runs are `event=manual`, ref=default branch.
428- `src/routes/packages-api.ts` (Block C2) — npm protocol: `GET/PUT/DELETE /npm/*` (packument, tarball, publish, yank); JSON helpers at `/api/packages/:owner/:repo/...`. PAT (`glc_`) bearer auth.
429- `src/routes/packages.tsx` (Block C2) — UI: `/:owner/:repo/packages` list + `/:owner/:repo/packages/:pkgName` detail.
430- `src/routes/pages.tsx` (Block C3) — `GET /:owner/:repo/pages/*` serves static files from latest gh-pages commit (binary via `getRawBlob`, text via `getBlob`). `GET/POST /:owner/:repo/settings/pages` settings + redeploy.
431- `src/routes/environments.tsx` (Block C4) — settings CRUD at `/:owner/:repo/settings/environments`; approval endpoints at `/:owner/:repo/deployments/:id/{approve,reject}`.
432- `src/routes/ai-explain.tsx` (Block D6) — `GET /:owner/:repo/explain` (softAuth), `POST /:owner/:repo/explain/regenerate` (requireAuth, owner-only).
433- `src/routes/ai-changelog.tsx` (Block D7) — `GET /:owner/:repo/ai/changelog` (softAuth). Form + rendered output; `?format=markdown` returns `text/markdown`.
434- `src/routes/copilot.ts` (Block D9) — `POST /api/copilot/completions` (requireAuth, 60/min rate limit), `GET /api/copilot/ping` (public).
435- `src/routes/dep-updater.tsx` (Block D2) — `GET /:owner/:repo/settings/dep-updater` + `POST /:owner/:repo/settings/dep-updater/run` (requireAuth, owner-only).
436- `src/routes/semantic-search.tsx` (Block D1) — `GET /:owner/:repo/search/semantic?q=` (softAuth) + `POST /:owner/:repo/search/semantic/reindex` (requireAuth, owner-only).
437- `src/routes/ai-tests.tsx` (Block D8) — `GET /:owner/:repo/ai/tests` (softAuth form + picker), `GET /:owner/:repo/ai/tests?format=raw` (raw text w/ MIME), `POST /:owner/:repo/ai/tests/generate` (requireAuth, renders highlighted source + AI-generated failing test with copy button).
438- `src/routes/discussions.tsx` (Block E2) — full discussion CRUD + categories + q-and-a answers + lock/pin. Exports `isValidCategory(c)` helper. Owner-only lock/pin; owner-or-author can close/toggle.
439- `src/routes/gists.tsx` (Block E4) — `GET /gists` discover, `/gists/new|:slug|:slug/edit|:slug/delete|:slug/star|:slug/revisions|:slug/revisions/:rev` + `/:username/gists`. Exports `generateSlug()` (8-hex) and `snapshotOf(files)` JSON serializer. Retries on slug collision up to 5x.
440- `src/routes/projects.tsx` (Block E1) — kanban board CRUD. Auto-seeds three default columns on project create. `/:owner/:repo/projects/:number/items/:itemId/move` recomputes position via `max+1` of target column.
441- `src/routes/wikis.tsx` (Block E3) — DB-backed wiki with revision history + revert. Exports `slugifyTitle(title)` (lowercase alphanumerics joined by single dashes, trimmed). Every edit appends a `wiki_revisions` row; revert creates a new revision.
442- `src/routes/merge-queue.tsx` (Block E5) — `GET /:owner/:repo/queue` list, `POST /:owner/:repo/pulls/:n/enqueue` (requireAuth), `POST /:owner/:repo/queue/:id/dequeue` (owner-or-enqueuer), `POST /:owner/:repo/queue/process-next?base=X` (owner-only, re-runs gates against base then updates base ref). PR page has an extra "Add to merge queue" button.
443- `src/lib/merge-queue.ts` (Block E5) — `enqueuePr`, `dequeueEntry`, `peekHead`, `markHeadRunning`, `completeEntry`, `isQueued`, `queueDepth`, `listQueue`, `listQueueWithPrs`. No side effects beyond the `merge_queue_entries` table; callers own gate execution + git updates.
444- `src/routes/required-checks.tsx` (Block E6) — `/:owner/:repo/gates/protection/:id/checks` CRUD (owner-only, requireAuth). "Required checks" link added on gates settings UI next to each branch protection rule.
445- `src/lib/branch-protection.ts` extends for E6 — `listRequiredChecks(branchProtectionId)`, `passingCheckNames(repositoryId, commitSha)` (scans `gate_runs` + `workflow_runs`), and `evaluateProtection(rule, ctx, requiredChecks[])` now takes a third param + reports `missingChecks`.
446- `src/routes/protected-tags.tsx` (Block E7) — `/:owner/:repo/settings/protected-tags` CRUD (owner-only, requireAuth).
447- `src/lib/protected-tags.ts` (Block E7) — `matchProtectedTag`, `isProtectedTag`, `canBypassProtectedTag`, `listProtectedTags`, `addProtectedTag`, `removeProtectedTag`, `userIdFromUsername`. Matching uses `matchGlob` from environments.ts with `refs/tags/` prefix stripped. Post-receive hook writes audit log entries (`protected_tags.{create|update|delete}_violation_candidate`) on matched pushes.
448- `src/lib/traffic.ts` (Block F1) — `track`, `trackView`, `trackClone`, `trackByName(owner, repo, kind, meta)`, `summarise(repoId, windowDays=14)`, pure `bucketDaily(events)`. SHA-256-truncated IP hashing (16 hex) for unique-visitor approximation. All callers use `.catch(() => {})` fire-and-forget.
449- `src/routes/traffic.tsx` (Block F1) — `GET /:owner/:repo/traffic` (owner-only) with 7/14/30/90d windows, ascii-bar daily chart, top referers, unique visitors.
450- `src/routes/org-insights.tsx` (Block F2) — exports `computeOrgInsights(orgId)` returning `OrgInsightsSummary` (repoCount, gateRunsTotal, greenRate, openIssues, openPrs, mergedPrs30d, perRepo[]). `GET /orgs/:slug/insights` requires org membership. No new tables.
451- `src/lib/admin.ts` (Block F3) — `isSiteAdmin(userId)` with bootstrap rule (empty `site_admins` → oldest user wins), `listSiteAdmins`, `grantSiteAdmin`, `revokeSiteAdmin`, `getFlag`, `setFlag`, `listFlags`, `KNOWN_FLAGS = { registration_locked, site_banner_text, site_banner_level, read_only_mode }`. All helpers swallow DB errors.
452- `src/routes/admin.tsx` (Block F3) — `GET /admin` dashboard (user/repo/admin counts + recent signups), `/admin/users` + toggle grant/revoke, `/admin/repos` + nuclear delete, `/admin/flags` form. All mutations audit-logged via `audit()`. Gated through a `gate(c)` helper that returns `{user} | Response`.
453- `src/lib/billing.ts` (Block F4) — plan + quota helpers. `FALLBACK_PLANS` (free/pro/team/enterprise) mirror the seed rows. `getUserQuota(userId)` auto-initialises free row. `bumpUsage`, `checkQuota` (fail-open), `wouldExceedRepoLimit`, `resetIfCycleExpired`, `formatPrice`. Never throws into request path.
454- `src/routes/billing.tsx` (Block F4) — `GET /settings/billing` (personal view with usage bars + plan cards), `GET /admin/billing` (site-admin user/plan table), `POST /admin/billing/:userId/plan` (override plan, audit-logged).
455- `src/routes/pwa.ts` (Block G1) — `/manifest.webmanifest`, `/sw.js`, `/icon.svg`. Exports `MANIFEST`, `SERVICE_WORKER_SRC`, `PWA_REGISTER_SNIPPET` for testing. SW deliberately skips `.git/`, `/api/`, `/login*`, `/register`, `/logout`.
456- `src/lib/graphql.ts` (Block G2) — hand-rolled recursive-descent parser (`parseQuery`) + executor (`execute`) over a fixed schema. Zero dependencies. Root fields: viewer, user, repository, search, rateLimit. No mutations.
457- `src/routes/graphql.ts` (Block G2) — `POST /api/graphql` JSON endpoint + `GET /api/graphql` GraphiQL-lite explorer (Cmd+Enter to run).
458- `cli/gluecron.ts` (Block G3) — single-file Bun CLI. Exports `dispatch(argv, out)` for programmatic use, `HELP` constant, `loadConfig`/`saveConfig`. Config at `~/.gluecron/config.json` (0600). Compile: `bun build cli/gluecron.ts --compile --outfile gluecron`.
459- `vscode-extension/` (Block G4) — VS Code extension with `package.json` declaring four commands (explainFile, openOnWeb, searchSemantic, generateTests) + `gluecron.host` / `gluecron.token` settings. Detects Gluecron remotes via `git config remote.origin.url`.
460- `src/lib/marketplace.ts` (Block H1+H2) — marketplace + app identity surface. `KNOWN_PERMISSIONS` (10 scopes), `KNOWN_EVENTS` (8 kinds). Pure helpers: `slugify` (40-char cap), `botUsername` (`<slug>[bot]`), `normalisePermissions` (drops unknown, de-dupes), `parsePermissions` (JSON), `hasPermission` (write→read implication), `permissionsSubset`, `generateBearerToken` (`ghi_` prefix + 24-byte hex), `hashBearer` (sha256). DB helpers: `listPublicApps(query)`, `getAppBySlug`, `createApp` (retries slug collisions, creates matching bot row), `installApp` (idempotent soft-update), `uninstallApp` (revokes all tokens), `issueInstallToken` (1h TTL default), `verifyInstallToken` (checks revoked/expired/uninstalled/suspended), `listInstallationsForApp`, `listInstallationsForTarget`, `listEventsForApp`, `countInstalls`. Never throws into request path.
461- `src/routes/marketplace.tsx` (Block H1+H2) — public marketplace + developer UX. `GET /marketplace` (directory + search), `GET /marketplace/:slug` (detail + install form), `POST /marketplace/:slug/install` (v1 user-target only), `POST /marketplace/installations/:id/uninstall` (installer-only), `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count, owner-only), `POST /developer/apps/:slug/tokens/new` (show-once `ghi_` token). All mutations audit-logged.
462- `src/routes/code-scanning.tsx` (Block I5) — `GET /:owner/:repo/security` (softAuth, private-repo visibility enforced). Aggregates last-100 scan-related `gate_runs`, builds `latestByName` map, renders summary cards + scanner status list + recent runs.
463- `src/routes/sponsors.tsx` (Block I6) — public `/sponsors/:username` + maintainer `/settings/sponsors` (requireAuth). Tier CRUD (`POST /settings/sponsors/tiers/new`, soft-retire via `is_active=false` on delete). Exports `sponsorshipTotalForUser(userId)` helper and `__internal.formatCents` for tests.
464- `src/lib/email-digest.ts` (Block I7) — `composeDigest(userId, since?)` (never throws, null on failure), `sendDigestForUser(userId)` (opt-out check + updates `last_digest_sent_at` on success), `sendDigestsToAll()` (iterates opted-in users). Pulls notifications + owned-repo gate_runs (failed/repaired) + merged PRs over last 7d. Builds text + escaped HTML body. Exports `__internal = { textToHtml, escapeHtml, fmtRange }` for tests.
465- `src/routes/admin.tsx` (extends Block F3 for I7) — adds `GET /admin/digests` (opted-in count + recently sent list), `POST /admin/digests/run` (calls `sendDigestsToAll`, audit-logged with counts), `POST /admin/digests/preview` (sends to one user by username, audit-logged). New "Email digests" tile on the /admin dashboard grid.
466- `src/routes/settings.tsx` (extends for I7) — adds `notify_email_digest_weekly` checkbox to email prefs + handler wiring in `POST /settings/notifications`, and `GET /settings/digest/preview` (renders `composeDigest` output inline via `raw(body.html)` with Hono's `hono/html`).
467- `src/lib/symbols.ts` (Block I8) — regex-based top-level symbol extractor. Pure helpers: `detectLanguage(path)` (10 extensions mapped to 8 languages), `extractSymbols(content, lang)` (per-language rule list, 1-based line numbers, 240-char signature cap, skips lines >500 chars). `indexRepositorySymbols(repoId)` walks the default-branch tree, caps at 2000 files / 1MB each, replaces the prior set in batches of 500. `findDefinitions(repoId, name)` + `countSymbolsForRepo(repoId)`. `__internal` exposes `RULES` + `EXT_LANG` for tests.
468- `src/routes/symbols.tsx` (Block I8) — `/:owner/:repo/symbols` overview (total + per-kind counts + A–Z list with blob deep-links), `/:owner/:repo/symbols/search?q=` prefix search (ilike `q%`), `/:owner/:repo/symbols/:name` detail (all definitions with signature preview + deep link). `POST /:owner/:repo/symbols/reindex` is requireAuth + owner-only.
469- `src/lib/mirrors.ts` (Block I9) — upstream URL validator (accepts https/http/git schemes, rejects ssh/file/paths/shell metacharacters, 2048-char cap), `safeUrlForLog` (redacts embedded credentials), `upsertMirror` / `deleteMirror` / `getMirrorForRepo` / `listRecentRuns`, `runMirrorSync` (runs `git fetch --prune --tags --no-write-fetch-head` via `Bun.spawn` with 5-min timeout + `GIT_TERMINAL_PROMPT=0`; updates `last_synced_at` + `last_status` + `last_error`), `listDueMirrors` + `syncAllDue` for the admin trigger.
470- `src/routes/mirrors.tsx` (Block I9) — owner-only config at `/:owner/:repo/settings/mirror` (GET form + recent-runs panel, POST save, POST delete, POST sync-now). Site-admin `POST /admin/mirrors/sync-all` iterates due mirrors. All mutations `audit()`-logged.
471- `src/lib/rulesets.ts` (Block J6) — exports `RULE_TYPES` (6 types), pure `globToRegex`, defensive `parseParams`, pure evaluator `evaluatePush(rulesets, ctx) → {allowed, violations}`. CRUD: `listRulesetsForRepo`, `getRuleset`, `createRuleset`, `updateRulesetEnforcement`, `deleteRuleset`, `addRule`, `deleteRule`. `__internal = { evalRule, globToRegex, parseParams }` for tests. Every rule variant no-ops on malformed params or bad regex rather than throwing.
472- `src/routes/rulesets.tsx` (Block J6) — owner-only UI at `/:owner/:repo/settings/rulesets` (list + create), `/:id` (detail + enforcement toggle + add rule), `/:id/delete`, `/:id/rules/:ruleId/delete`. All mutations `audit()`-logged via the existing `gate(c)` owner pattern.
473- `src/lib/close-keywords.ts` (Block J7) — pure parser. Exports `extractClosingRefs(text)` + `extractClosingRefsMulti(sources[])`. Verbs: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved. Case-insensitive, word-boundary-respecting, ignores cross-repo `owner/repo#N` refs, rejects non-positive numbers, returns sorted de-duped list. Tolerates `:` / `-` / whitespace between verb and ref.
474- `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.
475- `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).
476
477### 4.7 Views (locked contracts)
478- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
479- `src/views/components.tsx``RepoHeader`, `RepoNav` (active: `code|issues|pulls|commits|releases|actions|gates|insights|explain|changelog|semantic`), `RepoCard`, etc.
480- `src/views/reactions.tsx``ReactionsBar` (no-JS compatible, form-per-emoji)
481- Nav links: logo · search · theme-toggle · Explore · Ask · Notifications · New · Profile (or Sign in / Register)
482- Keyboard chords: `/`, `Cmd+K`, `?`, `n`, `g d`, `g n`, `g e`, `g a`
483
484### 4.8 Tests (locked)
485- `src/__tests__/green-ecosystem.test.ts` — secret scanner, codeowners, AI fallback, health, rate-limit headers, `/shortcuts`, `/search`
486- All other existing test files — do not delete without owner permission
487
488### 4.9 Invariants (never break these)
489- `isAiAvailable()` guard returns true fallback strings when no ANTHROPIC_API_KEY. AI features degrade gracefully.
490- `getUnreadCount` never throws; returns 0 on any error.
491- Rate-limit middleware adds `X-RateLimit-Limit` + `X-RateLimit-Remaining` to every response, including 500s.
492- `c.header("X-Request-Id", ...)` set by request-context on every response.
493- Secret scanner skips binary/lock paths (`shouldSkipPath`).
494- `SECRET_PATTERNS` is an exported array. Its shape is `{ type, regex, severity }`.
495- Theme routes live outside `/settings/*` (they must work for logged-out visitors). Cookie name: `theme`, values: `dark|light`.
496- Draft PRs cannot be merged — `/pulls/:n/merge` returns a redirect with the draft error when `pr.isDraft=true`.
497- Reactions API accepts only `ALLOWED_EMOJIS` and `ALLOWED_TARGETS`. Toggle is idempotent per (user, target, emoji).
498- `sendEmail()` never throws — always resolves to `{ ok, provider, ... }`. Email failures never break notification delivery or the primary request path.
499- Email fan-out in `notify()` is scoped to kinds in `EMAIL_ELIGIBLE` (mention / review_requested / assigned / gate_failed). Each eligible kind maps to exactly one user preference column.
500- Issue + PR template loading must return `null` on any git-subprocess failure (templates are a convenience, not a requirement). Forms always render.
501
502---
503
504## 5. OPERATIONAL NOTES
505
506### 5.1 Running locally
507```bash
508bun install
509bun dev # hot reload
510bun test # 601 tests currently pass
511bun run db:migrate
512```
513
514### 5.2 Environment
515- `DATABASE_URL` — Neon Postgres
516- `ANTHROPIC_API_KEY` — unlocks AI features
517- `GIT_REPOS_PATH` — default `./repos`
518- `PORT` — default 3000
519- `EMAIL_PROVIDER``log` (default, stderr-only) or `resend`
520- `EMAIL_FROM` — sender address for outbound mail
521- `RESEND_API_KEY` — required when `EMAIL_PROVIDER=resend`
522- `APP_BASE_URL` — canonical URL used to build absolute links in emails
523- `VOYAGE_API_KEY` — optional; when set, D1 semantic search uses Voyage `voyage-code-3` embeddings. Otherwise falls back to a deterministic 512-dim hashing embedder.
524
525### 5.3 Models
526- `claude-sonnet-4-20250514` — code review, security, chat
527- `claude-haiku-4-5-20251001` — commit messages, summaries, light tasks
528- Swap via `MODEL_SONNET` / `MODEL_HAIKU` constants in `src/lib/ai-client.ts`
529
530### 5.4 Deployment
531- `railway.toml` / `fly.toml` present
532- Crontech deploy on green push to default branch (can opt out via `autoDeployEnabled`)
533
534---
535
536## 6. SESSION WORKFLOW (WHAT THE NEXT AGENT DOES)
537
5381. Read this file, `CLAUDE.md`, `README.md`, `git log -1 --stat`.
5392. Check `git status` + current branch.
5403. Pick the next unfinished block from §3 (lowest letter + number first, unless owner specifies).
5414. Create a todo list that mirrors the sub-items of that block.
5425. Build. Write tests. Run `bun test`.
5436. Commit with `feat(<BLOCK-ID>): ...`.
5447. Push.
5458. Update this file:
546 - Move the block's row in §2 to ✅ where applicable.
547 - Add the block's files to §4 LOCKED BLOCKS.
548 - Commit + push again.
5499. Start the next block. **Do not stop to ask.**
550
551If a block is too large for a single session, split it into a sub-plan at the top of the session, ship what you can, and document what's left at the end of this file under a `## 7. IN-FLIGHT` section.
552
553---
554
555## 7. IN-FLIGHT
556
557(Intentionally empty. Add here if a block is partially complete at session end.)
ModifiedCLAUDE.md+13−0View fileUnifiedSplit
22
33AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
44
5## READ FIRST — every session
6
7**`BUILD_BIBLE.md` is mandatory reading for every Claude agent before any code changes.**
8
9It contains:
10- Agent policy (do-not-undo rule, continuous-build rule)
11- GitHub parity scorecard (what's shipped vs missing)
12- Numbered build plan (Blocks A–H)
13- Locked components that cannot be altered without owner permission
14- Session workflow
15
16Do not skip it. Do not refactor locked files. Do not stop mid-block.
17
518## Stack
619
720- **Runtime:** Bun
AddedDockerfile+45−0View fileUnifiedSplit
1# ---- build stage ----
2FROM oven/bun:1 AS builder
3
4WORKDIR /app
5
6# Copy lockfile and manifest first for layer caching
7COPY package.json bun.lock ./
8
9# Install production dependencies only
10RUN bun install --frozen-lockfile --production
11
12# ---- production stage ----
13# oven/bun:1-debian is based on Debian so apt is available
14FROM oven/bun:1-debian AS runner
15
16WORKDIR /app
17
18# Install git (required for git CLI subprocess calls)
19RUN apt-get update \
20 && apt-get install -y --no-install-recommends git \
21 && rm -rf /var/lib/apt/lists/*
22
23# Copy production node_modules from builder
24COPY --from=builder /app/node_modules ./node_modules
25
26# Copy application source and migration files
27COPY src/ ./src/
28COPY drizzle/ ./drizzle/
29COPY package.json ./
30
31# Create the repos directory and give ownership to the bun user
32RUN mkdir -p /app/repos \
33 && chown -R bun:bun /app
34
35# Run as non-root user (provided by the base image)
36USER bun
37
38# Default environment variables
39ENV GIT_REPOS_PATH=/app/repos \
40 PORT=3000 \
41 NODE_ENV=production
42
43EXPOSE 3000
44
45CMD ["bun", "run", "src/index.ts"]
AddedGATETEST_HOOK.md+182−0View fileUnifiedSplit
1# GateTest ↔ GlueCron integration
2
3GlueCron exposes **two** inbound paths for GateTest to report scan results back.
4Use the primary path for normal traffic; the backup path exists so you always
5have a way in if the shared secret is misconfigured.
6
7Base URL: `https://<your-gluecron-host>` (e.g. `https://gluecron.com`)
8
9---
10
11## Primary: shared-secret hook
12
13**URL:** `POST /api/hooks/gatetest`
14
15### Auth (pick one)
16
17**Option A — Bearer token**
18```
19Authorization: Bearer <GATETEST_CALLBACK_SECRET>
20```
21or if the GateTest side can't set `Authorization`:
22```
23X-GateTest-Token: <GATETEST_CALLBACK_SECRET>
24```
25
26**Option B — HMAC-SHA256 over raw body**
27```
28X-GateTest-Signature: sha256=<hex(hmac_sha256(GATETEST_HMAC_SECRET, rawBody))>
29```
30
31Both are compared with a timing-safe equality check.
32
33### Request body (JSON)
34
35```json
36{
37 "repository": "owner/name",
38 "sha": "0123abcd0123abcd0123abcd0123abcd01234567",
39 "ref": "refs/heads/main",
40 "pullRequestNumber": 42,
41 "status": "passed",
42 "summary": "12 tests passed, 0 failed, 3.4s",
43 "details": { "...": "arbitrary JSON, persisted as-is" },
44 "durationMs": 3400
45}
46```
47
48| Field | Required | Notes |
49|---|---|---|
50| `repository` | ✅ | `"owner/name"` |
51| `sha` | ✅ | full 40-char commit SHA |
52| `status` | ✅ | `"passed"` \| `"failed"` \| `"error"` \| `"success"` |
53| `ref` | optional | defaults to `refs/heads/main` |
54| `pullRequestNumber` | optional | links the gate run to a PR |
55| `summary` | optional | shown on the gates UI |
56| `details` | optional | arbitrary JSON, stored verbatim |
57| `durationMs` | optional | for metrics |
58
59### Response
60
61```json
62{ "ok": true, "gateRunId": "uuid-of-the-inserted-row" }
63```
64
65On failure:
66- `400` — invalid JSON or missing required fields
67- `401` — missing / invalid credentials
68- `404` — repository not known to GlueCron
69- `500` — DB error (retry-safe, idempotent on sha + gate_name)
70
71### Side effects on a `failed` status
721. Row inserted in `gate_runs`
732. In-app notification to the repo owner (kind `gate_failed`)
743. Entry in `audit_log` (action `gate_callback`)
75
76---
77
78## Backup: personal access token
79
80**URL:** `POST /api/v1/gate-runs`
81
82### Auth
83Standard GlueCron PAT, created at `/settings/tokens`:
84```
85Authorization: Bearer glc_<64-hex-chars>
86```
87Alternative header if Bearer isn't possible:
88```
89X-API-Token: glc_<64-hex-chars>
90```
91
92### Request body
93Identical to the primary path, plus optional `gateName`:
94```json
95{
96 "repository": "owner/name",
97 "sha": "...",
98 "status": "passed",
99 "gateName": "GateTest",
100 "summary": "...",
101 "details": { }
102}
103```
104
105### Authorisation rules
106- The PAT's owner must match the repo's owner (MVP rule — will expand with orgs/teams in Block B).
107- Token is rejected if expired.
108- Successful use updates `api_tokens.last_used_at`.
109
110### Listing prior runs
111```
112GET /api/v1/gate-runs?repository=owner/name&limit=20
113Authorization: Bearer glc_...
114```
115
116Returns:
117```json
118{ "ok": true, "runs": [ {...}, ... ] }
119```
120
121---
122
123## Liveness probe (no auth)
124
125```
126GET /api/hooks/ping
127```
128Returns:
129```json
130{
131 "ok": true,
132 "service": "gluecron",
133 "hooks": ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
134 "timestamp": "2026-04-14T..."
135}
136```
137
138Use this in GateTest's connectivity check before trying an authenticated call.
139
140---
141
142## Configuring the secrets
143
144On the GlueCron host (Railway / Fly / Docker) set either or both:
145```
146GATETEST_CALLBACK_SECRET=<long random bearer token>
147GATETEST_HMAC_SECRET=<long random HMAC key>
148```
149
150Generate with:
151```bash
152openssl rand -hex 32
153```
154
155Share the same value with GateTest. **If neither is set, the callback endpoint
156refuses all requests** — there is no anonymous write path by design.
157
158---
159
160## Example curl (primary path)
161
162```bash
163curl -X POST "https://gluecron.com/api/hooks/gatetest" \
164 -H "Authorization: Bearer $GATETEST_CALLBACK_SECRET" \
165 -H "Content-Type: application/json" \
166 -d '{
167 "repository": "alice/webapp",
168 "sha": "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b",
169 "ref": "refs/heads/main",
170 "status": "failed",
171 "summary": "2 tests failed in src/auth.test.ts",
172 "durationMs": 4812
173 }'
174```
175
176Response:
177```json
178{ "ok": true, "gateRunId": "b7f3e2a1-..." }
179```
180
181The repo owner immediately sees a red notification; the run shows up at
182`/<owner>/<repo>/gates`.
AddedLAUNCH_TODAY.md+140−0View fileUnifiedSplit
1# LAUNCH TODAY — exact steps to get GlueCron live
2
3The app is deploy-ready. Tests pass (76/76). Boot verified. Migrations included. Dockerfile + Railway + Fly configs shipped.
4
5You need **two secrets** and **one deploy command**. That's it.
6
7---
8
9## A. What you need (3 minutes)
10
111. **Neon Postgres database** — free tier at https://neon.tech
12 - Create a project → copy the "pooled" connection string.
13 - This becomes `DATABASE_URL`.
142. **Anthropic API key** — https://console.anthropic.com
15 - Create a key → `ANTHROPIC_API_KEY` (optional: all AI features gracefully degrade without it, but you'll want this for the differentiator features).
163. **A deploy target** — pick one:
17 - Railway (easiest, `railway.toml` already configured)
18 - Fly.io (has `fly.toml` with persistent volume for git repos)
19 - Any Docker host (Render, Koyeb, DO App Platform, a VPS — `Dockerfile` works anywhere)
20
21---
22
23## B. Railway (fastest path — ~5 minutes)
24
25```bash
26# 1. Install Railway CLI (one-time)
27npm i -g @railway/cli
28
29# 2. From the repo root
30railway login
31railway link # create or pick a project
32railway variables set DATABASE_URL="postgresql://..."
33railway variables set ANTHROPIC_API_KEY="sk-ant-..."
34railway up # builds Dockerfile, runs db:migrate via releaseCommand, starts server
35```
36
37Railway gives you a live URL like `https://gluecron-production.up.railway.app`.
38
39Add your custom domain (`gluecron.com`) in the Railway dashboard → Settings → Networking.
40
41---
42
43## C. Fly.io (persistent volume for git repos — ~8 minutes)
44
45```bash
46# 1. Install flyctl (one-time)
47curl -L https://fly.io/install.sh | sh
48
49# 2. From the repo root
50fly auth login
51fly launch --no-deploy # accepts existing fly.toml
52fly volumes create gluecron_repos --size 10 --region lhr
53fly secrets set DATABASE_URL="postgresql://..."
54fly secrets set ANTHROPIC_API_KEY="sk-ant-..."
55fly deploy
56```
57
58Fly gives you `https://gluecron.fly.dev`. Point your domain with:
59```bash
60fly certs add gluecron.com
61```
62
63---
64
65## D. Any Docker host
66
67```bash
68docker build -t gluecron .
69docker run -d \
70 -p 3000:3000 \
71 -e DATABASE_URL="postgresql://..." \
72 -e ANTHROPIC_API_KEY="sk-ant-..." \
73 -v gluecron_repos:/app/repos \
74 gluecron
75
76# Run migrations once:
77docker run --rm \
78 -e DATABASE_URL="postgresql://..." \
79 gluecron bun run db:migrate
80```
81
82---
83
84## E. First-boot checklist (after deploy)
85
861. Visit `https://your-url/healthz``{"ok":true,...}`
872. Visit `https://your-url/readyz``{"ok":true}` (confirms DB connectivity)
883. Visit `https://your-url/register` → create the first admin account
894. Visit `https://your-url/new` → create a repo (auto-configures with green defaults)
905. Clone it: `git clone https://your-url/<owner>/<repo>.git` — confirms Smart HTTP works
916. Push a commit → post-receive hook fires GateTest + secret scan + webhook fan-out
92
93---
94
95## F. Custom domain (gluecron.com)
96
97DNS:
98- `A` or `CNAME` → your deploy host (Railway / Fly / Docker box)
99- If using Railway, they issue the cert automatically
100- If using Fly, run `fly certs add gluecron.com` after DNS is pointed
101- If using a VPS, terminate TLS with Caddy / nginx / Cloudflare in front
102
103---
104
105## G. Post-launch hardening (day 1–3)
106
107These are already shipped and will just start working:
108- ✅ Rate limiting (`/api/*` 120/min, `/login` 20/min, `/register` 10/min)
109- ✅ Health + readiness + metrics endpoints
110- ✅ Request-ID tracing on every response
111- ✅ Secret scanner on every push
112- ✅ AI security review on every push (if `ANTHROPIC_API_KEY` set)
113- ✅ Auto-repair engine (if `ANTHROPIC_API_KEY` set)
114- ✅ CODEOWNERS auto-sync
115- ✅ Notifications + dashboard + audit log
116
117Observability you might want to add later (Block F in BUILD_BIBLE.md):
118- Ship `/metrics` to Grafana / Datadog / Prometheus
119- Wire error tracking (Sentry) — one-file addition
120- Email digests (currently in-app only)
121
122---
123
124## H. What fails gracefully if you skip secrets
125
126| Missing | Effect |
127|---|---|
128| `DATABASE_URL` | App boots, `/healthz` returns 200, any DB route returns 500. Don't deploy without it. |
129| `ANTHROPIC_API_KEY` | All AI features return safe fallback strings. Site fully usable as a git host. |
130| `GATETEST_API_KEY` | GateTest integration silently skipped. Local gates still run. |
131
132---
133
134## I. If anything goes wrong
135
136- Check `/readyz` — tells you if DB is reachable.
137- Check `/metrics` — process health snapshot.
138- Container logs show every request with latency + status.
139- Every request has `X-Request-Id` header — grep logs by that ID.
140- `bun test` in the container proves the build is sound.
Modifiedbun.lock+54−0View fileUnifiedSplit
55 "": {
66 "name": "gluecron",
77 "dependencies": {
8 "@anthropic-ai/sdk": "^0.88.0",
89 "@hono/node-server": "^1.13.0",
910 "@neondatabase/serverless": "^0.10.0",
11 "@simplewebauthn/server": "^13.3.0",
1012 "drizzle-orm": "^0.39.0",
1113 "highlight.js": "^11.11.0",
1214 "hono": "^4.7.0",
2022 },
2123 },
2224 "packages": {
25 "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.88.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-QQOtB5U9ZBJQj6y1ICmDZl14LWa4JCiJRoihI+0yuZ4OjbONrakP0yLwPv4DJFb3VYCtQM31bTOpCBMs2zghPw=="],
26
27 "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
28
2329 "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
2430
2531 "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
7278
7379 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
7480
81 "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
82
7583 "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
7684
85 "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
86
7787 "@neondatabase/serverless": ["@neondatabase/serverless@0.10.4", "", { "dependencies": { "@types/pg": "8.11.6" } }, "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA=="],
7888
89 "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
90
91 "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw=="],
92
93 "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w=="],
94
95 "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g=="],
96
97 "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw=="],
98
99 "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw=="],
100
101 "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.1", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.1", "@peculiar/asn1-pfx": "^2.6.1", "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw=="],
102
103 "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA=="],
104
105 "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
106
107 "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA=="],
108
109 "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ=="],
110
111 "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
112
79113 "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
80114
115 "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
116
81117 "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
82118
83119 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
84120
85121 "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="],
86122
123 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
124
87125 "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
88126
89127 "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
110148
111149 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
112150
151 "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
152
113153 "marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
114154
115155 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
134174
135175 "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
136176
177 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
178
179 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
180
181 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
182
137183 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
138184
139185 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
144190
145191 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
146192
193 "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
194
195 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
196
197 "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
198
147199 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
148200
149201 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
152204
153205 "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
154206
207 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
208
155209 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
156210
157211 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
Addedcli/README.md+36−0View fileUnifiedSplit
1# gluecron CLI
2
3Official CLI for Gluecron. Single Bun-compiled binary; talks to any Gluecron
4server over HTTPS.
5
6## Build
7
8```
9bun build cli/gluecron.ts --compile --outfile gluecron
10./gluecron version
11```
12
13## Commands
14
15```
16gluecron login Save a personal access token
17gluecron whoami Print the logged-in user
18gluecron repo ls [--user <name>] List repos
19gluecron repo show <owner/name> Show a repo
20gluecron repo create <name> [--private]
21 Create a repo
22gluecron issues ls <owner/name> List open issues
23gluecron gql '<query>' Run a GraphQL query
24gluecron host [url] Get or set the server URL
25gluecron version Print version
26```
27
28Config is stored at `~/.gluecron/config.json` with 0600 permissions.
29
30Server URL can be overridden via `GLUECRON_HOST` or `gluecron host <url>`.
31
32## Auth
33
34The CLI uses personal access tokens (PATs). Create one via the web UI at
35`/settings/tokens`. Tokens carry the `glc_` prefix and are sent as
36`Authorization: Bearer <token>`.
Addedcli/gluecron.ts+304−0View fileUnifiedSplit
1#!/usr/bin/env bun
2/**
3 * Block G3 — `gluecron` CLI.
4 *
5 * A dependency-free Bun executable that talks to a Gluecron server using
6 * either the REST `/api/*` endpoints or the GraphQL endpoint at `/api/graphql`.
7 *
8 * gluecron login — store a PAT in ~/.gluecron/config.json
9 * gluecron whoami — print the logged-in user
10 * gluecron repo ls — list repos for the logged-in user
11 * gluecron repo show <owner/name> — pretty-print a repo
12 * gluecron repo create <name> — create a repo for the logged-in user
13 * gluecron issues ls <owner/name> — list open issues
14 * gluecron gql '<query>' — run a GraphQL query verbatim
15 *
16 * Build: bun build cli/gluecron.ts --compile --outfile gluecron
17 * Install: cp gluecron /usr/local/bin/
18 */
19
20import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
21import { homedir } from "node:os";
22import { join } from "node:path";
23
24const VERSION = "0.1.0";
25const DEFAULT_HOST = process.env.GLUECRON_HOST || "http://localhost:3000";
26const CONFIG_DIR = join(homedir(), ".gluecron");
27const CONFIG_FILE = join(CONFIG_DIR, "config.json");
28
29// ---------- Config ----------
30
31interface Config {
32 host: string;
33 token?: string;
34 username?: string;
35}
36
37export function loadConfig(): Config {
38 try {
39 if (existsSync(CONFIG_FILE)) {
40 const raw = readFileSync(CONFIG_FILE, "utf8");
41 const parsed = JSON.parse(raw) as Partial<Config>;
42 return {
43 host: parsed.host || DEFAULT_HOST,
44 token: parsed.token,
45 username: parsed.username,
46 };
47 }
48 } catch {
49 // fall through
50 }
51 return { host: DEFAULT_HOST };
52}
53
54export function saveConfig(cfg: Config) {
55 mkdirSync(CONFIG_DIR, { recursive: true });
56 writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), {
57 mode: 0o600,
58 });
59}
60
61// ---------- HTTP ----------
62
63export async function http(
64 cfg: Config,
65 method: string,
66 path: string,
67 body?: unknown
68): Promise<any> {
69 const url = cfg.host.replace(/\/+$/, "") + path;
70 const headers: Record<string, string> = {
71 "content-type": "application/json",
72 accept: "application/json",
73 };
74 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
75 const res = await fetch(url, {
76 method,
77 headers,
78 body: body ? JSON.stringify(body) : undefined,
79 });
80 const text = await res.text();
81 let json: any;
82 try {
83 json = text ? JSON.parse(text) : {};
84 } catch {
85 json = { _raw: text };
86 }
87 if (!res.ok) {
88 const msg = json?.error || res.statusText || "request failed";
89 throw new Error(`[${res.status}] ${msg}`);
90 }
91 return json;
92}
93
94// ---------- Commands ----------
95
96export const HELP = `gluecron CLI v${VERSION}
97
98Usage:
99 gluecron login Save a personal access token
100 gluecron whoami Print the logged-in user
101 gluecron repo ls [--user <name>] List repos
102 gluecron repo show <owner/name> Show a repo
103 gluecron repo create <name> [--private]
104 Create a repo
105 gluecron issues ls <owner/name> List open issues
106 gluecron gql '<query>' Run a GraphQL query
107 gluecron host [url] Get or set the server URL
108 gluecron version Print version
109 gluecron help Print this help
110
111Env:
112 GLUECRON_HOST override the server URL (default: ${DEFAULT_HOST})
113`;
114
115export async function cmdLogin(
116 cfg: Config,
117 prompt: (q: string) => Promise<string>
118): Promise<Config> {
119 const host =
120 (await prompt(`Server URL [${cfg.host}]: `)) || cfg.host;
121 const token = await prompt("Personal access token (glc_...): ");
122 if (!token) throw new Error("token is required");
123 const next: Config = { host, token };
124 // Probe /api/user/me to confirm
125 const me = await http(next, "GET", "/api/user/me").catch(() => null);
126 if (me?.username) next.username = me.username;
127 saveConfig(next);
128 return next;
129}
130
131export async function cmdWhoami(cfg: Config): Promise<string> {
132 if (!cfg.token) return "(not logged in)";
133 const me = await http(cfg, "GET", "/api/user/me").catch(() => null);
134 if (!me?.username) return cfg.username || "(unknown)";
135 return `${me.username} (${me.email || "no email"})`;
136}
137
138export async function cmdRepoLs(
139 cfg: Config,
140 user?: string
141): Promise<Array<{ owner: string; name: string; visibility: string }>> {
142 const username = user || cfg.username;
143 if (!username) throw new Error("no user context — log in or pass --user");
144 const q = `{ user(username:"${username}") { repos { name visibility } } }`;
145 const r = await http(cfg, "POST", "/api/graphql", { query: q });
146 const repos = r?.data?.user?.repos || [];
147 return repos.map((x: any) => ({
148 owner: username,
149 name: x.name,
150 visibility: x.visibility,
151 }));
152}
153
154export async function cmdRepoShow(
155 cfg: Config,
156 slug: string
157): Promise<Record<string, any>> {
158 const [owner, name] = slug.split("/");
159 if (!owner || !name) throw new Error("expected owner/name");
160 const q = `{ repository(owner:"${owner}", name:"${name}") {
161 name description visibility starCount forkCount
162 owner { username }
163 issues(state:"open", limit:5) { number title }
164 } }`;
165 const r = await http(cfg, "POST", "/api/graphql", { query: q });
166 return r?.data?.repository || null;
167}
168
169export async function cmdRepoCreate(
170 cfg: Config,
171 name: string,
172 isPrivate = false
173): Promise<any> {
174 if (!cfg.username) throw new Error("log in first (gluecron login)");
175 return http(cfg, "POST", "/api/repos", {
176 name,
177 owner: cfg.username,
178 isPrivate,
179 });
180}
181
182export async function cmdIssuesLs(
183 cfg: Config,
184 slug: string
185): Promise<Array<{ number: number; title: string }>> {
186 const [owner, name] = slug.split("/");
187 const q = `{ repository(owner:"${owner}", name:"${name}") { issues(state:"open", limit:50) { number title } } }`;
188 const r = await http(cfg, "POST", "/api/graphql", { query: q });
189 return r?.data?.repository?.issues || [];
190}
191
192export async function cmdGql(cfg: Config, query: string): Promise<any> {
193 return http(cfg, "POST", "/api/graphql", { query });
194}
195
196// ---------- Dispatcher ----------
197
198export async function dispatch(argv: string[], out = console.log): Promise<number> {
199 const cfg = loadConfig();
200 const [cmd, ...rest] = argv;
201
202 if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
203 out(HELP);
204 return 0;
205 }
206 if (cmd === "version" || cmd === "--version" || cmd === "-v") {
207 out(VERSION);
208 return 0;
209 }
210
211 try {
212 switch (cmd) {
213 case "host": {
214 if (rest[0]) {
215 cfg.host = rest[0];
216 saveConfig(cfg);
217 }
218 out(cfg.host);
219 return 0;
220 }
221 case "login": {
222 const { default: readline } = await import("node:readline/promises");
223 const rl = readline.createInterface({
224 input: process.stdin,
225 output: process.stdout,
226 });
227 const next = await cmdLogin(cfg, (q) => rl.question(q));
228 rl.close();
229 out(`Logged in as ${next.username || "(unknown)"}`);
230 return 0;
231 }
232 case "whoami":
233 out(await cmdWhoami(cfg));
234 return 0;
235 case "repo": {
236 const sub = rest[0];
237 if (sub === "ls") {
238 const userFlagIdx = rest.indexOf("--user");
239 const user = userFlagIdx >= 0 ? rest[userFlagIdx + 1] : undefined;
240 const repos = await cmdRepoLs(cfg, user);
241 for (const r of repos) {
242 out(` ${r.owner}/${r.name} · ${r.visibility}`);
243 }
244 return 0;
245 }
246 if (sub === "show") {
247 const repo = await cmdRepoShow(cfg, rest[1]);
248 if (!repo) {
249 out("(not found)");
250 return 1;
251 }
252 out(JSON.stringify(repo, null, 2));
253 return 0;
254 }
255 if (sub === "create") {
256 const isPrivate = rest.includes("--private");
257 const name = rest.find((x, i) => i > 0 && !x.startsWith("--"));
258 if (!name) {
259 out("usage: gluecron repo create <name> [--private]");
260 return 1;
261 }
262 const r = await cmdRepoCreate(cfg, name, isPrivate);
263 out(JSON.stringify(r, null, 2));
264 return 0;
265 }
266 out("usage: gluecron repo (ls|show|create)");
267 return 1;
268 }
269 case "issues": {
270 if (rest[0] !== "ls" || !rest[1]) {
271 out("usage: gluecron issues ls <owner/name>");
272 return 1;
273 }
274 const issues = await cmdIssuesLs(cfg, rest[1]);
275 for (const i of issues) {
276 out(` #${i.number} ${i.title}`);
277 }
278 return 0;
279 }
280 case "gql": {
281 if (!rest[0]) {
282 out("usage: gluecron gql '<query>'");
283 return 1;
284 }
285 const r = await cmdGql(cfg, rest.join(" "));
286 out(JSON.stringify(r, null, 2));
287 return 0;
288 }
289 default:
290 out(`unknown command: ${cmd}\n`);
291 out(HELP);
292 return 1;
293 }
294 } catch (err) {
295 out(`error: ${(err as Error).message}`);
296 return 1;
297 }
298}
299
300// Entry
301if (import.meta.main) {
302 const code = await dispatch(process.argv.slice(2));
303 process.exit(code);
304}
Addeddrizzle/0000_initial.sql+248−0View fileUnifiedSplit
1-- Gluecron initial migration
2-- Generated manually to match src/db/schema.ts
3
4--> statement-breakpoint
5CREATE TABLE IF NOT EXISTS "users" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
7 "username" text NOT NULL,
8 "email" text NOT NULL,
9 "display_name" text,
10 "password_hash" text NOT NULL,
11 "avatar_url" text,
12 "bio" text,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 "updated_at" timestamp DEFAULT now() NOT NULL,
15 CONSTRAINT "users_username_unique" UNIQUE ("username"),
16 CONSTRAINT "users_email_unique" UNIQUE ("email")
17);
18
19--> statement-breakpoint
20CREATE TABLE IF NOT EXISTS "sessions" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
22 "user_id" uuid NOT NULL,
23 "token" text NOT NULL,
24 "expires_at" timestamp NOT NULL,
25 "created_at" timestamp DEFAULT now() NOT NULL,
26 CONSTRAINT "sessions_token_unique" UNIQUE ("token"),
27 CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
28);
29
30--> statement-breakpoint
31CREATE TABLE IF NOT EXISTS "repositories" (
32 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33 "name" text NOT NULL,
34 "owner_id" uuid NOT NULL,
35 "description" text,
36 "is_private" boolean DEFAULT false NOT NULL,
37 "default_branch" text DEFAULT 'main' NOT NULL,
38 "disk_path" text NOT NULL,
39 "forked_from_id" uuid,
40 "created_at" timestamp DEFAULT now() NOT NULL,
41 "updated_at" timestamp DEFAULT now() NOT NULL,
42 "pushed_at" timestamp,
43 "star_count" integer DEFAULT 0 NOT NULL,
44 "fork_count" integer DEFAULT 0 NOT NULL,
45 "issue_count" integer DEFAULT 0 NOT NULL,
46 CONSTRAINT "repositories_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
47 CONSTRAINT "repositories_forked_from_id_repositories_id_fk" FOREIGN KEY ("forked_from_id") REFERENCES "repositories" ("id") ON DELETE SET NULL
48);
49
50--> statement-breakpoint
51CREATE UNIQUE INDEX IF NOT EXISTS "repos_owner_name" ON "repositories" ("owner_id", "name");
52
53--> statement-breakpoint
54CREATE TABLE IF NOT EXISTS "stars" (
55 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
56 "user_id" uuid NOT NULL,
57 "repository_id" uuid NOT NULL,
58 "created_at" timestamp DEFAULT now() NOT NULL,
59 CONSTRAINT "stars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
60 CONSTRAINT "stars_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
61);
62
63--> statement-breakpoint
64CREATE UNIQUE INDEX IF NOT EXISTS "stars_user_repo" ON "stars" ("user_id", "repository_id");
65
66--> statement-breakpoint
67CREATE TABLE IF NOT EXISTS "issues" (
68 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
69 "number" serial NOT NULL,
70 "repository_id" uuid NOT NULL,
71 "author_id" uuid NOT NULL,
72 "title" text NOT NULL,
73 "body" text,
74 "state" text DEFAULT 'open' NOT NULL,
75 "created_at" timestamp DEFAULT now() NOT NULL,
76 "updated_at" timestamp DEFAULT now() NOT NULL,
77 "closed_at" timestamp,
78 CONSTRAINT "issues_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
79 CONSTRAINT "issues_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
80);
81
82--> statement-breakpoint
83CREATE INDEX IF NOT EXISTS "issues_repo_state" ON "issues" ("repository_id", "state");
84
85--> statement-breakpoint
86CREATE INDEX IF NOT EXISTS "issues_repo_number" ON "issues" ("repository_id", "number");
87
88--> statement-breakpoint
89CREATE TABLE IF NOT EXISTS "issue_comments" (
90 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
91 "issue_id" uuid NOT NULL,
92 "author_id" uuid NOT NULL,
93 "body" text NOT NULL,
94 "created_at" timestamp DEFAULT now() NOT NULL,
95 "updated_at" timestamp DEFAULT now() NOT NULL,
96 CONSTRAINT "issue_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "issues" ("id") ON DELETE CASCADE,
97 CONSTRAINT "issue_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
98);
99
100--> statement-breakpoint
101CREATE INDEX IF NOT EXISTS "comments_issue" ON "issue_comments" ("issue_id");
102
103--> statement-breakpoint
104CREATE TABLE IF NOT EXISTS "labels" (
105 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
106 "repository_id" uuid NOT NULL,
107 "name" text NOT NULL,
108 "color" text DEFAULT '#8b949e' NOT NULL,
109 "description" text,
110 CONSTRAINT "labels_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
111);
112
113--> statement-breakpoint
114CREATE UNIQUE INDEX IF NOT EXISTS "labels_repo_name" ON "labels" ("repository_id", "name");
115
116--> statement-breakpoint
117CREATE TABLE IF NOT EXISTS "issue_labels" (
118 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
119 "issue_id" uuid NOT NULL,
120 "label_id" uuid NOT NULL,
121 CONSTRAINT "issue_labels_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "issues" ("id") ON DELETE CASCADE,
122 CONSTRAINT "issue_labels_label_id_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "labels" ("id") ON DELETE CASCADE
123);
124
125--> statement-breakpoint
126CREATE UNIQUE INDEX IF NOT EXISTS "issue_labels_unique" ON "issue_labels" ("issue_id", "label_id");
127
128--> statement-breakpoint
129CREATE TABLE IF NOT EXISTS "pull_requests" (
130 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
131 "number" serial NOT NULL,
132 "repository_id" uuid NOT NULL,
133 "author_id" uuid NOT NULL,
134 "title" text NOT NULL,
135 "body" text,
136 "state" text DEFAULT 'open' NOT NULL,
137 "base_branch" text NOT NULL,
138 "head_branch" text NOT NULL,
139 "merged_at" timestamp,
140 "merged_by" uuid,
141 "created_at" timestamp DEFAULT now() NOT NULL,
142 "updated_at" timestamp DEFAULT now() NOT NULL,
143 "closed_at" timestamp,
144 CONSTRAINT "pull_requests_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
145 CONSTRAINT "pull_requests_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id"),
146 CONSTRAINT "pull_requests_merged_by_users_id_fk" FOREIGN KEY ("merged_by") REFERENCES "users" ("id")
147);
148
149--> statement-breakpoint
150CREATE INDEX IF NOT EXISTS "prs_repo_state" ON "pull_requests" ("repository_id", "state");
151
152--> statement-breakpoint
153CREATE INDEX IF NOT EXISTS "prs_repo_number" ON "pull_requests" ("repository_id", "number");
154
155--> statement-breakpoint
156CREATE TABLE IF NOT EXISTS "pr_comments" (
157 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
158 "pull_request_id" uuid NOT NULL,
159 "author_id" uuid NOT NULL,
160 "body" text NOT NULL,
161 "is_ai_review" boolean DEFAULT false NOT NULL,
162 "file_path" text,
163 "line_number" integer,
164 "created_at" timestamp DEFAULT now() NOT NULL,
165 "updated_at" timestamp DEFAULT now() NOT NULL,
166 CONSTRAINT "pr_comments_pull_request_id_pull_requests_id_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE,
167 CONSTRAINT "pr_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
168);
169
170--> statement-breakpoint
171CREATE INDEX IF NOT EXISTS "pr_comments_pr" ON "pr_comments" ("pull_request_id");
172
173--> statement-breakpoint
174CREATE TABLE IF NOT EXISTS "activity_feed" (
175 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
176 "repository_id" uuid NOT NULL,
177 "user_id" uuid,
178 "action" text NOT NULL,
179 "target_type" text,
180 "target_id" text,
181 "metadata" text,
182 "created_at" timestamp DEFAULT now() NOT NULL,
183 CONSTRAINT "activity_feed_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
184 CONSTRAINT "activity_feed_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id")
185);
186
187--> statement-breakpoint
188CREATE INDEX IF NOT EXISTS "activity_repo" ON "activity_feed" ("repository_id");
189
190--> statement-breakpoint
191CREATE INDEX IF NOT EXISTS "activity_user" ON "activity_feed" ("user_id");
192
193--> statement-breakpoint
194CREATE TABLE IF NOT EXISTS "webhooks" (
195 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
196 "repository_id" uuid NOT NULL,
197 "url" text NOT NULL,
198 "secret" text,
199 "events" text DEFAULT 'push' NOT NULL,
200 "is_active" boolean DEFAULT true NOT NULL,
201 "last_delivered_at" timestamp,
202 "last_status" integer,
203 "created_at" timestamp DEFAULT now() NOT NULL,
204 CONSTRAINT "webhooks_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
205);
206
207--> statement-breakpoint
208CREATE INDEX IF NOT EXISTS "webhooks_repo" ON "webhooks" ("repository_id");
209
210--> statement-breakpoint
211CREATE TABLE IF NOT EXISTS "api_tokens" (
212 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
213 "user_id" uuid NOT NULL,
214 "name" text NOT NULL,
215 "token_hash" text NOT NULL,
216 "token_prefix" text NOT NULL,
217 "scopes" text DEFAULT 'repo' NOT NULL,
218 "last_used_at" timestamp,
219 "expires_at" timestamp,
220 "created_at" timestamp DEFAULT now() NOT NULL,
221 CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
222);
223
224--> statement-breakpoint
225CREATE TABLE IF NOT EXISTS "repo_topics" (
226 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
227 "repository_id" uuid NOT NULL,
228 "topic" text NOT NULL,
229 CONSTRAINT "repo_topics_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
230);
231
232--> statement-breakpoint
233CREATE UNIQUE INDEX IF NOT EXISTS "repo_topics_unique" ON "repo_topics" ("repository_id", "topic");
234
235--> statement-breakpoint
236CREATE INDEX IF NOT EXISTS "topics_name" ON "repo_topics" ("topic");
237
238--> statement-breakpoint
239CREATE TABLE IF NOT EXISTS "ssh_keys" (
240 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
241 "user_id" uuid NOT NULL,
242 "title" text NOT NULL,
243 "fingerprint" text NOT NULL,
244 "public_key" text NOT NULL,
245 "last_used_at" timestamp,
246 "created_at" timestamp DEFAULT now() NOT NULL,
247 CONSTRAINT "ssh_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
248);
Addeddrizzle/0001_green_ecosystem.sql+274−0View fileUnifiedSplit
1-- Gluecron migration 0001: green ecosystem — advanced platform features
2-- Adds: repo_settings, branch_protection, gate_runs, notifications, releases,
3-- milestones, reactions, pr_reviews, code_owners, ai_chats, audit_log,
4-- deployments, rate_limit_buckets, plus new columns on existing tables.
5
6--> statement-breakpoint
7ALTER TABLE "repositories" ADD COLUMN IF NOT EXISTS "is_archived" boolean DEFAULT false NOT NULL;
8
9--> statement-breakpoint
10ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "is_draft" boolean DEFAULT false NOT NULL;
11
12--> statement-breakpoint
13ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "merge_strategy" text DEFAULT 'merge' NOT NULL;
14
15--> statement-breakpoint
16ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "milestone_id" uuid;
17
18--> statement-breakpoint
19CREATE TABLE IF NOT EXISTS "repo_settings" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "repository_id" uuid NOT NULL UNIQUE,
22 "gate_test_enabled" boolean DEFAULT true NOT NULL,
23 "ai_review_enabled" boolean DEFAULT true NOT NULL,
24 "secret_scan_enabled" boolean DEFAULT true NOT NULL,
25 "security_scan_enabled" boolean DEFAULT true NOT NULL,
26 "dependency_scan_enabled" boolean DEFAULT true NOT NULL,
27 "lint_enabled" boolean DEFAULT true NOT NULL,
28 "type_check_enabled" boolean DEFAULT true NOT NULL,
29 "test_enabled" boolean DEFAULT true NOT NULL,
30 "auto_fix_enabled" boolean DEFAULT true NOT NULL,
31 "auto_merge_resolve_enabled" boolean DEFAULT true NOT NULL,
32 "auto_format_enabled" boolean DEFAULT true NOT NULL,
33 "ai_commit_messages_enabled" boolean DEFAULT true NOT NULL,
34 "ai_pr_summary_enabled" boolean DEFAULT true NOT NULL,
35 "ai_changelog_enabled" boolean DEFAULT true NOT NULL,
36 "auto_deploy_enabled" boolean DEFAULT true NOT NULL,
37 "deploy_require_all_green" boolean DEFAULT true NOT NULL,
38 "created_at" timestamp DEFAULT now() NOT NULL,
39 "updated_at" timestamp DEFAULT now() NOT NULL,
40 CONSTRAINT "repo_settings_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
41);
42
43--> statement-breakpoint
44CREATE TABLE IF NOT EXISTS "branch_protection" (
45 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
46 "repository_id" uuid NOT NULL,
47 "pattern" text NOT NULL,
48 "require_pull_request" boolean DEFAULT true NOT NULL,
49 "require_green_gates" boolean DEFAULT true NOT NULL,
50 "require_ai_approval" boolean DEFAULT true NOT NULL,
51 "require_human_review" boolean DEFAULT false NOT NULL,
52 "required_approvals" integer DEFAULT 0 NOT NULL,
53 "allow_force_push" boolean DEFAULT false NOT NULL,
54 "allow_deletion" boolean DEFAULT false NOT NULL,
55 "dismiss_stale_reviews" boolean DEFAULT true NOT NULL,
56 "created_at" timestamp DEFAULT now() NOT NULL,
57 "updated_at" timestamp DEFAULT now() NOT NULL,
58 CONSTRAINT "branch_protection_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
59);
60
61--> statement-breakpoint
62CREATE UNIQUE INDEX IF NOT EXISTS "branch_protection_repo_pattern" ON "branch_protection" ("repository_id", "pattern");
63
64--> statement-breakpoint
65CREATE TABLE IF NOT EXISTS "gate_runs" (
66 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
67 "repository_id" uuid NOT NULL,
68 "pull_request_id" uuid,
69 "commit_sha" text NOT NULL,
70 "ref" text NOT NULL,
71 "gate_name" text NOT NULL,
72 "status" text NOT NULL,
73 "summary" text,
74 "details" text,
75 "repair_attempted" boolean DEFAULT false NOT NULL,
76 "repair_succeeded" boolean DEFAULT false NOT NULL,
77 "repair_commit_sha" text,
78 "duration_ms" integer,
79 "created_at" timestamp DEFAULT now() NOT NULL,
80 "completed_at" timestamp,
81 CONSTRAINT "gate_runs_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
82 CONSTRAINT "gate_runs_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE
83);
84
85--> statement-breakpoint
86CREATE INDEX IF NOT EXISTS "gate_runs_repo_sha" ON "gate_runs" ("repository_id", "commit_sha");
87
88--> statement-breakpoint
89CREATE INDEX IF NOT EXISTS "gate_runs_pr" ON "gate_runs" ("pull_request_id");
90
91--> statement-breakpoint
92CREATE INDEX IF NOT EXISTS "gate_runs_created" ON "gate_runs" ("created_at");
93
94--> statement-breakpoint
95CREATE TABLE IF NOT EXISTS "notifications" (
96 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
97 "user_id" uuid NOT NULL,
98 "repository_id" uuid,
99 "kind" text NOT NULL,
100 "title" text NOT NULL,
101 "body" text,
102 "url" text,
103 "read_at" timestamp,
104 "created_at" timestamp DEFAULT now() NOT NULL,
105 CONSTRAINT "notifications_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
106 CONSTRAINT "notifications_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
107);
108
109--> statement-breakpoint
110CREATE INDEX IF NOT EXISTS "notifications_user_unread" ON "notifications" ("user_id", "read_at");
111
112--> statement-breakpoint
113CREATE INDEX IF NOT EXISTS "notifications_user_created" ON "notifications" ("user_id", "created_at");
114
115--> statement-breakpoint
116CREATE TABLE IF NOT EXISTS "releases" (
117 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
118 "repository_id" uuid NOT NULL,
119 "author_id" uuid NOT NULL,
120 "tag" text NOT NULL,
121 "name" text NOT NULL,
122 "body" text,
123 "target_commit" text NOT NULL,
124 "is_draft" boolean DEFAULT false NOT NULL,
125 "is_prerelease" boolean DEFAULT false NOT NULL,
126 "created_at" timestamp DEFAULT now() NOT NULL,
127 "published_at" timestamp,
128 CONSTRAINT "releases_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
129 CONSTRAINT "releases_author_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
130);
131
132--> statement-breakpoint
133CREATE UNIQUE INDEX IF NOT EXISTS "releases_repo_tag" ON "releases" ("repository_id", "tag");
134
135--> statement-breakpoint
136CREATE TABLE IF NOT EXISTS "milestones" (
137 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
138 "repository_id" uuid NOT NULL,
139 "title" text NOT NULL,
140 "description" text,
141 "state" text DEFAULT 'open' NOT NULL,
142 "due_date" timestamp,
143 "created_at" timestamp DEFAULT now() NOT NULL,
144 "closed_at" timestamp,
145 CONSTRAINT "milestones_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
146);
147
148--> statement-breakpoint
149CREATE INDEX IF NOT EXISTS "milestones_repo_state" ON "milestones" ("repository_id", "state");
150
151--> statement-breakpoint
152CREATE TABLE IF NOT EXISTS "reactions" (
153 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
154 "user_id" uuid NOT NULL,
155 "target_type" text NOT NULL,
156 "target_id" uuid NOT NULL,
157 "emoji" text NOT NULL,
158 "created_at" timestamp DEFAULT now() NOT NULL,
159 CONSTRAINT "reactions_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
160);
161
162--> statement-breakpoint
163CREATE UNIQUE INDEX IF NOT EXISTS "reactions_unique" ON "reactions" ("user_id", "target_type", "target_id", "emoji");
164
165--> statement-breakpoint
166CREATE INDEX IF NOT EXISTS "reactions_target" ON "reactions" ("target_type", "target_id");
167
168--> statement-breakpoint
169CREATE TABLE IF NOT EXISTS "pr_reviews" (
170 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
171 "pull_request_id" uuid NOT NULL,
172 "reviewer_id" uuid NOT NULL,
173 "state" text NOT NULL,
174 "body" text,
175 "is_ai" boolean DEFAULT false NOT NULL,
176 "created_at" timestamp DEFAULT now() NOT NULL,
177 CONSTRAINT "pr_reviews_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE,
178 CONSTRAINT "pr_reviews_reviewer_fk" FOREIGN KEY ("reviewer_id") REFERENCES "users" ("id")
179);
180
181--> statement-breakpoint
182CREATE INDEX IF NOT EXISTS "pr_reviews_pr" ON "pr_reviews" ("pull_request_id");
183
184--> statement-breakpoint
185CREATE TABLE IF NOT EXISTS "code_owners" (
186 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
187 "repository_id" uuid NOT NULL,
188 "path_pattern" text NOT NULL,
189 "owner_usernames" text NOT NULL,
190 "created_at" timestamp DEFAULT now() NOT NULL,
191 CONSTRAINT "code_owners_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
192);
193
194--> statement-breakpoint
195CREATE INDEX IF NOT EXISTS "code_owners_repo" ON "code_owners" ("repository_id");
196
197--> statement-breakpoint
198CREATE TABLE IF NOT EXISTS "ai_chats" (
199 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
200 "user_id" uuid NOT NULL,
201 "repository_id" uuid,
202 "title" text,
203 "messages" text DEFAULT '[]' NOT NULL,
204 "created_at" timestamp DEFAULT now() NOT NULL,
205 "updated_at" timestamp DEFAULT now() NOT NULL,
206 CONSTRAINT "ai_chats_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
207 CONSTRAINT "ai_chats_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
208);
209
210--> statement-breakpoint
211CREATE INDEX IF NOT EXISTS "ai_chats_user" ON "ai_chats" ("user_id");
212
213--> statement-breakpoint
214CREATE INDEX IF NOT EXISTS "ai_chats_repo" ON "ai_chats" ("repository_id");
215
216--> statement-breakpoint
217CREATE TABLE IF NOT EXISTS "audit_log" (
218 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
219 "user_id" uuid,
220 "repository_id" uuid,
221 "action" text NOT NULL,
222 "target_type" text,
223 "target_id" text,
224 "ip" text,
225 "user_agent" text,
226 "metadata" text,
227 "created_at" timestamp DEFAULT now() NOT NULL,
228 CONSTRAINT "audit_log_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL,
229 CONSTRAINT "audit_log_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE SET NULL
230);
231
232--> statement-breakpoint
233CREATE INDEX IF NOT EXISTS "audit_log_user" ON "audit_log" ("user_id");
234
235--> statement-breakpoint
236CREATE INDEX IF NOT EXISTS "audit_log_repo" ON "audit_log" ("repository_id");
237
238--> statement-breakpoint
239CREATE INDEX IF NOT EXISTS "audit_log_created" ON "audit_log" ("created_at");
240
241--> statement-breakpoint
242CREATE TABLE IF NOT EXISTS "deployments" (
243 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
244 "repository_id" uuid NOT NULL,
245 "environment" text DEFAULT 'production' NOT NULL,
246 "commit_sha" text NOT NULL,
247 "ref" text NOT NULL,
248 "status" text NOT NULL,
249 "blocked_reason" text,
250 "target" text,
251 "triggered_by" uuid,
252 "created_at" timestamp DEFAULT now() NOT NULL,
253 "completed_at" timestamp,
254 CONSTRAINT "deployments_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
255 CONSTRAINT "deployments_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users" ("id")
256);
257
258--> statement-breakpoint
259CREATE INDEX IF NOT EXISTS "deployments_repo" ON "deployments" ("repository_id");
260
261--> statement-breakpoint
262CREATE INDEX IF NOT EXISTS "deployments_created" ON "deployments" ("created_at");
263
264--> statement-breakpoint
265CREATE TABLE IF NOT EXISTS "rate_limit_buckets" (
266 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
267 "bucket_key" text NOT NULL UNIQUE,
268 "count" integer DEFAULT 0 NOT NULL,
269 "window_start" timestamp DEFAULT now() NOT NULL,
270 "expires_at" timestamp NOT NULL
271);
272
273--> statement-breakpoint
274CREATE INDEX IF NOT EXISTS "rate_limit_expires" ON "rate_limit_buckets" ("expires_at");
Addeddrizzle/0002_saved_replies_and_email.sql+26−0View fileUnifiedSplit
1-- Gluecron migration 0002:
2-- - saved_replies (Block A6)
3-- - users.notify_email_on_mention, users.notify_email_on_assign (Block A8)
4
5--> statement-breakpoint
6CREATE TABLE IF NOT EXISTS "saved_replies" (
7 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
8 "user_id" uuid NOT NULL,
9 "shortcut" text NOT NULL,
10 "body" text NOT NULL,
11 "created_at" timestamp DEFAULT now() NOT NULL,
12 "updated_at" timestamp DEFAULT now() NOT NULL,
13 CONSTRAINT "saved_replies_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
14);
15
16--> statement-breakpoint
17CREATE UNIQUE INDEX IF NOT EXISTS "saved_replies_user_shortcut" ON "saved_replies" ("user_id", "shortcut");
18
19--> statement-breakpoint
20ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "notify_email_on_mention" boolean DEFAULT true NOT NULL;
21
22--> statement-breakpoint
23ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "notify_email_on_assign" boolean DEFAULT true NOT NULL;
24
25--> statement-breakpoint
26ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "notify_email_on_gate_fail" boolean DEFAULT true NOT NULL;
Addeddrizzle/0003_orgs_and_teams.sql+66−0View fileUnifiedSplit
1-- Gluecron migration 0003: Block B1 — organizations + teams.
2-- Schema is additive; does not touch existing tables.
3
4--> statement-breakpoint
5CREATE TABLE IF NOT EXISTS "organizations" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
7 "slug" text NOT NULL UNIQUE,
8 "name" text NOT NULL,
9 "description" text,
10 "avatar_url" text,
11 "billing_email" text,
12 "created_by_id" uuid NOT NULL,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 "updated_at" timestamp DEFAULT now() NOT NULL,
15 CONSTRAINT "organizations_created_by_fk" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE restrict
16);
17
18--> statement-breakpoint
19CREATE TABLE IF NOT EXISTS "org_members" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "org_id" uuid NOT NULL,
22 "user_id" uuid NOT NULL,
23 "role" text NOT NULL DEFAULT 'member',
24 "created_at" timestamp DEFAULT now() NOT NULL,
25 CONSTRAINT "org_members_org_fk" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade,
26 CONSTRAINT "org_members_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
27);
28
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "org_members_unique" ON "org_members" ("org_id", "user_id");
31
32--> statement-breakpoint
33CREATE INDEX IF NOT EXISTS "org_members_user" ON "org_members" ("user_id");
34
35--> statement-breakpoint
36CREATE TABLE IF NOT EXISTS "teams" (
37 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
38 "org_id" uuid NOT NULL,
39 "slug" text NOT NULL,
40 "name" text NOT NULL,
41 "description" text,
42 "parent_team_id" uuid,
43 "created_at" timestamp DEFAULT now() NOT NULL,
44 "updated_at" timestamp DEFAULT now() NOT NULL,
45 CONSTRAINT "teams_org_fk" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE cascade
46);
47
48--> statement-breakpoint
49CREATE UNIQUE INDEX IF NOT EXISTS "teams_org_slug" ON "teams" ("org_id", "slug");
50
51--> statement-breakpoint
52CREATE TABLE IF NOT EXISTS "team_members" (
53 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
54 "team_id" uuid NOT NULL,
55 "user_id" uuid NOT NULL,
56 "role" text NOT NULL DEFAULT 'member',
57 "created_at" timestamp DEFAULT now() NOT NULL,
58 CONSTRAINT "team_members_team_fk" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE cascade,
59 CONSTRAINT "team_members_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
60);
61
62--> statement-breakpoint
63CREATE UNIQUE INDEX IF NOT EXISTS "team_members_unique" ON "team_members" ("team_id", "user_id");
64
65--> statement-breakpoint
66CREATE INDEX IF NOT EXISTS "team_members_user" ON "team_members" ("user_id");
Addeddrizzle/0004_org_owned_repos.sql+36−0View fileUnifiedSplit
1-- Gluecron migration 0004: Block B2 — repositories can be owned by an org.
2-- Adds repositories.org_id (nullable) + partial unique indexes so a user and
3-- an org can each have a repo named "web" without collision, but two orgs
4-- (or two users) still cannot.
5
6--> statement-breakpoint
7ALTER TABLE "repositories" ADD COLUMN IF NOT EXISTS "org_id" uuid;
8
9--> statement-breakpoint
10ALTER TABLE "repositories"
11 DROP CONSTRAINT IF EXISTS "repositories_org_id_fk";
12
13--> statement-breakpoint
14ALTER TABLE "repositories"
15 ADD CONSTRAINT "repositories_org_id_fk"
16 FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE;
17
18--> statement-breakpoint
19-- Existing unique index was on (owner_id, name) across all rows. That would
20-- block the same user from creating a personal "web" AND an org-owned "web"
21-- where they happen to be the listed creator. Make it partial so it only
22-- enforces uniqueness within the user namespace.
23DROP INDEX IF EXISTS "repos_owner_name";
24
25--> statement-breakpoint
26CREATE UNIQUE INDEX IF NOT EXISTS "repos_owner_name"
27 ON "repositories" ("owner_id", "name")
28 WHERE "org_id" IS NULL;
29
30--> statement-breakpoint
31CREATE UNIQUE INDEX IF NOT EXISTS "repos_org_name"
32 ON "repositories" ("org_id", "name")
33 WHERE "org_id" IS NOT NULL;
34
35--> statement-breakpoint
36CREATE INDEX IF NOT EXISTS "repos_org" ON "repositories" ("org_id");
Addeddrizzle/0005_totp_2fa.sql+30−0View fileUnifiedSplit
1-- Gluecron migration 0005: Block B4 — TOTP 2FA + recovery codes.
2
3--> statement-breakpoint
4ALTER TABLE "sessions" ADD COLUMN IF NOT EXISTS "requires_2fa" boolean DEFAULT false NOT NULL;
5
6--> statement-breakpoint
7CREATE TABLE IF NOT EXISTS "user_totp" (
8 "user_id" uuid PRIMARY KEY NOT NULL,
9 "secret" text NOT NULL,
10 "enabled_at" timestamp,
11 "last_used_at" timestamp,
12 "created_at" timestamp DEFAULT now() NOT NULL,
13 CONSTRAINT "user_totp_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
14);
15
16--> statement-breakpoint
17CREATE TABLE IF NOT EXISTS "user_recovery_codes" (
18 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
19 "user_id" uuid NOT NULL,
20 "code_hash" text NOT NULL,
21 "used_at" timestamp,
22 "created_at" timestamp DEFAULT now() NOT NULL,
23 CONSTRAINT "user_recovery_codes_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
24);
25
26--> statement-breakpoint
27CREATE INDEX IF NOT EXISTS "recovery_codes_user" ON "user_recovery_codes" ("user_id");
28
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "recovery_codes_user_hash" ON "user_recovery_codes" ("user_id", "code_hash");
Addeddrizzle/0006_webauthn_passkeys.sql+33−0View fileUnifiedSplit
1-- Gluecron migration 0006: Block B5 — WebAuthn passkeys.
2
3--> statement-breakpoint
4CREATE TABLE IF NOT EXISTS "user_passkeys" (
5 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6 "user_id" uuid NOT NULL,
7 "credential_id" text NOT NULL UNIQUE,
8 "public_key" text NOT NULL,
9 "counter" integer DEFAULT 0 NOT NULL,
10 "transports" text,
11 "name" text NOT NULL DEFAULT 'Passkey',
12 "last_used_at" timestamp,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 CONSTRAINT "user_passkeys_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
15);
16
17--> statement-breakpoint
18CREATE INDEX IF NOT EXISTS "passkeys_user" ON "user_passkeys" ("user_id");
19
20--> statement-breakpoint
21CREATE TABLE IF NOT EXISTS "webauthn_challenges" (
22 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
23 "user_id" uuid,
24 "session_key" text NOT NULL UNIQUE,
25 "challenge" text NOT NULL,
26 "kind" text NOT NULL,
27 "expires_at" timestamp NOT NULL,
28 "created_at" timestamp DEFAULT now() NOT NULL,
29 CONSTRAINT "webauthn_challenges_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
30);
31
32--> statement-breakpoint
33CREATE INDEX IF NOT EXISTS "webauthn_challenges_expires" ON "webauthn_challenges" ("expires_at");
Addeddrizzle/0007_oauth_provider.sql+78−0View fileUnifiedSplit
1-- Gluecron migration 0007: Block B6 — OAuth 2.0 provider.
2--
3-- Tables:
4-- oauth_apps — third-party apps registered by developers
5-- oauth_authorizations — short-lived authorization codes (single-use)
6-- oauth_access_tokens — long-lived bearer tokens + refresh tokens
7--
8-- All secrets / code / token values are stored as SHA-256 hex hashes.
9-- Only the plaintext `clientSecret` is shown to the developer once at
10-- creation; the plaintext auth code and tokens are returned to the client
11-- in the OAuth response and never persisted.
12
13--> statement-breakpoint
14CREATE TABLE IF NOT EXISTS "oauth_apps" (
15 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
16 "owner_id" uuid NOT NULL,
17 "name" text NOT NULL,
18 "client_id" text NOT NULL UNIQUE,
19 "client_secret_hash" text NOT NULL,
20 "client_secret_prefix" text NOT NULL,
21 "redirect_uris" text NOT NULL,
22 "homepage_url" text,
23 "description" text,
24 "confidential" boolean DEFAULT true NOT NULL,
25 "revoked_at" timestamp,
26 "created_at" timestamp DEFAULT now() NOT NULL,
27 "updated_at" timestamp DEFAULT now() NOT NULL,
28 CONSTRAINT "oauth_apps_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
29);
30
31--> statement-breakpoint
32CREATE INDEX IF NOT EXISTS "oauth_apps_owner" ON "oauth_apps" ("owner_id");
33
34--> statement-breakpoint
35CREATE TABLE IF NOT EXISTS "oauth_authorizations" (
36 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37 "app_id" uuid NOT NULL,
38 "user_id" uuid NOT NULL,
39 "code_hash" text NOT NULL UNIQUE,
40 "redirect_uri" text NOT NULL,
41 "scopes" text NOT NULL DEFAULT '',
42 "code_challenge" text,
43 "code_challenge_method" text,
44 "expires_at" timestamp NOT NULL,
45 "used_at" timestamp,
46 "created_at" timestamp DEFAULT now() NOT NULL,
47 CONSTRAINT "oauth_authorizations_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
48 CONSTRAINT "oauth_authorizations_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
49);
50
51--> statement-breakpoint
52CREATE INDEX IF NOT EXISTS "oauth_authorizations_expires" ON "oauth_authorizations" ("expires_at");
53
54--> statement-breakpoint
55CREATE TABLE IF NOT EXISTS "oauth_access_tokens" (
56 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
57 "app_id" uuid NOT NULL,
58 "user_id" uuid NOT NULL,
59 "access_token_hash" text NOT NULL UNIQUE,
60 "refresh_token_hash" text UNIQUE,
61 "scopes" text NOT NULL DEFAULT '',
62 "expires_at" timestamp NOT NULL,
63 "refresh_expires_at" timestamp,
64 "revoked_at" timestamp,
65 "last_used_at" timestamp,
66 "created_at" timestamp DEFAULT now() NOT NULL,
67 CONSTRAINT "oauth_access_tokens_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
68 CONSTRAINT "oauth_access_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
69);
70
71--> statement-breakpoint
72CREATE INDEX IF NOT EXISTS "oauth_access_tokens_user" ON "oauth_access_tokens" ("user_id");
73
74--> statement-breakpoint
75CREATE INDEX IF NOT EXISTS "oauth_access_tokens_app" ON "oauth_access_tokens" ("app_id");
76
77--> statement-breakpoint
78CREATE INDEX IF NOT EXISTS "oauth_access_tokens_expires" ON "oauth_access_tokens" ("expires_at");
Addeddrizzle/0008_workflows.sql+93−0View fileUnifiedSplit
1-- Gluecron migration 0008: Block C1 — Actions-equivalent workflow runner.
2--
3-- Tables:
4-- workflows — parsed workflow YAML files discovered in a repo
5-- workflow_runs — one execution of a workflow, triggered by an event
6-- workflow_jobs — jobs within a run (each is a sequence of steps)
7-- workflow_artifacts — files uploaded by a run (stored in bytea for v1)
8
9--> statement-breakpoint
10CREATE TABLE IF NOT EXISTS "workflows" (
11 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
12 "repository_id" uuid NOT NULL,
13 "name" text NOT NULL,
14 "path" text NOT NULL,
15 "yaml" text NOT NULL,
16 "parsed" text NOT NULL,
17 "on_events" text NOT NULL DEFAULT '[]',
18 "disabled" boolean DEFAULT false NOT NULL,
19 "created_at" timestamp DEFAULT now() NOT NULL,
20 "updated_at" timestamp DEFAULT now() NOT NULL,
21 CONSTRAINT "workflows_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
22);
23
24--> statement-breakpoint
25CREATE INDEX IF NOT EXISTS "workflows_repo" ON "workflows" ("repository_id");
26--> statement-breakpoint
27CREATE UNIQUE INDEX IF NOT EXISTS "workflows_repo_path" ON "workflows" ("repository_id", "path");
28
29--> statement-breakpoint
30CREATE TABLE IF NOT EXISTS "workflow_runs" (
31 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
32 "workflow_id" uuid NOT NULL,
33 "repository_id" uuid NOT NULL,
34 "run_number" integer NOT NULL,
35 "event" text NOT NULL,
36 "ref" text,
37 "commit_sha" text,
38 "triggered_by" uuid,
39 "status" text NOT NULL DEFAULT 'queued',
40 "conclusion" text,
41 "queued_at" timestamp DEFAULT now() NOT NULL,
42 "started_at" timestamp,
43 "finished_at" timestamp,
44 "created_at" timestamp DEFAULT now() NOT NULL,
45 CONSTRAINT "workflow_runs_workflow_fk" FOREIGN KEY ("workflow_id") REFERENCES "workflows"("id") ON DELETE cascade,
46 CONSTRAINT "workflow_runs_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
47 CONSTRAINT "workflow_runs_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE set null
48);
49
50--> statement-breakpoint
51CREATE INDEX IF NOT EXISTS "workflow_runs_repo" ON "workflow_runs" ("repository_id");
52--> statement-breakpoint
53CREATE INDEX IF NOT EXISTS "workflow_runs_status" ON "workflow_runs" ("status");
54--> statement-breakpoint
55CREATE INDEX IF NOT EXISTS "workflow_runs_workflow" ON "workflow_runs" ("workflow_id");
56
57--> statement-breakpoint
58CREATE TABLE IF NOT EXISTS "workflow_jobs" (
59 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
60 "run_id" uuid NOT NULL,
61 "name" text NOT NULL,
62 "job_order" integer NOT NULL DEFAULT 0,
63 "runs_on" text NOT NULL DEFAULT 'default',
64 "status" text NOT NULL DEFAULT 'queued',
65 "conclusion" text,
66 "exit_code" integer,
67 "steps" text NOT NULL DEFAULT '[]',
68 "logs" text NOT NULL DEFAULT '',
69 "started_at" timestamp,
70 "finished_at" timestamp,
71 "created_at" timestamp DEFAULT now() NOT NULL,
72 CONSTRAINT "workflow_jobs_run_fk" FOREIGN KEY ("run_id") REFERENCES "workflow_runs"("id") ON DELETE cascade
73);
74
75--> statement-breakpoint
76CREATE INDEX IF NOT EXISTS "workflow_jobs_run" ON "workflow_jobs" ("run_id");
77
78--> statement-breakpoint
79CREATE TABLE IF NOT EXISTS "workflow_artifacts" (
80 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
81 "run_id" uuid NOT NULL,
82 "job_id" uuid,
83 "name" text NOT NULL,
84 "size_bytes" integer NOT NULL DEFAULT 0,
85 "content_type" text DEFAULT 'application/octet-stream' NOT NULL,
86 "content" bytea,
87 "created_at" timestamp DEFAULT now() NOT NULL,
88 CONSTRAINT "workflow_artifacts_run_fk" FOREIGN KEY ("run_id") REFERENCES "workflow_runs"("id") ON DELETE cascade,
89 CONSTRAINT "workflow_artifacts_job_fk" FOREIGN KEY ("job_id") REFERENCES "workflow_jobs"("id") ON DELETE set null
90);
91
92--> statement-breakpoint
93CREATE INDEX IF NOT EXISTS "workflow_artifacts_run" ON "workflow_artifacts" ("run_id");
Addeddrizzle/0009_packages.sql+65−0View fileUnifiedSplit
1-- Gluecron migration 0009: Block C2 — Package registry (npm-compatible).
2--
3-- Tables:
4-- packages — logical package (one per repo per ecosystem)
5-- package_versions — published versions with tarball stored as bytea
6-- package_tags — dist tags (latest, beta, etc.)
7
8--> statement-breakpoint
9CREATE TABLE IF NOT EXISTS "packages" (
10 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
11 "repository_id" uuid NOT NULL,
12 "ecosystem" text NOT NULL DEFAULT 'npm',
13 "scope" text,
14 "name" text NOT NULL,
15 "description" text,
16 "readme" text,
17 "homepage" text,
18 "license" text,
19 "visibility" text NOT NULL DEFAULT 'public',
20 "created_at" timestamp DEFAULT now() NOT NULL,
21 "updated_at" timestamp DEFAULT now() NOT NULL,
22 CONSTRAINT "packages_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
23);
24
25--> statement-breakpoint
26CREATE INDEX IF NOT EXISTS "packages_repo" ON "packages" ("repository_id");
27--> statement-breakpoint
28CREATE UNIQUE INDEX IF NOT EXISTS "packages_eco_scope_name" ON "packages" ("ecosystem", "scope", "name");
29
30--> statement-breakpoint
31CREATE TABLE IF NOT EXISTS "package_versions" (
32 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33 "package_id" uuid NOT NULL,
34 "version" text NOT NULL,
35 "shasum" text NOT NULL,
36 "integrity" text,
37 "size_bytes" integer NOT NULL DEFAULT 0,
38 "metadata" text NOT NULL DEFAULT '{}',
39 "tarball" text,
40 "published_by" uuid,
41 "yanked" boolean NOT NULL DEFAULT false,
42 "yanked_reason" text,
43 "published_at" timestamp DEFAULT now() NOT NULL,
44 CONSTRAINT "package_versions_pkg_fk" FOREIGN KEY ("package_id") REFERENCES "packages"("id") ON DELETE cascade,
45 CONSTRAINT "package_versions_user_fk" FOREIGN KEY ("published_by") REFERENCES "users"("id") ON DELETE set null
46);
47
48--> statement-breakpoint
49CREATE INDEX IF NOT EXISTS "package_versions_pkg" ON "package_versions" ("package_id");
50--> statement-breakpoint
51CREATE UNIQUE INDEX IF NOT EXISTS "package_versions_pkg_version" ON "package_versions" ("package_id", "version");
52
53--> statement-breakpoint
54CREATE TABLE IF NOT EXISTS "package_tags" (
55 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
56 "package_id" uuid NOT NULL,
57 "tag" text NOT NULL,
58 "version_id" uuid NOT NULL,
59 "updated_at" timestamp DEFAULT now() NOT NULL,
60 CONSTRAINT "package_tags_pkg_fk" FOREIGN KEY ("package_id") REFERENCES "packages"("id") ON DELETE cascade,
61 CONSTRAINT "package_tags_version_fk" FOREIGN KEY ("version_id") REFERENCES "package_versions"("id") ON DELETE cascade
62);
63
64--> statement-breakpoint
65CREATE UNIQUE INDEX IF NOT EXISTS "package_tags_pkg_tag" ON "package_tags" ("package_id", "tag");
Addeddrizzle/0010_pages.sql+34−0View fileUnifiedSplit
1-- Gluecron migration 0010: Block C3 — Pages / static hosting.
2--
3-- Tables:
4-- pages_deployments — recorded every time the source branch advances
5-- pages_settings — per-repo pages config (enabled, source branch/dir, custom domain)
6
7--> statement-breakpoint
8CREATE TABLE IF NOT EXISTS "pages_deployments" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
10 "repository_id" uuid NOT NULL,
11 "ref" text NOT NULL DEFAULT 'refs/heads/gh-pages',
12 "commit_sha" text NOT NULL,
13 "status" text NOT NULL DEFAULT 'success',
14 "triggered_by" uuid,
15 "created_at" timestamp DEFAULT now() NOT NULL,
16 CONSTRAINT "pages_deployments_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
17 CONSTRAINT "pages_deployments_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE set null
18);
19
20--> statement-breakpoint
21CREATE INDEX IF NOT EXISTS "pages_deployments_repo" ON "pages_deployments" ("repository_id");
22--> statement-breakpoint
23CREATE INDEX IF NOT EXISTS "pages_deployments_created" ON "pages_deployments" ("created_at");
24
25--> statement-breakpoint
26CREATE TABLE IF NOT EXISTS "pages_settings" (
27 "repository_id" uuid PRIMARY KEY NOT NULL,
28 "enabled" boolean NOT NULL DEFAULT true,
29 "source_branch" text NOT NULL DEFAULT 'gh-pages',
30 "source_dir" text NOT NULL DEFAULT '/',
31 "custom_domain" text,
32 "updated_at" timestamp DEFAULT now() NOT NULL,
33 CONSTRAINT "pages_settings_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
34);
Addeddrizzle/0011_environments.sql+37−0View fileUnifiedSplit
1-- Gluecron migration 0011: Block C4 — Environments with protected approvals.
2--
3-- Tables:
4-- environments — per-repo named environments (production, staging, preview)
5-- deployment_approvals — approve/reject decisions on a pending deployment
6
7--> statement-breakpoint
8CREATE TABLE IF NOT EXISTS "environments" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
10 "repository_id" uuid NOT NULL,
11 "name" text NOT NULL,
12 "require_approval" boolean NOT NULL DEFAULT false,
13 "reviewers" text NOT NULL DEFAULT '[]',
14 "wait_timer_minutes" integer NOT NULL DEFAULT 0,
15 "allowed_branches" text NOT NULL DEFAULT '[]',
16 "created_at" timestamp DEFAULT now() NOT NULL,
17 "updated_at" timestamp DEFAULT now() NOT NULL,
18 CONSTRAINT "environments_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
19);
20
21--> statement-breakpoint
22CREATE UNIQUE INDEX IF NOT EXISTS "environments_repo_name" ON "environments" ("repository_id", "name");
23
24--> statement-breakpoint
25CREATE TABLE IF NOT EXISTS "deployment_approvals" (
26 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
27 "deployment_id" uuid NOT NULL,
28 "user_id" uuid NOT NULL,
29 "decision" text NOT NULL,
30 "comment" text,
31 "created_at" timestamp DEFAULT now() NOT NULL,
32 CONSTRAINT "deployment_approvals_deployment_fk" FOREIGN KEY ("deployment_id") REFERENCES "deployments"("id") ON DELETE cascade,
33 CONSTRAINT "deployment_approvals_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
34);
35
36--> statement-breakpoint
37CREATE INDEX IF NOT EXISTS "deployment_approvals_deployment" ON "deployment_approvals" ("deployment_id");
Addeddrizzle/0012_ai_native.sql+68−0View fileUnifiedSplit
1-- Gluecron migration 0012: Block D — AI-native differentiation.
2--
3-- Tables:
4-- codebase_explanations — D6: per-commit cached "explain this codebase" markdown
5-- dep_update_runs — D2: AI dependency bumper run history
6-- code_chunks — D1: per-repo code chunks with (optional) embeddings
7
8--> statement-breakpoint
9CREATE TABLE IF NOT EXISTS "codebase_explanations" (
10 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
11 "repository_id" uuid NOT NULL,
12 "commit_sha" text NOT NULL,
13 "summary" text NOT NULL,
14 "markdown" text NOT NULL,
15 "model" text NOT NULL,
16 "generated_at" timestamp DEFAULT now() NOT NULL,
17 CONSTRAINT "codebase_explanations_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
18);
19
20--> statement-breakpoint
21CREATE UNIQUE INDEX IF NOT EXISTS "codebase_explanations_repo_sha" ON "codebase_explanations" ("repository_id", "commit_sha");
22
23--> statement-breakpoint
24CREATE TABLE IF NOT EXISTS "dep_update_runs" (
25 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
26 "repository_id" uuid NOT NULL,
27 "status" text NOT NULL DEFAULT 'pending',
28 "ecosystem" text NOT NULL,
29 "manifest_path" text NOT NULL,
30 "attempted_bumps" text NOT NULL DEFAULT '[]',
31 "applied_bumps" text NOT NULL DEFAULT '[]',
32 "branch_name" text,
33 "pr_number" integer,
34 "error_message" text,
35 "triggered_by" uuid,
36 "created_at" timestamp DEFAULT now() NOT NULL,
37 "completed_at" timestamp,
38 CONSTRAINT "dep_update_runs_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
39 CONSTRAINT "dep_update_runs_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE set null
40);
41
42--> statement-breakpoint
43CREATE INDEX IF NOT EXISTS "dep_update_runs_repo" ON "dep_update_runs" ("repository_id");
44--> statement-breakpoint
45CREATE INDEX IF NOT EXISTS "dep_update_runs_created" ON "dep_update_runs" ("created_at");
46
47--> statement-breakpoint
48-- D1: code chunks for semantic search. Embedding stored as JSON-encoded
49-- number array in text to avoid requiring pgvector; helper lib does cosine
50-- similarity in JS. Upgrade path: ALTER COLUMN embedding TYPE vector(1024).
51CREATE TABLE IF NOT EXISTS "code_chunks" (
52 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
53 "repository_id" uuid NOT NULL,
54 "commit_sha" text NOT NULL,
55 "path" text NOT NULL,
56 "start_line" integer NOT NULL,
57 "end_line" integer NOT NULL,
58 "content" text NOT NULL,
59 "embedding" text,
60 "embedding_model" text,
61 "created_at" timestamp DEFAULT now() NOT NULL,
62 CONSTRAINT "code_chunks_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
63);
64
65--> statement-breakpoint
66CREATE INDEX IF NOT EXISTS "code_chunks_repo" ON "code_chunks" ("repository_id");
67--> statement-breakpoint
68CREATE INDEX IF NOT EXISTS "code_chunks_repo_path" ON "code_chunks" ("repository_id", "path");
Addeddrizzle/0013_discussions.sql+50−0View fileUnifiedSplit
1-- Gluecron migration 0013: Block E2 — Discussions.
2--
3-- Tables:
4-- discussions — forum-style threaded conversations attached to a repo
5-- discussion_comments — comments (optionally nested 1 level via parent_comment_id)
6--
7-- Discussions mirror GitHub Discussions: pinned + categorised threads that live
8-- alongside issues/PRs but are conversational rather than work-tracking.
9
10--> statement-breakpoint
11CREATE TABLE IF NOT EXISTS "discussions" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
13 "number" serial NOT NULL,
14 "repository_id" uuid NOT NULL,
15 "author_id" uuid NOT NULL,
16 "category" text NOT NULL DEFAULT 'general',
17 "title" text NOT NULL,
18 "body" text,
19 "state" text NOT NULL DEFAULT 'open',
20 "locked" boolean NOT NULL DEFAULT false,
21 "answer_comment_id" uuid,
22 "pinned" boolean NOT NULL DEFAULT false,
23 "created_at" timestamp DEFAULT now() NOT NULL,
24 "updated_at" timestamp DEFAULT now() NOT NULL,
25 CONSTRAINT "discussions_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
26 CONSTRAINT "discussions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
27);
28
29--> statement-breakpoint
30CREATE INDEX IF NOT EXISTS "discussions_repo" ON "discussions" ("repository_id");
31--> statement-breakpoint
32CREATE UNIQUE INDEX IF NOT EXISTS "discussions_repo_number" ON "discussions" ("repository_id", "number");
33
34--> statement-breakpoint
35CREATE TABLE IF NOT EXISTS "discussion_comments" (
36 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37 "discussion_id" uuid NOT NULL,
38 "parent_comment_id" uuid,
39 "author_id" uuid NOT NULL,
40 "body" text NOT NULL,
41 "is_answer" boolean NOT NULL DEFAULT false,
42 "created_at" timestamp DEFAULT now() NOT NULL,
43 "updated_at" timestamp DEFAULT now() NOT NULL,
44 CONSTRAINT "discussion_comments_discussion_fk" FOREIGN KEY ("discussion_id") REFERENCES "discussions"("id") ON DELETE cascade,
45 CONSTRAINT "discussion_comments_parent_fk" FOREIGN KEY ("parent_comment_id") REFERENCES "discussion_comments"("id") ON DELETE cascade,
46 CONSTRAINT "discussion_comments_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
47);
48
49--> statement-breakpoint
50CREATE INDEX IF NOT EXISTS "discussion_comments_discussion" ON "discussion_comments" ("discussion_id");
Addeddrizzle/0014_gists.sql+73−0View fileUnifiedSplit
1-- Gluecron migration 0014: Block E4 — Gists.
2--
3-- User-owned small snippets/files that behave like tiny repos. DB-backed
4-- (no git bare repo for v1): each gist owns a collection of gist_files, and
5-- every edit appends a gist_revisions row containing a JSON snapshot of the
6-- full file set at that revision.
7--
8-- Tables:
9-- gists — top-level gist row (owner, slug, title, description)
10-- gist_files — individual files on a gist (filename, language, content)
11-- gist_revisions — per-edit snapshots (JSON {filename: content})
12-- gist_stars — per-user stars
13
14--> statement-breakpoint
15CREATE TABLE IF NOT EXISTS "gists" (
16 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
17 "owner_id" uuid NOT NULL,
18 "slug" text NOT NULL UNIQUE,
19 "title" text NOT NULL DEFAULT '',
20 "description" text NOT NULL DEFAULT '',
21 "is_public" boolean NOT NULL DEFAULT true,
22 "created_at" timestamp DEFAULT now() NOT NULL,
23 "updated_at" timestamp DEFAULT now() NOT NULL,
24 CONSTRAINT "gists_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
25);
26
27--> statement-breakpoint
28CREATE INDEX IF NOT EXISTS "gists_owner" ON "gists" ("owner_id");
29
30--> statement-breakpoint
31CREATE TABLE IF NOT EXISTS "gist_files" (
32 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33 "gist_id" uuid NOT NULL,
34 "filename" text NOT NULL,
35 "language" text,
36 "content" text NOT NULL DEFAULT '',
37 "size_bytes" integer NOT NULL DEFAULT 0,
38 CONSTRAINT "gist_files_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade
39);
40
41--> statement-breakpoint
42CREATE INDEX IF NOT EXISTS "gist_files_gist" ON "gist_files" ("gist_id");
43--> statement-breakpoint
44CREATE UNIQUE INDEX IF NOT EXISTS "gist_files_gist_filename" ON "gist_files" ("gist_id", "filename");
45
46--> statement-breakpoint
47CREATE TABLE IF NOT EXISTS "gist_revisions" (
48 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
49 "gist_id" uuid NOT NULL,
50 "revision" integer NOT NULL,
51 "snapshot" text NOT NULL DEFAULT '{}',
52 "author_id" uuid NOT NULL,
53 "message" text,
54 "created_at" timestamp DEFAULT now() NOT NULL,
55 CONSTRAINT "gist_revisions_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade,
56 CONSTRAINT "gist_revisions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE cascade
57);
58
59--> statement-breakpoint
60CREATE INDEX IF NOT EXISTS "gist_revisions_gist_rev" ON "gist_revisions" ("gist_id", "revision");
61
62--> statement-breakpoint
63CREATE TABLE IF NOT EXISTS "gist_stars" (
64 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
65 "gist_id" uuid NOT NULL,
66 "user_id" uuid NOT NULL,
67 "created_at" timestamp DEFAULT now() NOT NULL,
68 CONSTRAINT "gist_stars_gist_fk" FOREIGN KEY ("gist_id") REFERENCES "gists"("id") ON DELETE cascade,
69 CONSTRAINT "gist_stars_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
70);
71
72--> statement-breakpoint
73CREATE UNIQUE INDEX IF NOT EXISTS "gist_stars_gist_user" ON "gist_stars" ("gist_id", "user_id");
Addeddrizzle/0015_projects.sql+66−0View fileUnifiedSplit
1-- Gluecron migration 0015: Block E1 — Projects / kanban boards.
2--
3-- Tables:
4-- projects — top-level board scoped to a repo (or org later)
5-- project_columns — ordered columns (To Do / Doing / Done, etc)
6-- project_items — cards on the board. Can be a freeform note OR linked
7-- to an existing issue / pull_request (polymorphic fk).
8--
9-- Schema follows a lightweight GitHub Projects v2 model but scoped to a repo
10-- for v1 (cross-repo + org boards can be added by nulling repository_id later).
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "projects" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "number" serial NOT NULL,
16 "repository_id" uuid NOT NULL,
17 "owner_id" uuid NOT NULL,
18 "title" text NOT NULL,
19 "description" text NOT NULL DEFAULT '',
20 "state" text NOT NULL DEFAULT 'open',
21 "created_at" timestamp DEFAULT now() NOT NULL,
22 "updated_at" timestamp DEFAULT now() NOT NULL,
23 CONSTRAINT "projects_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
24 CONSTRAINT "projects_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id")
25);
26
27--> statement-breakpoint
28CREATE INDEX IF NOT EXISTS "projects_repo" ON "projects" ("repository_id");
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "projects_repo_number" ON "projects" ("repository_id", "number");
31
32--> statement-breakpoint
33CREATE TABLE IF NOT EXISTS "project_columns" (
34 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
35 "project_id" uuid NOT NULL,
36 "name" text NOT NULL,
37 "position" integer NOT NULL DEFAULT 0,
38 "created_at" timestamp DEFAULT now() NOT NULL,
39 CONSTRAINT "project_columns_project_fk" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE cascade
40);
41
42--> statement-breakpoint
43CREATE INDEX IF NOT EXISTS "project_columns_project" ON "project_columns" ("project_id");
44
45--> statement-breakpoint
46CREATE TABLE IF NOT EXISTS "project_items" (
47 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
48 "project_id" uuid NOT NULL,
49 "column_id" uuid NOT NULL,
50 "position" integer NOT NULL DEFAULT 0,
51 -- Card content. A card is EITHER a free-form note (note + title set) OR
52 -- a linked issue / pull_request (item_type + item_id set).
53 "item_type" text NOT NULL DEFAULT 'note', -- note | issue | pr
54 "item_id" uuid, -- FK handled per-type at app level
55 "title" text NOT NULL DEFAULT '',
56 "note" text NOT NULL DEFAULT '',
57 "created_at" timestamp DEFAULT now() NOT NULL,
58 "updated_at" timestamp DEFAULT now() NOT NULL,
59 CONSTRAINT "project_items_project_fk" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE cascade,
60 CONSTRAINT "project_items_column_fk" FOREIGN KEY ("column_id") REFERENCES "project_columns"("id") ON DELETE cascade
61);
62
63--> statement-breakpoint
64CREATE INDEX IF NOT EXISTS "project_items_project" ON "project_items" ("project_id");
65--> statement-breakpoint
66CREATE INDEX IF NOT EXISTS "project_items_column" ON "project_items" ("column_id", "position");
Addeddrizzle/0016_wikis.sql+46−0View fileUnifiedSplit
1-- Gluecron migration 0016: Block E3 — Wikis.
2--
3-- DB-backed wiki for v1 (git-backed mirror is a future upgrade). Each repo
4-- owns a collection of wiki_pages keyed on slug, with revisions stored
5-- incrementally for history/diff/revert.
6--
7-- Tables:
8-- wiki_pages — current content per slug
9-- wiki_revisions — append-only history (body + message + author)
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "wiki_pages" (
13 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14 "repository_id" uuid NOT NULL,
15 "slug" text NOT NULL,
16 "title" text NOT NULL,
17 "body" text NOT NULL DEFAULT '',
18 "revision" integer NOT NULL DEFAULT 1,
19 "created_at" timestamp DEFAULT now() NOT NULL,
20 "updated_at" timestamp DEFAULT now() NOT NULL,
21 "updated_by" uuid,
22 CONSTRAINT "wiki_pages_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
23 CONSTRAINT "wiki_pages_author_fk" FOREIGN KEY ("updated_by") REFERENCES "users"("id")
24);
25
26--> statement-breakpoint
27CREATE INDEX IF NOT EXISTS "wiki_pages_repo" ON "wiki_pages" ("repository_id");
28--> statement-breakpoint
29CREATE UNIQUE INDEX IF NOT EXISTS "wiki_pages_repo_slug" ON "wiki_pages" ("repository_id", "slug");
30
31--> statement-breakpoint
32CREATE TABLE IF NOT EXISTS "wiki_revisions" (
33 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
34 "page_id" uuid NOT NULL,
35 "revision" integer NOT NULL,
36 "title" text NOT NULL,
37 "body" text NOT NULL DEFAULT '',
38 "message" text,
39 "author_id" uuid NOT NULL,
40 "created_at" timestamp DEFAULT now() NOT NULL,
41 CONSTRAINT "wiki_revisions_page_fk" FOREIGN KEY ("page_id") REFERENCES "wiki_pages"("id") ON DELETE cascade,
42 CONSTRAINT "wiki_revisions_author_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
43);
44
45--> statement-breakpoint
46CREATE INDEX IF NOT EXISTS "wiki_revisions_page" ON "wiki_revisions" ("page_id", "revision");
Addeddrizzle/0017_merge_queue.sql+33−0View fileUnifiedSplit
1-- Gluecron migration 0017: Block E5 — Merge queues.
2--
3-- Serialised merge: instead of merging a PR immediately, it's appended to
4-- a queue scoped on (repository_id, base_branch). A worker (or manual
5-- process-next button for v1) pops the head, re-runs gates against the
6-- latest base, and if green actually merges. If red, kicks the PR back
7-- with a failure comment.
8--
9-- Tables:
10-- merge_queue_entries — one row per PR currently queued / processed
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "merge_queue_entries" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "repository_id" uuid NOT NULL,
16 "pull_request_id" uuid NOT NULL,
17 "base_branch" text NOT NULL,
18 "state" text NOT NULL DEFAULT 'queued', -- queued | running | merged | failed | dequeued
19 "position" integer NOT NULL DEFAULT 0,
20 "enqueued_by" uuid,
21 "enqueued_at" timestamp DEFAULT now() NOT NULL,
22 "started_at" timestamp,
23 "finished_at" timestamp,
24 "error_message" text,
25 CONSTRAINT "merge_queue_entries_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
26 CONSTRAINT "merge_queue_entries_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests"("id") ON DELETE cascade,
27 CONSTRAINT "merge_queue_entries_enqueuer_fk" FOREIGN KEY ("enqueued_by") REFERENCES "users"("id")
28);
29
30--> statement-breakpoint
31CREATE INDEX IF NOT EXISTS "merge_queue_repo_branch" ON "merge_queue_entries" ("repository_id", "base_branch", "state");
32--> statement-breakpoint
33CREATE UNIQUE INDEX IF NOT EXISTS "merge_queue_pr_active" ON "merge_queue_entries" ("pull_request_id") WHERE state IN ('queued', 'running');
Addeddrizzle/0018_required_checks.sql+22−0View fileUnifiedSplit
1-- Gluecron migration 0018: Block E6 — Required status checks matrix.
2--
3-- The existing `branch_protection.requireGreenGates` flag only says "all
4-- gates must pass". This matrix lets owners require SPECIFIC named checks
5-- (workflow names or gate_run kinds) to pass for a branch pattern.
6--
7-- Tables:
8-- branch_required_checks — one row per (rule, check_name) pairing
9
10--> statement-breakpoint
11CREATE TABLE IF NOT EXISTS "branch_required_checks" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
13 "branch_protection_id" uuid NOT NULL,
14 "check_name" text NOT NULL,
15 "created_at" timestamp DEFAULT now() NOT NULL,
16 CONSTRAINT "branch_required_checks_rule_fk" FOREIGN KEY ("branch_protection_id") REFERENCES "branch_protection"("id") ON DELETE cascade
17);
18
19--> statement-breakpoint
20CREATE INDEX IF NOT EXISTS "branch_required_checks_rule" ON "branch_required_checks" ("branch_protection_id");
21--> statement-breakpoint
22CREATE UNIQUE INDEX IF NOT EXISTS "branch_required_checks_unique" ON "branch_required_checks" ("branch_protection_id", "check_name");
Addeddrizzle/0019_protected_tags.sql+26−0View fileUnifiedSplit
1-- Gluecron migration 0019: Block E7 — Protected tags.
2--
3-- Lets owners mark tag patterns (e.g. `v*`, `release-*`) as protected so
4-- that non-owners cannot create, update, or delete matching tags.
5-- Enforcement happens in the git push flow: see post-receive hook for
6-- advisory notifications; the actual block is implemented at the route
7-- level or service layer (for v1 we just record + surface the policy).
8--
9-- Tables:
10-- protected_tags — per-repo tag patterns with glob support
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "protected_tags" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
15 "repository_id" uuid NOT NULL,
16 "pattern" text NOT NULL,
17 "created_at" timestamp DEFAULT now() NOT NULL,
18 "created_by" uuid,
19 CONSTRAINT "protected_tags_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
20 CONSTRAINT "protected_tags_creator_fk" FOREIGN KEY ("created_by") REFERENCES "users"("id")
21);
22
23--> statement-breakpoint
24CREATE INDEX IF NOT EXISTS "protected_tags_repo" ON "protected_tags" ("repository_id");
25--> statement-breakpoint
26CREATE UNIQUE INDEX IF NOT EXISTS "protected_tags_repo_pattern" ON "protected_tags" ("repository_id", "pattern");
Addeddrizzle/0020_analytics_and_admin.sql+91−0View fileUnifiedSplit
1-- Gluecron migration 0020: Block F — Observability + admin.
2--
3-- Covers F1 (traffic analytics), F3 (admin panel), and F4 (billing/quotas).
4-- F2 (org insights) is computed live from existing tables.
5--
6-- Tables:
7-- repo_traffic_events — view/clone events, 1 row per event. Rolled up via
8-- GROUP BY for daily/weekly charts.
9-- system_flags — simple key/value state for site-admin (e.g.
10-- "site_banner_text", "registration_locked"). Only
11-- site admins can write.
12-- site_admins — explicit list of user ids that are global admins.
13-- Absence of any row means "first user is admin"
14-- bootstrap (handled in code).
15-- billing_plans — plan catalogue (name, limits). Seeded with free.
16-- user_quotas — per-user usage counters + plan assignment. Writes
17-- are bumped from action paths (push bytes, ai tokens).
18
19--> statement-breakpoint
20CREATE TABLE IF NOT EXISTS "repo_traffic_events" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
22 "repository_id" uuid NOT NULL,
23 "kind" text NOT NULL, -- view | clone | api | ui
24 "path" text, -- path visited (first 256 chars)
25 "user_id" uuid, -- null for anon
26 "ip_hash" text, -- sha256(ip) prefix; best-effort uniq
27 "user_agent" text, -- first 128 chars
28 "referer" text, -- first 256 chars
29 "created_at" timestamp DEFAULT now() NOT NULL,
30 CONSTRAINT "repo_traffic_events_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
31 CONSTRAINT "repo_traffic_events_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE set null
32);
33
34--> statement-breakpoint
35CREATE INDEX IF NOT EXISTS "repo_traffic_events_repo_time" ON "repo_traffic_events" ("repository_id", "created_at");
36--> statement-breakpoint
37CREATE INDEX IF NOT EXISTS "repo_traffic_events_kind" ON "repo_traffic_events" ("repository_id", "kind", "created_at");
38
39--> statement-breakpoint
40CREATE TABLE IF NOT EXISTS "system_flags" (
41 "key" text PRIMARY KEY NOT NULL,
42 "value" text NOT NULL DEFAULT '',
43 "updated_at" timestamp DEFAULT now() NOT NULL,
44 "updated_by" uuid,
45 CONSTRAINT "system_flags_updater_fk" FOREIGN KEY ("updated_by") REFERENCES "users"("id")
46);
47
48--> statement-breakpoint
49CREATE TABLE IF NOT EXISTS "site_admins" (
50 "user_id" uuid PRIMARY KEY NOT NULL,
51 "granted_at" timestamp DEFAULT now() NOT NULL,
52 "granted_by" uuid,
53 CONSTRAINT "site_admins_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade,
54 CONSTRAINT "site_admins_granter_fk" FOREIGN KEY ("granted_by") REFERENCES "users"("id")
55);
56
57--> statement-breakpoint
58CREATE TABLE IF NOT EXISTS "billing_plans" (
59 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
60 "slug" text NOT NULL UNIQUE, -- free | pro | team | enterprise
61 "name" text NOT NULL,
62 "price_cents" integer NOT NULL DEFAULT 0,
63 "repo_limit" integer NOT NULL DEFAULT 10,
64 "storage_mb_limit" integer NOT NULL DEFAULT 1024,
65 "ai_tokens_monthly" integer NOT NULL DEFAULT 100000,
66 "bandwidth_gb_monthly" integer NOT NULL DEFAULT 10,
67 "private_repos" boolean NOT NULL DEFAULT false,
68 "created_at" timestamp DEFAULT now() NOT NULL
69);
70
71--> statement-breakpoint
72CREATE TABLE IF NOT EXISTS "user_quotas" (
73 "user_id" uuid PRIMARY KEY NOT NULL,
74 "plan_slug" text NOT NULL DEFAULT 'free',
75 "storage_mb_used" integer NOT NULL DEFAULT 0,
76 "ai_tokens_used_this_month" integer NOT NULL DEFAULT 0,
77 "bandwidth_gb_used_this_month" integer NOT NULL DEFAULT 0,
78 "cycle_start" timestamp DEFAULT now() NOT NULL,
79 "updated_at" timestamp DEFAULT now() NOT NULL,
80 CONSTRAINT "user_quotas_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
81);
82
83--> statement-breakpoint
84-- Seed the default plans.
85INSERT INTO "billing_plans" ("slug","name","price_cents","repo_limit","storage_mb_limit","ai_tokens_monthly","bandwidth_gb_monthly","private_repos")
86VALUES
87 ('free','Free',0,10,1024,100000,10,false),
88 ('pro','Pro',900,200,10240,1000000,100,true),
89 ('team','Team',2400,1000,51200,5000000,500,true),
90 ('enterprise','Enterprise',9900,10000,512000,50000000,5000,true)
91ON CONFLICT (slug) DO NOTHING;
Addeddrizzle/0021_marketplace_and_apps.sql+100−0View fileUnifiedSplit
1-- Gluecron migration 0021: Block H — Marketplace + GitHub Apps equivalent.
2--
3-- H1 — App marketplace: creators register apps, users install them against
4-- their personal account / org / individual repo. Each install grants a
5-- concrete set of scopes (pull-read, issues-write, checks-write, etc.).
6--
7-- H2 — Bot identities: every marketplace app gets an "app user" that can
8-- comment, open PRs, attach checks, etc. Bots authenticate with installation
9-- tokens tied to a single installation and a time-window.
10--
11-- Tables:
12-- apps — app definitions (slug, description, webhook, permissions)
13-- app_installations — app X (repo | org | user) with granted permissions
14-- app_bots — one bot account per app (username ends with `[bot]`)
15-- app_install_tokens — short-lived bearer tokens scoped to a single install
16-- app_events — audit trail of installs, uninstalls, events delivered
17
18--> statement-breakpoint
19CREATE TABLE IF NOT EXISTS "apps" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "slug" text NOT NULL UNIQUE, -- url-safe, globally unique
22 "name" text NOT NULL,
23 "description" text NOT NULL DEFAULT '',
24 "icon_url" text,
25 "homepage_url" text,
26 "webhook_url" text, -- where events are delivered
27 "webhook_secret" text, -- HMAC secret; shown once at create
28 "creator_id" uuid NOT NULL,
29 "permissions" text NOT NULL DEFAULT '[]', -- JSON array of permission names
30 "default_events" text NOT NULL DEFAULT '[]', -- JSON array: push, issues, pulls…
31 "is_public" boolean NOT NULL DEFAULT true, -- listed in /marketplace?
32 "created_at" timestamp DEFAULT now() NOT NULL,
33 "updated_at" timestamp DEFAULT now() NOT NULL,
34 CONSTRAINT "apps_creator_fk" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE cascade
35);
36
37--> statement-breakpoint
38CREATE INDEX IF NOT EXISTS "apps_public_slug" ON "apps" ("is_public", "slug");
39
40--> statement-breakpoint
41CREATE TABLE IF NOT EXISTS "app_installations" (
42 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
43 "app_id" uuid NOT NULL,
44 "installed_by" uuid NOT NULL, -- user who clicked install
45 "target_type" text NOT NULL, -- user | org | repository
46 "target_id" uuid NOT NULL,
47 "granted_permissions" text NOT NULL DEFAULT '[]', -- JSON subset of app.permissions
48 "suspended_at" timestamp,
49 "created_at" timestamp DEFAULT now() NOT NULL,
50 "uninstalled_at" timestamp,
51 CONSTRAINT "app_installations_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade,
52 CONSTRAINT "app_installations_user_fk" FOREIGN KEY ("installed_by") REFERENCES "users"("id") ON DELETE cascade
53);
54
55--> statement-breakpoint
56CREATE INDEX IF NOT EXISTS "app_installations_app" ON "app_installations" ("app_id");
57--> statement-breakpoint
58CREATE INDEX IF NOT EXISTS "app_installations_target" ON "app_installations" ("target_type", "target_id");
59--> statement-breakpoint
60CREATE UNIQUE INDEX IF NOT EXISTS "app_installations_unique" ON "app_installations" ("app_id", "target_type", "target_id") WHERE "uninstalled_at" IS NULL;
61
62--> statement-breakpoint
63CREATE TABLE IF NOT EXISTS "app_bots" (
64 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
65 "app_id" uuid NOT NULL UNIQUE,
66 "username" text NOT NULL UNIQUE, -- `${app.slug}[bot]`
67 "display_name" text NOT NULL,
68 "avatar_url" text,
69 "created_at" timestamp DEFAULT now() NOT NULL,
70 CONSTRAINT "app_bots_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade
71);
72
73--> statement-breakpoint
74CREATE TABLE IF NOT EXISTS "app_install_tokens" (
75 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
76 "installation_id" uuid NOT NULL,
77 "token_hash" text NOT NULL UNIQUE, -- sha256 of bearer
78 "expires_at" timestamp NOT NULL,
79 "created_at" timestamp DEFAULT now() NOT NULL,
80 "revoked_at" timestamp,
81 CONSTRAINT "app_install_tokens_inst_fk" FOREIGN KEY ("installation_id") REFERENCES "app_installations"("id") ON DELETE cascade
82);
83
84--> statement-breakpoint
85CREATE INDEX IF NOT EXISTS "app_install_tokens_hash" ON "app_install_tokens" ("token_hash");
86
87--> statement-breakpoint
88CREATE TABLE IF NOT EXISTS "app_events" (
89 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
90 "app_id" uuid NOT NULL,
91 "installation_id" uuid,
92 "kind" text NOT NULL, -- installed | uninstalled | delivery_ok | delivery_fail
93 "payload" text, -- JSON, first 2048 chars
94 "response_status" integer,
95 "created_at" timestamp DEFAULT now() NOT NULL,
96 CONSTRAINT "app_events_app_fk" FOREIGN KEY ("app_id") REFERENCES "apps"("id") ON DELETE cascade
97);
98
99--> statement-breakpoint
100CREATE INDEX IF NOT EXISTS "app_events_app_time" ON "app_events" ("app_id", "created_at");
Addeddrizzle/0022_repo_templates.sql+34−0View fileUnifiedSplit
1-- Gluecron migration 0022: Template repositories + transfer history.
2--
3-- I2 — Template repositories. Owners can flag a repo as a "template" via
4-- settings; other users can then click "Use this template" to create a new
5-- repo seeded from the template's disk state. The seed flow is handled in
6-- application code (repositories.ts); this migration just adds the column.
7--
8-- I3 — Repository transfer history (audit trail of ownership changes). The
9-- primary `repositories.owner_id` / `org_id` fields carry the current owner;
10-- this table records prior owners so a transferred repo can prove provenance.
11
12--> statement-breakpoint
13ALTER TABLE "repositories"
14 ADD COLUMN IF NOT EXISTS "is_template" boolean NOT NULL DEFAULT false;
15
16--> statement-breakpoint
17CREATE INDEX IF NOT EXISTS "repos_is_template" ON "repositories" ("is_template") WHERE "is_template" = true;
18
19--> statement-breakpoint
20CREATE TABLE IF NOT EXISTS "repo_transfers" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
22 "repository_id" uuid NOT NULL,
23 "from_owner_id" uuid NOT NULL,
24 "from_org_id" uuid,
25 "to_owner_id" uuid NOT NULL,
26 "to_org_id" uuid,
27 "initiated_by" uuid NOT NULL,
28 "created_at" timestamp DEFAULT now() NOT NULL,
29 CONSTRAINT "repo_transfers_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
30 CONSTRAINT "repo_transfers_init_fk" FOREIGN KEY ("initiated_by") REFERENCES "users"("id") ON DELETE cascade
31);
32
33--> statement-breakpoint
34CREATE INDEX IF NOT EXISTS "repo_transfers_repo" ON "repo_transfers" ("repository_id", "created_at");
Addeddrizzle/0023_sponsors.sql+47−0View fileUnifiedSplit
1-- Gluecron migration 0023: Sponsors (Block I6).
2--
3-- Lightweight sponsorship model:
4-- sponsorship_tiers — the maintainer's published tiers (amount, description, benefit)
5-- sponsorships — ongoing or one-time support relationships
6--
7-- Payment rails are out of scope — we store the intent + any external
8-- provider/transaction reference. The UI surfaces a "Sponsor" button on user
9-- profiles that have at least one active tier.
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "sponsorship_tiers" (
13 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14 "maintainer_id" uuid NOT NULL,
15 "name" text NOT NULL, -- "Coffee", "Champion", "Patron"
16 "description" text NOT NULL DEFAULT '',
17 "monthly_cents" integer NOT NULL, -- 500 = $5/mo; 0 = one-time-only
18 "one_time_allowed" boolean NOT NULL DEFAULT true,
19 "is_active" boolean NOT NULL DEFAULT true,
20 "created_at" timestamp DEFAULT now() NOT NULL,
21 CONSTRAINT "sponsor_tiers_maintainer_fk" FOREIGN KEY ("maintainer_id") REFERENCES "users"("id") ON DELETE cascade
22);
23
24--> statement-breakpoint
25CREATE INDEX IF NOT EXISTS "sponsor_tiers_maintainer" ON "sponsorship_tiers" ("maintainer_id", "is_active");
26
27--> statement-breakpoint
28CREATE TABLE IF NOT EXISTS "sponsorships" (
29 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
30 "sponsor_id" uuid NOT NULL, -- the user paying
31 "maintainer_id" uuid NOT NULL, -- the user receiving
32 "tier_id" uuid, -- optional (custom amounts allowed)
33 "amount_cents" integer NOT NULL,
34 "kind" text NOT NULL, -- one_time | monthly
35 "note" text, -- public or private thank-you note
36 "is_public" boolean NOT NULL DEFAULT true,
37 "external_ref" text, -- stripe/provider txn id
38 "cancelled_at" timestamp,
39 "created_at" timestamp DEFAULT now() NOT NULL,
40 CONSTRAINT "sponsorships_sponsor_fk" FOREIGN KEY ("sponsor_id") REFERENCES "users"("id") ON DELETE cascade,
41 CONSTRAINT "sponsorships_maintainer_fk" FOREIGN KEY ("maintainer_id") REFERENCES "users"("id") ON DELETE cascade
42);
43
44--> statement-breakpoint
45CREATE INDEX IF NOT EXISTS "sponsorships_maintainer" ON "sponsorships" ("maintainer_id", "created_at");
46--> statement-breakpoint
47CREATE INDEX IF NOT EXISTS "sponsorships_sponsor" ON "sponsorships" ("sponsor_id", "created_at");
Addeddrizzle/0024_email_digest.sql+13−0View fileUnifiedSplit
1-- Gluecron migration 0024: Weekly email digest preference.
2--
3-- I7 — Opt-in weekly digest. Adds a single boolean column to `users` + a
4-- companion `last_digest_sent_at` timestamp so the cron job can skip users
5-- who've already received this week's digest (idempotent re-runs).
6
7--> statement-breakpoint
8ALTER TABLE "users"
9 ADD COLUMN IF NOT EXISTS "notify_email_digest_weekly" boolean NOT NULL DEFAULT false;
10
11--> statement-breakpoint
12ALTER TABLE "users"
13 ADD COLUMN IF NOT EXISTS "last_digest_sent_at" timestamp;
Addeddrizzle/0025_code_symbols.sql+31−0View fileUnifiedSplit
1-- Gluecron migration 0025: Code symbol index.
2--
3-- I8 — Symbol / xref navigation. Stores top-level symbol definitions
4-- (functions, classes, interfaces, types, consts) extracted from a
5-- repository's HEAD via a regex-based parser. References are found at
6-- lookup-time by grepping content, so we only persist definitions.
7--
8-- On-demand index: owner clicks "Reindex" on /:owner/:repo/symbols. We
9-- keep only the most recent commit's symbols — older rows are overwritten
10-- by deleting the prior set before inserting the new one.
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "code_symbols" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
16 "commit_sha" text NOT NULL,
17 "name" text NOT NULL,
18 "kind" text NOT NULL,
19 "path" text NOT NULL,
20 "line" integer NOT NULL,
21 "signature" text,
22 "created_at" timestamp NOT NULL DEFAULT now()
23);
24
25--> statement-breakpoint
26CREATE INDEX IF NOT EXISTS "code_symbols_repo_name_idx"
27 ON "code_symbols" ("repository_id", "name");
28
29--> statement-breakpoint
30CREATE INDEX IF NOT EXISTS "code_symbols_repo_path_idx"
31 ON "code_symbols" ("repository_id", "path");
Addeddrizzle/0026_repo_mirrors.sql+36−0View fileUnifiedSplit
1-- Gluecron migration 0026: Repository mirroring.
2--
3-- I9 — Pull-style mirroring. A mirrored repository has an upstream URL
4-- that we periodically `git fetch` from (via admin-trigger or cron). We
5-- keep a single row per repo (one mirror per mirrored repo) plus an
6-- append-only log of sync attempts for audit.
7
8--> statement-breakpoint
9CREATE TABLE IF NOT EXISTS "repo_mirrors" (
10 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
11 "repository_id" uuid NOT NULL UNIQUE
12 REFERENCES "repositories"("id") ON DELETE CASCADE,
13 "upstream_url" text NOT NULL,
14 "interval_minutes" integer NOT NULL DEFAULT 1440,
15 "last_synced_at" timestamp,
16 "last_status" text, -- "ok" | "error" | null (never synced)
17 "last_error" text,
18 "is_enabled" boolean NOT NULL DEFAULT true,
19 "created_at" timestamp NOT NULL DEFAULT now(),
20 "updated_at" timestamp NOT NULL DEFAULT now()
21);
22
23--> statement-breakpoint
24CREATE TABLE IF NOT EXISTS "repo_mirror_runs" (
25 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
26 "mirror_id" uuid NOT NULL REFERENCES "repo_mirrors"("id") ON DELETE CASCADE,
27 "started_at" timestamp NOT NULL DEFAULT now(),
28 "finished_at" timestamp,
29 "status" text NOT NULL DEFAULT 'running', -- running | ok | error
30 "message" text,
31 "exit_code" integer
32);
33
34--> statement-breakpoint
35CREATE INDEX IF NOT EXISTS "repo_mirror_runs_mirror_id_idx"
36 ON "repo_mirror_runs" ("mirror_id", "started_at" DESC);
Addeddrizzle/0027_sso_oidc.sql+40−0View fileUnifiedSplit
1-- Gluecron migration 0027: OIDC-based enterprise SSO.
2--
3-- I10 — A single site-wide OIDC provider (Okta / Azure AD / Auth0 / Google
4-- Workspace). Identified by an `id = 'default'` singleton row. Admin fills
5-- in issuer + endpoint URLs + client credentials. Users then see a
6-- "Sign in with SSO" button on /login.
7--
8-- `sso_user_links` maps a local user to the provider's `sub` claim, so
9-- repeat sign-ins find the existing account.
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "sso_config" (
13 "id" text PRIMARY KEY,
14 "enabled" boolean NOT NULL DEFAULT false,
15 "provider_name" text NOT NULL DEFAULT 'SSO',
16 "issuer" text,
17 "authorization_endpoint" text,
18 "token_endpoint" text,
19 "userinfo_endpoint" text,
20 "client_id" text,
21 "client_secret" text,
22 "scopes" text NOT NULL DEFAULT 'openid profile email',
23 "allowed_email_domains" text, -- comma-separated, null = any
24 "auto_create_users" boolean NOT NULL DEFAULT true,
25 "created_at" timestamp NOT NULL DEFAULT now(),
26 "updated_at" timestamp NOT NULL DEFAULT now()
27);
28
29--> statement-breakpoint
30CREATE TABLE IF NOT EXISTS "sso_user_links" (
31 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
33 "subject" text NOT NULL UNIQUE,
34 "email_at_link" text NOT NULL,
35 "linked_at" timestamp NOT NULL DEFAULT now()
36);
37
38--> statement-breakpoint
39CREATE INDEX IF NOT EXISTS "sso_user_links_user_id_idx"
40 ON "sso_user_links" ("user_id");
Addeddrizzle/0028_repo_dependencies.sql+31−0View fileUnifiedSplit
1-- Gluecron migration 0028: dependency graph.
2--
3-- J1 — Parses manifest files in a repo and stores a per-repo SBOM.
4-- `repo_dependencies` is a "last known state" set — each reindex REPLACES the
5-- prior rows. `ecosystem` is the package manager (npm, pypi, go, rubygems,
6-- cargo, composer). `manifest_path` is the file the dep came from.
7--
8-- One row per (repository_id, ecosystem, name, manifest_path). We don't
9-- de-dup across manifests (the same dep can legitimately appear in multiple
10-- manifests, e.g. server + client package.json).
11
12--> statement-breakpoint
13CREATE TABLE IF NOT EXISTS "repo_dependencies" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
16 "ecosystem" text NOT NULL, -- npm | pypi | go | rubygems | cargo | composer
17 "name" text NOT NULL,
18 "version_spec" text, -- "^1.2.3", ">=2.0", "1.2.3"
19 "manifest_path" text NOT NULL, -- "package.json", "frontend/package.json"
20 "is_dev" boolean NOT NULL DEFAULT false,
21 "commit_sha" text NOT NULL, -- commit the index was built against
22 "indexed_at" timestamp NOT NULL DEFAULT now()
23);
24
25--> statement-breakpoint
26CREATE INDEX IF NOT EXISTS "repo_dependencies_repo_id_idx"
27 ON "repo_dependencies" ("repository_id", "ecosystem");
28
29--> statement-breakpoint
30CREATE INDEX IF NOT EXISTS "repo_dependencies_name_idx"
31 ON "repo_dependencies" ("name");
Addeddrizzle/0029_security_advisories.sql+52−0View fileUnifiedSplit
1-- Gluecron migration 0029: security advisories + dependency alerts.
2--
3-- J2 — Per-package CVE-style advisories mapped against J1's `repo_dependencies`.
4-- Builds the "Dependabot alerts" / "GitHub security advisory" feature.
5--
6-- `security_advisories` is the master rule list — populated initially from a
7-- seeded set in `src/lib/advisories.ts`, extensible via admin import later.
8-- `repo_advisory_alerts` is the per-repo match state: one row per (repo,
9-- advisory) when we detect a dependency matches the advisory's affected range.
10-- An alert can be dismissed (ignored), auto-closed when the dep goes away, or
11-- marked fixed when the dep's version moves past the fixed_version.
12
13--> statement-breakpoint
14CREATE TABLE IF NOT EXISTS "security_advisories" (
15 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
16 "ghsa_id" text UNIQUE, -- GitHub Security Advisory ID, e.g. GHSA-xxxx
17 "cve_id" text, -- CVE-YYYY-NNNN when assigned
18 "summary" text NOT NULL,
19 "severity" text NOT NULL DEFAULT 'moderate', -- low | moderate | high | critical
20 "ecosystem" text NOT NULL, -- npm | pypi | go | rubygems | cargo | composer
21 "package_name" text NOT NULL,
22 "affected_range" text NOT NULL, -- "<1.2.3" | ">=2.0.0 <2.5.1"
23 "fixed_version" text, -- "1.2.3" — suggestion to upgrade to
24 "reference_url" text, -- link to upstream advisory / CVE
25 "published_at" timestamp NOT NULL DEFAULT now()
26);
27
28--> statement-breakpoint
29CREATE INDEX IF NOT EXISTS "security_advisories_pkg_idx"
30 ON "security_advisories" ("ecosystem", "package_name");
31
32--> statement-breakpoint
33CREATE TABLE IF NOT EXISTS "repo_advisory_alerts" (
34 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
35 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
36 "advisory_id" uuid NOT NULL REFERENCES "security_advisories"("id") ON DELETE CASCADE,
37 "dependency_name" text NOT NULL,
38 "dependency_version" text,
39 "manifest_path" text NOT NULL,
40 "status" text NOT NULL DEFAULT 'open', -- open | dismissed | fixed
41 "dismissed_reason" text,
42 "created_at" timestamp NOT NULL DEFAULT now(),
43 "updated_at" timestamp NOT NULL DEFAULT now()
44);
45
46--> statement-breakpoint
47CREATE UNIQUE INDEX IF NOT EXISTS "repo_advisory_alerts_unique_idx"
48 ON "repo_advisory_alerts" ("repository_id", "advisory_id", "manifest_path");
49
50--> statement-breakpoint
51CREATE INDEX IF NOT EXISTS "repo_advisory_alerts_status_idx"
52 ON "repo_advisory_alerts" ("repository_id", "status");
Addeddrizzle/0030_signing_keys.sql+43−0View fileUnifiedSplit
1-- Block J3 — Commit signature verification (GPG + SSH "Verified" badge)
2--
3-- Users register GPG or SSH public keys bound to an email identity. When we
4-- render a commit we extract the embedded signature, hash the key fingerprint
5-- out of the PGP/SSH armored blob, and look it up here. A match paired with
6-- an author email that the key declares → "Verified" badge.
7--
8-- Verifications are memoised in commit_verifications to avoid re-parsing the
9-- raw commit object on every page view.
10
11CREATE TABLE IF NOT EXISTS "signing_keys" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
13 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
14 "key_type" text NOT NULL, -- 'gpg' | 'ssh'
15 "title" text NOT NULL,
16 "fingerprint" text NOT NULL, -- lowercased hex (GPG) or base64 SHA256 (SSH)
17 "public_key" text NOT NULL, -- armored/authorized_keys form as uploaded
18 "email" text, -- optional UID/comment email binding
19 "expires_at" timestamp,
20 "last_used_at" timestamp,
21 "created_at" timestamp NOT NULL DEFAULT now()
22);
23
24CREATE UNIQUE INDEX IF NOT EXISTS "signing_keys_fp_unique"
25 ON "signing_keys" ("key_type", "fingerprint");
26CREATE INDEX IF NOT EXISTS "signing_keys_user_idx"
27 ON "signing_keys" ("user_id");
28
29CREATE TABLE IF NOT EXISTS "commit_verifications" (
30 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
32 "commit_sha" text NOT NULL,
33 "verified" boolean NOT NULL DEFAULT false,
34 "reason" text NOT NULL, -- 'valid' | 'unsigned' | 'unknown_key' | 'expired' | 'bad_sig' | 'email_mismatch'
35 "signature_type" text, -- 'gpg' | 'ssh' | null
36 "signer_key_id" uuid REFERENCES "signing_keys"("id") ON DELETE SET NULL,
37 "signer_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
38 "signer_fingerprint" text,
39 "verified_at" timestamp NOT NULL DEFAULT now()
40);
41
42CREATE UNIQUE INDEX IF NOT EXISTS "commit_verifications_sha_unique"
43 ON "commit_verifications" ("repository_id", "commit_sha");
Addeddrizzle/0031_user_follows.sql+15−0View fileUnifiedSplit
1-- Block J4 — User following / follow-feed.
2--
3-- Directed graph of user -> user. Used to filter activity_feed into a
4-- personalised "what's happening in my network" view.
5
6CREATE TABLE IF NOT EXISTS "user_follows" (
7 "follower_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
8 "following_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
9 "created_at" timestamp NOT NULL DEFAULT now(),
10 PRIMARY KEY ("follower_id", "following_id"),
11 CONSTRAINT "user_follows_no_self" CHECK ("follower_id" <> "following_id")
12);
13
14CREATE INDEX IF NOT EXISTS "user_follows_following_idx"
15 ON "user_follows" ("following_id");
Addeddrizzle/0032_repo_rulesets.sql+32−0View fileUnifiedSplit
1-- Block J6 — Repository rulesets.
2--
3-- Extends the legacy branch_protection table with a policy engine that can
4-- scope rules at the repo (v1) or org level (future). Each ruleset groups
5-- N rules; an evaluator short-circuits on enforcement=active with block=true
6-- and merely logs when enforcement=evaluate.
7
8CREATE TABLE IF NOT EXISTS "repo_rulesets" (
9 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
11 "name" text NOT NULL,
12 "enforcement" text NOT NULL DEFAULT 'active', -- 'active' | 'evaluate' | 'disabled'
13 "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
14 "created_at" timestamp NOT NULL DEFAULT now(),
15 "updated_at" timestamp NOT NULL DEFAULT now()
16);
17
18CREATE INDEX IF NOT EXISTS "repo_rulesets_repo_idx"
19 ON "repo_rulesets" ("repository_id");
20CREATE UNIQUE INDEX IF NOT EXISTS "repo_rulesets_repo_name_unique"
21 ON "repo_rulesets" ("repository_id", "name");
22
23CREATE TABLE IF NOT EXISTS "ruleset_rules" (
24 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25 "ruleset_id" uuid NOT NULL REFERENCES "repo_rulesets"("id") ON DELETE CASCADE,
26 "rule_type" text NOT NULL,
27 "params" text NOT NULL DEFAULT '{}', -- JSON
28 "created_at" timestamp NOT NULL DEFAULT now()
29);
30
31CREATE INDEX IF NOT EXISTS "ruleset_rules_set_idx"
32 ON "ruleset_rules" ("ruleset_id");
Addeddrizzle/0033_commit_statuses.sql+24−0View fileUnifiedSplit
1-- Block J8 — Commit statuses (GitHub-parity external CI signal).
2--
3-- External systems post per-commit (sha, context) statuses that appear on
4-- commit detail views and fuel future merge-gating. Per (repo, sha, context)
5-- upsert semantics — a repost with the same context replaces the prior row.
6
7CREATE TABLE IF NOT EXISTS "commit_statuses" (
8 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
9 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
10 "commit_sha" text NOT NULL,
11 "state" text NOT NULL, -- 'pending' | 'success' | 'failure' | 'error'
12 "context" text NOT NULL DEFAULT 'default',
13 "description" text,
14 "target_url" text,
15 "creator_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
16 "created_at" timestamp NOT NULL DEFAULT now(),
17 "updated_at" timestamp NOT NULL DEFAULT now()
18);
19
20CREATE UNIQUE INDEX IF NOT EXISTS "commit_statuses_repo_sha_context_unique"
21 ON "commit_statuses" ("repository_id", "commit_sha", "context");
22
23CREATE INDEX IF NOT EXISTS "commit_statuses_repo_sha_idx"
24 ON "commit_statuses" ("repository_id", "commit_sha");
Addedfly.toml+33−0View fileUnifiedSplit
1app = "gluecron"
2primary_region = "lhr"
3
4[build]
5 dockerfile = "Dockerfile"
6
7[env]
8 GIT_REPOS_PATH = "/app/repos"
9 PORT = "3000"
10 NODE_ENV = "production"
11
12[http_service]
13 internal_port = 3000
14 force_https = true
15 auto_stop_machines = "suspend"
16 auto_start_machines = true
17 min_machines_running = 1
18
19 [http_service.concurrency]
20 type = "connections"
21 hard_limit = 250
22 soft_limit = 200
23
24[deploy]
25 release_command = "bun run db:migrate"
26
27[[vm]]
28 size = "shared-cpu-1x"
29 memory = "512mb"
30
31[mounts]
32 source = "gluecron_repos"
33 destination = "/app/repos"
Modifiedpackage.json+2−0View fileUnifiedSplit
1212 "test": "bun test"
1313 },
1414 "dependencies": {
15 "@anthropic-ai/sdk": "^0.88.0",
1516 "@hono/node-server": "^1.13.0",
1617 "@neondatabase/serverless": "^0.10.0",
18 "@simplewebauthn/server": "^13.3.0",
1719 "drizzle-orm": "^0.39.0",
1820 "highlight.js": "^11.11.0",
1921 "hono": "^4.7.0",
Addedrailway.toml+10−0View fileUnifiedSplit
1[build]
2builder = "dockerfile"
3
4[deploy]
5releaseCommand = "bun run db:migrate"
6
7[env]
8GIT_REPOS_PATH = "/app/repos"
9PORT = "3000"
10NODE_ENV = "production"
Addedsrc/__tests__/admin.test.ts+98−0View fileUnifiedSplit
1/**
2 * Block F3 — Admin panel smoke tests.
3 *
4 * Exercises the auth gate on every admin route + the lib exports. Doesn't
5 * mutate DB; `isSiteAdmin(null)` and `getFlag` against a non-existent key
6 * degrade gracefully and are safe to call in any environment.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 isSiteAdmin,
13 KNOWN_FLAGS,
14 getFlag,
15} from "../lib/admin";
16
17describe("admin — auth gate", () => {
18 it("GET /admin without auth → 302 /login", async () => {
19 const res = await app.request("/admin");
20 expect(res.status).toBe(302);
21 expect(res.headers.get("location") || "").toContain("/login");
22 });
23
24 it("GET /admin/users without auth → 302 /login", async () => {
25 const res = await app.request("/admin/users");
26 expect(res.status).toBe(302);
27 expect(res.headers.get("location") || "").toContain("/login");
28 });
29
30 it("GET /admin/repos without auth → 302 /login", async () => {
31 const res = await app.request("/admin/repos");
32 expect(res.status).toBe(302);
33 expect(res.headers.get("location") || "").toContain("/login");
34 });
35
36 it("GET /admin/flags without auth → 302 /login", async () => {
37 const res = await app.request("/admin/flags");
38 expect(res.status).toBe(302);
39 expect(res.headers.get("location") || "").toContain("/login");
40 });
41
42 it("POST /admin/flags without auth → 302 /login", async () => {
43 const res = await app.request("/admin/flags", {
44 method: "POST",
45 body: new URLSearchParams({ registration_locked: "1" }),
46 headers: { "content-type": "application/x-www-form-urlencoded" },
47 });
48 expect(res.status).toBe(302);
49 expect(res.headers.get("location") || "").toContain("/login");
50 });
51});
52
53describe("admin — isSiteAdmin", () => {
54 it("returns false for null/undefined user", async () => {
55 expect(await isSiteAdmin(null)).toBe(false);
56 expect(await isSiteAdmin(undefined)).toBe(false);
57 expect(await isSiteAdmin("")).toBe(false);
58 });
59
60 it("returns false for non-existent user id", async () => {
61 const result = await isSiteAdmin("00000000-0000-0000-0000-000000000000");
62 expect(typeof result).toBe("boolean");
63 });
64});
65
66describe("admin — KNOWN_FLAGS", () => {
67 it("exposes registration_locked, site_banner_text, site_banner_level, read_only_mode", () => {
68 expect(KNOWN_FLAGS).toHaveProperty("registration_locked");
69 expect(KNOWN_FLAGS).toHaveProperty("site_banner_text");
70 expect(KNOWN_FLAGS).toHaveProperty("site_banner_level");
71 expect(KNOWN_FLAGS).toHaveProperty("read_only_mode");
72 });
73
74 it("defaults registration_locked to '0' (unlocked)", () => {
75 expect(KNOWN_FLAGS.registration_locked).toBe("0");
76 });
77});
78
79describe("admin — getFlag", () => {
80 it("returns null for unknown keys and never throws", async () => {
81 const v = await getFlag("nonexistent_flag_xyz");
82 expect(v === null || typeof v === "string").toBe(true);
83 });
84});
85
86describe("admin — lib exports", () => {
87 it("exports full admin surface", async () => {
88 const mod = await import("../lib/admin");
89 expect(typeof mod.isSiteAdmin).toBe("function");
90 expect(typeof mod.listSiteAdmins).toBe("function");
91 expect(typeof mod.grantSiteAdmin).toBe("function");
92 expect(typeof mod.revokeSiteAdmin).toBe("function");
93 expect(typeof mod.getFlag).toBe("function");
94 expect(typeof mod.setFlag).toBe("function");
95 expect(typeof mod.listFlags).toBe("function");
96 expect(mod.KNOWN_FLAGS).toBeDefined();
97 });
98});
Addedsrc/__tests__/advisories.test.ts+195−0View fileUnifiedSplit
1/**
2 * Block J2 — Security advisory tests.
3 *
4 * Pure matcher tests + route auth smokes. Live scanning requires the DB
5 * and the J1 dep graph; those paths are exercised via integration.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 SEED_ADVISORIES,
12 parseVersion,
13 compareVersions,
14 satisfiesRange,
15 normalizeManifestVersion,
16 rangeMatches,
17 __internal,
18} from "../lib/advisories";
19
20describe("advisories — parseVersion", () => {
21 it("parses dotted versions", () => {
22 expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
23 expect(parseVersion("0.5")).toEqual([0, 5]);
24 });
25
26 it("strips ^~ prefixes", () => {
27 expect(parseVersion("^1.2.3")).toEqual([1, 2, 3]);
28 expect(parseVersion("~2.0.0")).toEqual([2, 0, 0]);
29 expect(parseVersion("v1.8.0")).toEqual([1, 8, 0]);
30 });
31
32 it("strips prerelease suffixes", () => {
33 expect(parseVersion("1.2.3-beta.1")).toEqual([1, 2, 3]);
34 expect(parseVersion("1.2.3+build.99")).toEqual([1, 2, 3]);
35 });
36
37 it("treats non-numeric as 0", () => {
38 expect(parseVersion("garbage")).toEqual([0]);
39 expect(parseVersion("")).toEqual([0, 0, 0]);
40 });
41});
42
43describe("advisories — compareVersions", () => {
44 it("orders versions correctly", () => {
45 expect(compareVersions("1.2.3", "1.2.4")).toBeLessThan(0);
46 expect(compareVersions("1.2.4", "1.2.3")).toBeGreaterThan(0);
47 expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
48 });
49
50 it("handles different length arrays", () => {
51 expect(compareVersions("1.2", "1.2.0")).toBe(0);
52 expect(compareVersions("1.2", "1.2.1")).toBeLessThan(0);
53 });
54
55 it("handles major bump across tens", () => {
56 expect(compareVersions("2.0.0", "10.0.0")).toBeLessThan(0);
57 expect(compareVersions("1.9.0", "1.10.0")).toBeLessThan(0);
58 });
59});
60
61describe("advisories — satisfiesRange", () => {
62 it("handles simple comparisons", () => {
63 expect(satisfiesRange("1.0.0", "<1.2.3")).toBe(true);
64 expect(satisfiesRange("1.2.3", "<1.2.3")).toBe(false);
65 expect(satisfiesRange("1.0.0", ">=1.0.0")).toBe(true);
66 expect(satisfiesRange("0.9.9", ">=1.0.0")).toBe(false);
67 });
68
69 it("handles compound ranges", () => {
70 expect(satisfiesRange("1.5.0", ">=1.0.0 <2.0.0")).toBe(true);
71 expect(satisfiesRange("0.9.0", ">=1.0.0 <2.0.0")).toBe(false);
72 expect(satisfiesRange("2.0.0", ">=1.0.0 <2.0.0")).toBe(false);
73 });
74
75 it("handles bare version as equality", () => {
76 expect(satisfiesRange("1.2.3", "1.2.3")).toBe(true);
77 expect(satisfiesRange("1.2.4", "1.2.3")).toBe(false);
78 });
79
80 it("handles <=, >=", () => {
81 expect(satisfiesRange("1.0.0", "<=1.0.0")).toBe(true);
82 expect(satisfiesRange("1.0.1", "<=1.0.0")).toBe(false);
83 expect(satisfiesRange("1.0.0", ">=1.0.0")).toBe(true);
84 });
85});
86
87describe("advisories — normalizeManifestVersion", () => {
88 it("strips semver prefixes", () => {
89 expect(normalizeManifestVersion("^1.2.3")).toBe("1.2.3");
90 expect(normalizeManifestVersion("~2.0.0")).toBe("2.0.0");
91 expect(normalizeManifestVersion("v1.8.0")).toBe("1.8.0");
92 });
93
94 it("plucks lower bound from compound spec", () => {
95 expect(normalizeManifestVersion(">=1.0 <2.0")).toBe("1.0");
96 });
97
98 it("returns null for unpinned / wildcard / null", () => {
99 expect(normalizeManifestVersion(null)).toBeNull();
100 expect(normalizeManifestVersion("*")).toBeNull();
101 expect(normalizeManifestVersion("latest")).toBeNull();
102 expect(normalizeManifestVersion("")).toBeNull();
103 });
104});
105
106describe("advisories — rangeMatches", () => {
107 it("matches unpatched version against <fixed range", () => {
108 expect(rangeMatches("4.17.10", "<4.17.12")).toBe(true);
109 expect(rangeMatches("^4.17.10", "<4.17.12")).toBe(true);
110 });
111
112 it("rejects patched version", () => {
113 expect(rangeMatches("4.17.21", "<4.17.12")).toBe(false);
114 expect(rangeMatches("^4.17.21", "<4.17.12")).toBe(false);
115 });
116
117 it("conservatively matches when spec can't be pinned", () => {
118 expect(rangeMatches("*", "<1.0.0")).toBe(true);
119 expect(rangeMatches(null, "<1.0.0")).toBe(true);
120 });
121
122 it("handles compound ranges from the seed list", () => {
123 // log4j CVE range
124 expect(rangeMatches("2.14.1", ">=2.0 <2.15.0")).toBe(true);
125 expect(rangeMatches("2.15.0", ">=2.0 <2.15.0")).toBe(false);
126 expect(rangeMatches("1.9.0", ">=2.0 <2.15.0")).toBe(false);
127 });
128});
129
130describe("advisories — seed data shape", () => {
131 it("has at least a dozen entries", () => {
132 expect(SEED_ADVISORIES.length).toBeGreaterThanOrEqual(10);
133 });
134
135 it("every entry has required fields", () => {
136 for (const a of SEED_ADVISORIES) {
137 expect(typeof a.ghsaId).toBe("string");
138 expect(a.ghsaId.startsWith("GHSA-")).toBe(true);
139 expect(["low", "moderate", "high", "critical"]).toContain(a.severity);
140 expect(typeof a.ecosystem).toBe("string");
141 expect(typeof a.packageName).toBe("string");
142 expect(typeof a.affectedRange).toBe("string");
143 }
144 });
145
146 it("log4j advisory is present", () => {
147 const log4j = SEED_ADVISORIES.find((a) => a.cveId === "CVE-2021-44228");
148 expect(log4j).toBeDefined();
149 expect(log4j!.severity).toBe("critical");
150 });
151
152 it("seed ghsa_ids are unique", () => {
153 const ids = SEED_ADVISORIES.map((a) => a.ghsaId);
154 expect(new Set(ids).size).toBe(ids.length);
155 });
156});
157
158describe("advisories — route auth", () => {
159 it("GET /:o/:r/security/advisories for unknown repo → 404 or 500", async () => {
160 const res = await app.request("/nobody/missing/security/advisories");
161 expect([404, 500]).toContain(res.status);
162 });
163
164 it("GET /all variant also mounted", async () => {
165 const res = await app.request("/nobody/missing/security/advisories/all");
166 expect([404, 500]).toContain(res.status);
167 });
168
169 it("POST scan without auth → 302 /login", async () => {
170 const res = await app.request(
171 "/alice/repo/security/advisories/scan",
172 { method: "POST" }
173 );
174 expect(res.status).toBe(302);
175 expect(res.headers.get("location") || "").toContain("/login");
176 });
177
178 it("POST dismiss without auth → 302 /login", async () => {
179 const res = await app.request(
180 "/alice/repo/security/advisories/00000000-0000-0000-0000-000000000000/dismiss",
181 { method: "POST" }
182 );
183 expect(res.status).toBe(302);
184 expect(res.headers.get("location") || "").toContain("/login");
185 });
186
187 it("POST reopen without auth → 302 /login", async () => {
188 const res = await app.request(
189 "/alice/repo/security/advisories/00000000-0000-0000-0000-000000000000/reopen",
190 { method: "POST" }
191 );
192 expect(res.status).toBe(302);
193 expect(res.headers.get("location") || "").toContain("/login");
194 });
195});
Addedsrc/__tests__/ai-changelog.test.ts+134−0View fileUnifiedSplit
1/**
2 * Block D7 — AI changelog route tests.
3 *
4 * The route depends on a real git repo on disk, so these tests drive the
5 * route via the exported Hono app directly. A temporary bare repository is
6 * spun up per test run under a unique GIT_REPOS_PATH so we don't collide
7 * with other tests or the developer's local `./repos` tree.
8 *
9 * Tests here are deliberately scoped to the guarantees the Block D7 spec
10 * calls out:
11 * - Module imports cleanly.
12 * - GET without query params renders the picker form (HTML).
13 * - GET with nonsense from/to strings renders an error banner, NOT a 500.
14 * - GET for a non-existent repo returns 404.
15 */
16
17import { describe, it, expect, beforeAll, afterAll } from "bun:test";
18import { mkdtemp, rm, mkdir, writeFile } from "fs/promises";
19import { tmpdir } from "os";
20import { join } from "path";
21
22// Pin GIT_REPOS_PATH BEFORE importing the route so `config.gitReposPath`
23// (which is a lazy getter) reads our scratch dir on first access.
24const SCRATCH = await mkdtemp(join(tmpdir(), "gluecron-ai-changelog-"));
25process.env.GIT_REPOS_PATH = SCRATCH;
26
27// eslint-disable-next-line import/first
28const { default: aiChangelog } = await import("../routes/ai-changelog");
29
30const OWNER = "alice";
31const REPO = "demo";
32
33async function run(cmd: string[], cwd: string) {
34 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
35 await proc.exited;
36}
37
38async function seedRepo(): Promise<void> {
39 // Bare repo lives where getRepoPath expects: <root>/<owner>/<repo>.git
40 const bareDir = join(SCRATCH, OWNER, `${REPO}.git`);
41 await mkdir(bareDir, { recursive: true });
42 await run(["git", "init", "--bare", bareDir], SCRATCH);
43 await run(
44 ["git", "symbolic-ref", "HEAD", "refs/heads/main"],
45 bareDir
46 );
47
48 // Push one real commit from a scratch working tree so branches/refs exist.
49 const workDir = await mkdtemp(join(tmpdir(), "gluecron-ai-changelog-work-"));
50 await run(["git", "init", workDir], SCRATCH);
51 await run(
52 ["git", "-C", workDir, "config", "user.email", "test@example.com"],
53 SCRATCH
54 );
55 await run(
56 ["git", "-C", workDir, "config", "user.name", "Test"],
57 SCRATCH
58 );
59 await writeFile(join(workDir, "README.md"), "# hello\n");
60 await run(["git", "-C", workDir, "add", "README.md"], SCRATCH);
61 await run(
62 ["git", "-C", workDir, "commit", "-m", "initial commit"],
63 SCRATCH
64 );
65 // Ensure the local branch is called main regardless of git defaults.
66 await run(
67 ["git", "-C", workDir, "branch", "-M", "main"],
68 SCRATCH
69 );
70 await run(
71 ["git", "-C", workDir, "remote", "add", "origin", bareDir],
72 SCRATCH
73 );
74 await run(
75 ["git", "-C", workDir, "push", "origin", "main"],
76 SCRATCH
77 );
78
79 await rm(workDir, { recursive: true, force: true });
80}
81
82describe("routes/ai-changelog — module", () => {
83 it("imports cleanly and exports a Hono app", () => {
84 expect(aiChangelog).toBeDefined();
85 expect(typeof aiChangelog.request).toBe("function");
86 });
87});
88
89describe("routes/ai-changelog — repo present", () => {
90 beforeAll(async () => {
91 await seedRepo();
92 });
93
94 afterAll(async () => {
95 await rm(SCRATCH, { recursive: true, force: true }).catch(() => {});
96 });
97
98 it("GET without query params renders the picker form", async () => {
99 const res = await aiChangelog.request(
100 `/${OWNER}/${REPO}/ai/changelog`
101 );
102 expect(res.status).toBe(200);
103 const body = await res.text();
104 expect(body).toContain("AI Changelog");
105 // The form exposes from/to inputs and a submit button.
106 expect(body).toContain('name="from"');
107 expect(body).toContain('name="to"');
108 expect(body.toLowerCase()).toContain("generate");
109 });
110
111 it("GET with nonsense from/to renders an error banner, not a 500", async () => {
112 const res = await aiChangelog.request(
113 `/${OWNER}/${REPO}/ai/changelog?from=not-a-real-ref-xyz&to=also-not-real-abc`
114 );
115 // Must NOT be a 500 — the route should handle unresolvable refs gracefully.
116 expect(res.status).toBe(200);
117 const body = await res.text();
118 // Error banner class used by the Layout for form errors.
119 expect(body).toContain("auth-error");
120 // And mentions the offending ref(s).
121 expect(body).toMatch(/Could not resolve/i);
122 });
123});
124
125describe("routes/ai-changelog — missing repo", () => {
126 it("returns 404 for a non-existent repo", async () => {
127 const res = await aiChangelog.request(
128 "/nobody-xyz/nothing-xyz/ai/changelog"
129 );
130 expect(res.status).toBe(404);
131 const body = await res.text();
132 expect(body.toLowerCase()).toContain("repository not found");
133 });
134});
Addedsrc/__tests__/ai-explain.test.ts+87−0View fileUnifiedSplit
1/**
2 * Block D6 — AI "Explain this codebase" tests.
3 *
4 * These run without a live database. The lib function is specified never
5 * to throw; the route responds with 404 on unknown repos and gracefully
6 * degrades when the DB proxy is unavailable (503).
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 explainCodebase,
12 getCachedExplanation,
13} from "../lib/ai-explain";
14
15describe("lib/ai-explain — module shape", () => {
16 it("exports the expected functions", () => {
17 expect(typeof explainCodebase).toBe("function");
18 expect(typeof getCachedExplanation).toBe("function");
19 });
20});
21
22describe("lib/ai-explain — explainCodebase", () => {
23 it("returns the fallback shape for a bogus owner/repo without throwing", async () => {
24 const result = await explainCodebase({
25 owner: "does-not-exist",
26 repo: "neither-does-this",
27 repositoryId: "00000000-0000-0000-0000-000000000000",
28 commitSha: "0".repeat(40),
29 });
30 expect(result).toBeDefined();
31 expect(typeof result.markdown).toBe("string");
32 expect(typeof result.summary).toBe("string");
33 expect(typeof result.model).toBe("string");
34 expect(result.cached).toBe(false);
35 // When there is no bare git repo, no files can be sampled and the
36 // helper falls through to the canonical "unable to generate" message.
37 expect(result.markdown).toBe("_Unable to generate explanation._");
38 expect(result.model).toBe("fallback");
39 });
40
41 it("never throws even when both the DB and git repo are missing", async () => {
42 await expect(
43 explainCodebase({
44 owner: "alice",
45 repo: "project",
46 repositoryId: "00000000-0000-0000-0000-000000000000",
47 commitSha: "deadbeef".repeat(5),
48 force: true,
49 })
50 ).resolves.toBeDefined();
51 });
52});
53
54describe("lib/ai-explain — getCachedExplanation", () => {
55 it("returns null on cache miss / unavailable DB without throwing", async () => {
56 const result = await getCachedExplanation(
57 "00000000-0000-0000-0000-000000000000",
58 "0".repeat(40)
59 );
60 expect(result).toBeNull();
61 });
62});
63
64describe("routes/ai-explain — guards", () => {
65 it("direct GET /:owner/:repo/explain 404s when repo does not exist", async () => {
66 const { default: aiExplainRoutes } = await import("../routes/ai-explain");
67 const res = await aiExplainRoutes.request("/alice/does-not-exist/explain");
68 // 404 when the DB reports no such repo; 503 when the DB proxy is down.
69 expect([404, 503]).toContain(res.status);
70 });
71
72 it("direct POST /:owner/:repo/explain/regenerate without auth redirects to /login or 404s", async () => {
73 const { default: aiExplainRoutes } = await import("../routes/ai-explain");
74 const res = await aiExplainRoutes.request(
75 "/alice/does-not-exist/explain/regenerate",
76 {
77 method: "POST",
78 redirect: "manual",
79 }
80 );
81 expect([302, 303, 404, 503]).toContain(res.status);
82 if (res.status === 302 || res.status === 303) {
83 const loc = res.headers.get("location") || "";
84 expect(loc).toContain("/login");
85 }
86 });
87});
Addedsrc/__tests__/ai-incident.test.ts+82−0View fileUnifiedSplit
1/**
2 * Block D4 — AI Incident Responder tests.
3 *
4 * These tests exercise the pure helper (no I/O) and confirm that
5 * `onDeployFailure` degrades gracefully when the repository does not exist
6 * (or the DB is unavailable) without ever throwing.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 onDeployFailure,
12 summariseCommitsForIncident,
13} from "../lib/ai-incident";
14
15describe("lib/ai-incident — module shape", () => {
16 it("imports cleanly and exports the expected functions", () => {
17 expect(typeof summariseCommitsForIncident).toBe("function");
18 expect(typeof onDeployFailure).toBe("function");
19 });
20});
21
22describe("lib/ai-incident — summariseCommitsForIncident", () => {
23 it("formats each commit as `- <sha7> <subject> — <author>`", () => {
24 const out = summariseCommitsForIncident([
25 { sha: "abcdef1234567890", message: "fix: handle null", author: "alice" },
26 { sha: "fedcba0987654321", message: "feat: new thing", author: "bob" },
27 ]);
28 expect(out).toBe(
29 "- abcdef1 fix: handle null — alice\n" +
30 "- fedcba0 feat: new thing — bob"
31 );
32 });
33
34 it("keeps only the first line of multi-line commit messages", () => {
35 const out = summariseCommitsForIncident([
36 {
37 sha: "1111111aaaaaaa",
38 message: "first line\n\nbody goes here",
39 author: "carol",
40 },
41 ]);
42 expect(out).toBe("- 1111111 first line — carol");
43 });
44
45 it("handles an empty list as an empty string", () => {
46 expect(summariseCommitsForIncident([])).toBe("");
47 });
48
49 it("tolerates missing fields without throwing", () => {
50 const out = summariseCommitsForIncident([
51 { sha: "", message: "", author: "" },
52 ]);
53 // Expect a renderable line even when fields are blank.
54 expect(out).toContain(" — ");
55 });
56});
57
58describe("lib/ai-incident — onDeployFailure", () => {
59 it("returns { issueNumber: null, reason: <non-empty> } for an unknown repositoryId without throwing", async () => {
60 const result = await onDeployFailure({
61 repositoryId: "00000000-0000-0000-0000-000000000000",
62 deploymentId: "00000000-0000-0000-0000-000000000000",
63 ref: "refs/heads/main",
64 commitSha: "0".repeat(40),
65 target: "crontech",
66 errorMessage: "HTTP 500",
67 });
68 expect(result).toBeDefined();
69 expect(result.issueNumber).toBeNull();
70 expect(typeof result.reason).toBe("string");
71 expect(result.reason.length).toBeGreaterThan(0);
72 });
73
74 it("never throws even when all optional fields are missing", async () => {
75 await expect(
76 onDeployFailure({
77 repositoryId: "00000000-0000-0000-0000-000000000000",
78 deploymentId: "00000000-0000-0000-0000-000000000000",
79 })
80 ).resolves.toBeDefined();
81 });
82});
Addedsrc/__tests__/ai-tests.test.ts+276−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite tests.
3 *
4 * Pure-helper tests run directly; route tests tolerate the usual graceful
5 * degradation envelope (200 / 404 / 503) because these suites run without
6 * a live database.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 buildTestsPrompt,
12 contentTypeFor,
13 detectLanguage,
14 detectTestFramework,
15 generateTestStub,
16} from "../lib/ai-tests";
17
18// ---------------------------------------------------------------------------
19// detectLanguage
20// ---------------------------------------------------------------------------
21
22describe("lib/ai-tests — detectLanguage", () => {
23 it("maps .ts and .tsx to typescript", () => {
24 expect(detectLanguage("src/foo.ts")).toBe("typescript");
25 expect(detectLanguage("src/Foo.tsx")).toBe("typescript");
26 });
27
28 it("maps .js and .jsx to javascript", () => {
29 expect(detectLanguage("lib/foo.js")).toBe("javascript");
30 expect(detectLanguage("lib/bar.jsx")).toBe("javascript");
31 });
32
33 it("maps .py to python", () => {
34 expect(detectLanguage("pkg/mod.py")).toBe("python");
35 });
36
37 it("maps .go to go", () => {
38 expect(detectLanguage("cmd/main.go")).toBe("go");
39 });
40
41 it("maps .rs to rust", () => {
42 expect(detectLanguage("src/lib.rs")).toBe("rust");
43 });
44
45 it("maps unknown / extensionless to other", () => {
46 expect(detectLanguage("notes.txt")).toBe("other");
47 expect(detectLanguage("Makefile")).toBe("other");
48 expect(detectLanguage("README")).toBe("other");
49 });
50});
51
52// ---------------------------------------------------------------------------
53// detectTestFramework
54// ---------------------------------------------------------------------------
55
56describe("lib/ai-tests — detectTestFramework", () => {
57 it("returns bun:test when repo looks bun-ish with jest-style test files", () => {
58 const f = detectTestFramework("typescript", [
59 "package.json",
60 "bun.lockb",
61 "src/__tests__/foo.test.ts",
62 "src/__tests__/bar.test.ts",
63 ]);
64 expect(f).toBe("bun:test");
65 });
66
67 it("returns vitest when vitest.config.ts is present", () => {
68 const f = detectTestFramework("typescript", [
69 "package.json",
70 "vitest.config.ts",
71 "src/foo.ts",
72 ]);
73 expect(f).toBe("vitest");
74 });
75
76 it("returns jest when a jest.config.* file is present", () => {
77 const f = detectTestFramework("javascript", [
78 "package.json",
79 "jest.config.js",
80 ]);
81 expect(f).toBe("jest");
82 });
83
84 it("returns pytest when pytest.ini is present", () => {
85 const f = detectTestFramework("python", [
86 "pytest.ini",
87 "pkg/__init__.py",
88 "pkg/mod.py",
89 ]);
90 expect(f).toBe("pytest");
91 });
92
93 it("returns pytest for python regardless of signals (sensible default)", () => {
94 expect(detectTestFramework("python", [])).toBe("pytest");
95 });
96
97 it("returns go test for go language", () => {
98 expect(detectTestFramework("go", ["go.mod"])).toBe("go test");
99 });
100
101 it("falls back to bun:test when repoFiles is empty", () => {
102 expect(detectTestFramework("typescript", [])).toBe("bun:test");
103 expect(detectTestFramework("javascript", [])).toBe("bun:test");
104 expect(detectTestFramework("other", [])).toBe("bun:test");
105 });
106});
107
108// ---------------------------------------------------------------------------
109// buildTestsPrompt
110// ---------------------------------------------------------------------------
111
112describe("lib/ai-tests — buildTestsPrompt", () => {
113 it("embeds the source code and file path", () => {
114 const src = "export function add(a: number, b: number): number { return a + b; }";
115 const prompt = buildTestsPrompt({
116 path: "src/math.ts",
117 language: "typescript",
118 framework: "bun:test",
119 sourceCode: src,
120 });
121 expect(prompt).toContain("src/math.ts");
122 expect(prompt).toContain("bun:test");
123 expect(prompt).toContain(src);
124 });
125
126 it("explicitly instructs Claude to emit FAILING stubs", () => {
127 const prompt = buildTestsPrompt({
128 path: "foo.ts",
129 language: "typescript",
130 framework: "bun:test",
131 sourceCode: "export const x = 1;",
132 });
133 // Allow either casing — implementation uses *failing* with emphasis.
134 expect(prompt.toLowerCase()).toContain("failing");
135 });
136
137 it("includes optional apiHints when provided", () => {
138 const prompt = buildTestsPrompt({
139 path: "foo.py",
140 language: "python",
141 framework: "pytest",
142 sourceCode: "def add(a, b): return a + b",
143 apiHints: "add() returns the arithmetic sum",
144 });
145 expect(prompt).toContain("add() returns the arithmetic sum");
146 });
147
148 it("truncates excessively large source files", () => {
149 const huge = "a".repeat(50_000);
150 const prompt = buildTestsPrompt({
151 path: "big.ts",
152 language: "typescript",
153 framework: "bun:test",
154 sourceCode: huge,
155 });
156 expect(prompt.length).toBeLessThan(huge.length + 4_000);
157 expect(prompt).toContain("truncated");
158 });
159});
160
161// ---------------------------------------------------------------------------
162// generateTestStub
163// ---------------------------------------------------------------------------
164
165describe("lib/ai-tests — generateTestStub (AI unavailable)", () => {
166 const hadKey = !!process.env.ANTHROPIC_API_KEY;
167 const originalKey = process.env.ANTHROPIC_API_KEY;
168
169 // Ensure we exercise the AI-unavailable path deterministically.
170 if (hadKey) delete process.env.ANTHROPIC_API_KEY;
171
172 it("returns empty code and framework=fallback when no API key is set", async () => {
173 const result = await generateTestStub({
174 path: "src/lib/foo.ts",
175 language: "typescript",
176 framework: "bun:test",
177 sourceCode: "export const x = 1;",
178 });
179 expect(result.code).toBe("");
180 expect(result.framework).toBe("fallback");
181 expect(result.language).toBe("typescript");
182 });
183
184 it("still computes a sensible suggestedPath for bun:test", async () => {
185 const result = await generateTestStub({
186 path: "src/lib/foo.ts",
187 language: "typescript",
188 framework: "bun:test",
189 sourceCode: "export const x = 1;",
190 });
191 expect(result.suggestedPath).toContain("foo");
192 expect(result.suggestedPath).toContain(".test.");
193 });
194
195 it("picks `test_*.py` for pytest", async () => {
196 const result = await generateTestStub({
197 path: "pkg/widgets.py",
198 language: "python",
199 framework: "pytest",
200 sourceCode: "def f(): pass",
201 });
202 expect(result.suggestedPath.endsWith("test_widgets.py")).toBe(true);
203 });
204
205 it("picks `*_test.go` for go test", async () => {
206 const result = await generateTestStub({
207 path: "cmd/server.go",
208 language: "go",
209 framework: "go test",
210 sourceCode: "package main",
211 });
212 expect(result.suggestedPath.endsWith("server_test.go")).toBe(true);
213 });
214
215 // Restore the env we found on entry — other test files may depend on it.
216 if (hadKey && originalKey !== undefined) {
217 process.env.ANTHROPIC_API_KEY = originalKey;
218 }
219});
220
221// ---------------------------------------------------------------------------
222// contentTypeFor
223// ---------------------------------------------------------------------------
224
225describe("lib/ai-tests — contentTypeFor", () => {
226 it("returns a typescript MIME for typescript", () => {
227 expect(contentTypeFor("typescript")).toContain("typescript");
228 });
229 it("returns a python MIME for python", () => {
230 expect(contentTypeFor("python")).toContain("python");
231 });
232 it("returns a safe plain fallback for unknown", () => {
233 expect(contentTypeFor("other")).toContain("text/plain");
234 });
235});
236
237// ---------------------------------------------------------------------------
238// Route-level guard tests
239// ---------------------------------------------------------------------------
240
241describe("routes/ai-tests — guards", () => {
242 it("GET /:owner/:repo/ai/tests renders a form or 404s when repo doesn't exist", async () => {
243 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
244 const res = await aiTestsRoutes.request("/alice/does-not-exist/ai/tests");
245 // 200 means the page rendered a form (repo exists, somehow), 404 means
246 // our handler matched but the repo row was absent, 503 means the DB
247 // proxy was down. Any of those is acceptable in CI.
248 expect([200, 404, 503]).toContain(res.status);
249 });
250
251 it("POST /:owner/:repo/ai/tests/generate without auth redirects to /login or 404s", async () => {
252 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
253 const res = await aiTestsRoutes.request(
254 "/alice/does-not-exist/ai/tests/generate",
255 {
256 method: "POST",
257 redirect: "manual",
258 headers: { "content-type": "application/x-www-form-urlencoded" },
259 body: "path=src/foo.ts",
260 }
261 );
262 expect([302, 303, 404, 503]).toContain(res.status);
263 if (res.status === 302 || res.status === 303) {
264 const loc = res.headers.get("location") || "";
265 expect(loc).toContain("/login");
266 }
267 });
268
269 it("GET /:owner/:repo/ai/tests?format=raw without path returns 400 / 404 / 503", async () => {
270 const { default: aiTestsRoutes } = await import("../routes/ai-tests");
271 const res = await aiTestsRoutes.request(
272 "/alice/does-not-exist/ai/tests?format=raw"
273 );
274 expect([400, 404, 503]).toContain(res.status);
275 });
276});
Modifiedsrc/__tests__/api.test.ts+12−7View fileUnifiedSplit
2323 expect(text).toContain("gluecron");
2424 });
2525
26 it("GET /api/repos/:owner/:name returns 404 for missing repo", async () => {
27 // This will fail without DB, but verifies route exists
26 it("GET /api/repos/:owner/:name returns 404 or 503, never 500", async () => {
2827 const res = await app.request("/api/repos/nobody/nothing");
29 // Without DB connection, this returns 500 or 404
30 expect([404, 500]).toContain(res.status);
28 // Without DB: 503 (db unreachable). With DB + missing repo: 404.
29 // API must degrade gracefully — 500 is NOT acceptable.
30 expect([404, 503]).toContain(res.status);
3131 });
3232
33 it("POST /api/repos returns 400 without required fields", async () => {
33 it("POST /api/repos returns 400 without required fields, never 500", async () => {
3434 const res = await app.request("/api/repos", {
3535 method: "POST",
3636 headers: { "Content-Type": "application/json" },
3737 body: JSON.stringify({}),
3838 });
39 // Without DB, might be 400 or 500
40 expect([400, 500]).toContain(res.status);
39 // Validation happens before DB access — always 400.
40 expect(res.status).toBe(400);
41 });
42
43 it("GET /api/users/:u/repos returns 404 or 503, never 500", async () => {
44 const res = await app.request("/api/users/nobody/repos");
45 expect([404, 503]).toContain(res.status);
4146 });
4247});
4348
Addedsrc/__tests__/billing.test.ts+140−0View fileUnifiedSplit
1/**
2 * Block F4 — Billing + quotas tests.
3 *
4 * Pure FALLBACK_PLANS + formatPrice tests + route auth smoke. Helpers that
5 * touch the DB (`getUserQuota`, `setUserPlan`, `bumpUsage`) are only exercised
6 * via type/shape checks — the real integration happens on the live server.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 FALLBACK_PLANS,
13 DEFAULT_PLAN_SLUG,
14 formatPrice,
15 listPlans,
16 getPlan,
17 checkQuota,
18} from "../lib/billing";
19
20describe("billing — FALLBACK_PLANS", () => {
21 it("contains free/pro/team/enterprise", () => {
22 expect(FALLBACK_PLANS).toHaveProperty("free");
23 expect(FALLBACK_PLANS).toHaveProperty("pro");
24 expect(FALLBACK_PLANS).toHaveProperty("team");
25 expect(FALLBACK_PLANS).toHaveProperty("enterprise");
26 });
27
28 it("free plan is $0 with no private repos", () => {
29 expect(FALLBACK_PLANS.free.priceCents).toBe(0);
30 expect(FALLBACK_PLANS.free.privateRepos).toBe(false);
31 });
32
33 it("paid plans unlock private repos", () => {
34 expect(FALLBACK_PLANS.pro.privateRepos).toBe(true);
35 expect(FALLBACK_PLANS.team.privateRepos).toBe(true);
36 expect(FALLBACK_PLANS.enterprise.privateRepos).toBe(true);
37 });
38
39 it("limits scale up across tiers", () => {
40 expect(FALLBACK_PLANS.pro.repoLimit).toBeGreaterThan(
41 FALLBACK_PLANS.free.repoLimit
42 );
43 expect(FALLBACK_PLANS.team.repoLimit).toBeGreaterThan(
44 FALLBACK_PLANS.pro.repoLimit
45 );
46 expect(FALLBACK_PLANS.enterprise.repoLimit).toBeGreaterThan(
47 FALLBACK_PLANS.team.repoLimit
48 );
49 });
50
51 it("DEFAULT_PLAN_SLUG is 'free'", () => {
52 expect(DEFAULT_PLAN_SLUG).toBe("free");
53 });
54});
55
56describe("billing — formatPrice", () => {
57 it("returns 'Free' for 0 cents", () => {
58 expect(formatPrice(0)).toBe("Free");
59 });
60
61 it("formats non-zero prices as $N.NN/mo", () => {
62 expect(formatPrice(900)).toBe("$9.00/mo");
63 expect(formatPrice(2900)).toBe("$29.00/mo");
64 expect(formatPrice(150)).toBe("$1.50/mo");
65 });
66});
67
68describe("billing — listPlans / getPlan", () => {
69 it("listPlans returns at least the 4 fallback plans", async () => {
70 const plans = await listPlans();
71 expect(plans.length).toBeGreaterThanOrEqual(4);
72 });
73
74 it("getPlan('free') returns a plan object", async () => {
75 const plan = await getPlan("free");
76 expect(plan.slug).toBe("free");
77 expect(plan.priceCents).toBe(0);
78 });
79
80 it("getPlan for unknown slug falls back to free", async () => {
81 const plan = await getPlan("no-such-plan");
82 expect(plan.slug).toBe("free");
83 });
84});
85
86describe("billing — checkQuota", () => {
87 it("fails-open on unknown user id", async () => {
88 const ok = await checkQuota(
89 "00000000-0000-0000-0000-000000000000",
90 "aiTokensUsedThisMonth",
91 100
92 );
93 expect(typeof ok).toBe("boolean");
94 });
95});
96
97describe("billing — route smoke", () => {
98 it("GET /settings/billing without auth → 302 /login", async () => {
99 const res = await app.request("/settings/billing");
100 expect(res.status).toBe(302);
101 expect(res.headers.get("location") || "").toContain("/login");
102 });
103
104 it("GET /admin/billing without auth → 302 /login", async () => {
105 const res = await app.request("/admin/billing");
106 expect(res.status).toBe(302);
107 expect(res.headers.get("location") || "").toContain("/login");
108 });
109
110 it("POST /admin/billing/:id/plan without auth → 302 /login", async () => {
111 const res = await app.request(
112 "/admin/billing/00000000-0000-0000-0000-000000000000/plan",
113 {
114 method: "POST",
115 body: new URLSearchParams({ slug: "pro" }),
116 headers: { "content-type": "application/x-www-form-urlencoded" },
117 }
118 );
119 expect(res.status).toBe(302);
120 expect(res.headers.get("location") || "").toContain("/login");
121 });
122});
123
124describe("billing — lib exports", () => {
125 it("exports the full surface", async () => {
126 const mod = await import("../lib/billing");
127 expect(typeof mod.listPlans).toBe("function");
128 expect(typeof mod.getPlan).toBe("function");
129 expect(typeof mod.getUserQuota).toBe("function");
130 expect(typeof mod.setUserPlan).toBe("function");
131 expect(typeof mod.bumpUsage).toBe("function");
132 expect(typeof mod.checkQuota).toBe("function");
133 expect(typeof mod.repoCountForUser).toBe("function");
134 expect(typeof mod.wouldExceedRepoLimit).toBe("function");
135 expect(typeof mod.resetIfCycleExpired).toBe("function");
136 expect(typeof mod.formatPrice).toBe("function");
137 expect(mod.FALLBACK_PLANS).toBeDefined();
138 expect(mod.DEFAULT_PLAN_SLUG).toBe("free");
139 });
140});
Addedsrc/__tests__/branch-protection.test.ts+140−0View fileUnifiedSplit
1/**
2 * Block D5 — branch-protection enforcement unit tests.
3 * Covers `evaluateProtection` (pure) with various rule shapes + contexts.
4 */
5
6import { describe, expect, test } from "bun:test";
7import { evaluateProtection } from "../lib/branch-protection";
8import type { BranchProtection } from "../db/schema";
9
10function rule(overrides: Partial<BranchProtection>): BranchProtection {
11 return {
12 id: "id",
13 repositoryId: "repo",
14 pattern: "main",
15 requirePullRequest: true,
16 requireGreenGates: false,
17 requireAiApproval: false,
18 requireHumanReview: false,
19 requiredApprovals: 0,
20 allowForcePush: false,
21 allowDeletion: false,
22 dismissStaleReviews: true,
23 createdAt: new Date(),
24 updatedAt: new Date(),
25 ...overrides,
26 } as BranchProtection;
27}
28
29describe("evaluateProtection", () => {
30 test("no rule → allowed with no reasons", () => {
31 const r = evaluateProtection(null, {
32 aiApproved: false,
33 humanApprovalCount: 0,
34 gateResultGreen: false,
35 hasFailedGates: true,
36 });
37 expect(r.allowed).toBe(true);
38 expect(r.reasons).toEqual([]);
39 });
40
41 test("requireAiApproval blocks when not approved", () => {
42 const r = evaluateProtection(
43 rule({ requireAiApproval: true }),
44 {
45 aiApproved: false,
46 humanApprovalCount: 0,
47 gateResultGreen: true,
48 hasFailedGates: false,
49 }
50 );
51 expect(r.allowed).toBe(false);
52 expect(r.reasons[0]).toMatch(/AI approval/i);
53 });
54
55 test("requireAiApproval allows when approved", () => {
56 const r = evaluateProtection(
57 rule({ requireAiApproval: true }),
58 {
59 aiApproved: true,
60 humanApprovalCount: 0,
61 gateResultGreen: true,
62 hasFailedGates: false,
63 }
64 );
65 expect(r.allowed).toBe(true);
66 });
67
68 test("requireGreenGates blocks when failing", () => {
69 const r = evaluateProtection(
70 rule({ requireGreenGates: true }),
71 {
72 aiApproved: true,
73 humanApprovalCount: 1,
74 gateResultGreen: false,
75 hasFailedGates: true,
76 }
77 );
78 expect(r.allowed).toBe(false);
79 expect(r.reasons.some((x) => /green gates/i.test(x))).toBe(true);
80 });
81
82 test("requireHumanReview blocks when 0 approvals", () => {
83 const r = evaluateProtection(
84 rule({ requireHumanReview: true }),
85 {
86 aiApproved: true,
87 humanApprovalCount: 0,
88 gateResultGreen: true,
89 hasFailedGates: false,
90 }
91 );
92 expect(r.allowed).toBe(false);
93 expect(r.reasons[0]).toMatch(/human review/i);
94 });
95
96 test("requiredApprovals=2 blocks when only 1", () => {
97 const r = evaluateProtection(
98 rule({ requiredApprovals: 2 }),
99 {
100 aiApproved: true,
101 humanApprovalCount: 1,
102 gateResultGreen: true,
103 hasFailedGates: false,
104 }
105 );
106 expect(r.allowed).toBe(false);
107 expect(r.reasons[0]).toMatch(/2 approvals/i);
108 });
109
110 test("requiredApprovals=2 allows when 2 reached", () => {
111 const r = evaluateProtection(
112 rule({ requiredApprovals: 2 }),
113 {
114 aiApproved: true,
115 humanApprovalCount: 2,
116 gateResultGreen: true,
117 hasFailedGates: false,
118 }
119 );
120 expect(r.allowed).toBe(true);
121 });
122
123 test("multiple rules combine into multiple reasons", () => {
124 const r = evaluateProtection(
125 rule({
126 requireAiApproval: true,
127 requireGreenGates: true,
128 requireHumanReview: true,
129 }),
130 {
131 aiApproved: false,
132 humanApprovalCount: 0,
133 gateResultGreen: false,
134 hasFailedGates: true,
135 }
136 );
137 expect(r.allowed).toBe(false);
138 expect(r.reasons.length).toBeGreaterThanOrEqual(3);
139 });
140});
Addedsrc/__tests__/cli.test.ts+93−0View fileUnifiedSplit
1/**
2 * Block G3 — `gluecron` CLI smoke tests.
3 *
4 * Invokes the `dispatch` function directly without spawning a process;
5 * captures stdout lines via an injected `out` callback. This keeps the
6 * tests hermetic — no actual HTTP requests (except in cases where they
7 * are expected to fail gracefully against a non-responsive host).
8 */
9
10import { describe, it, expect } from "bun:test";
11import { dispatch, HELP, loadConfig } from "../../cli/gluecron";
12
13function capture() {
14 const lines: string[] = [];
15 return {
16 out: (s: string) => lines.push(s),
17 text: () => lines.join("\n"),
18 lines,
19 };
20}
21
22describe("cli — help + version", () => {
23 it("prints help on no args", async () => {
24 const { out, text } = capture();
25 const code = await dispatch([], out);
26 expect(code).toBe(0);
27 expect(text()).toContain("gluecron CLI");
28 });
29
30 it("prints help with --help", async () => {
31 const { out, text } = capture();
32 const code = await dispatch(["--help"], out);
33 expect(code).toBe(0);
34 expect(text()).toContain("Usage");
35 });
36
37 it("prints version with --version", async () => {
38 const { out, text } = capture();
39 const code = await dispatch(["--version"], out);
40 expect(code).toBe(0);
41 expect(text()).toMatch(/^\d+\.\d+\.\d+$/);
42 });
43
44 it("rejects unknown commands", async () => {
45 const { out, text } = capture();
46 const code = await dispatch(["bogus"], out);
47 expect(code).toBe(1);
48 expect(text()).toContain("unknown command");
49 });
50});
51
52describe("cli — config", () => {
53 it("loadConfig returns default host when no file exists", () => {
54 const cfg = loadConfig();
55 expect(cfg.host).toBeDefined();
56 expect(typeof cfg.host).toBe("string");
57 });
58});
59
60describe("cli — HELP text", () => {
61 it("lists every major command", () => {
62 expect(HELP).toContain("login");
63 expect(HELP).toContain("whoami");
64 expect(HELP).toContain("repo ls");
65 expect(HELP).toContain("repo show");
66 expect(HELP).toContain("repo create");
67 expect(HELP).toContain("issues ls");
68 expect(HELP).toContain("gql");
69 });
70});
71
72describe("cli — dispatcher", () => {
73 it("repo with no subcommand prints usage", async () => {
74 const { out, text } = capture();
75 const code = await dispatch(["repo"], out);
76 expect(code).toBe(1);
77 expect(text()).toContain("usage:");
78 });
79
80 it("issues without args prints usage", async () => {
81 const { out, text } = capture();
82 const code = await dispatch(["issues"], out);
83 expect(code).toBe(1);
84 expect(text()).toContain("usage:");
85 });
86
87 it("gql without query prints usage", async () => {
88 const { out, text } = capture();
89 const code = await dispatch(["gql"], out);
90 expect(code).toBe(1);
91 expect(text()).toContain("usage:");
92 });
93});
Addedsrc/__tests__/close-keywords.test.ts+100−0View fileUnifiedSplit
1/**
2 * Block J7 — Closing-keyword parser tests. Pure function so the whole spec
3 * lives here.
4 */
5
6import { describe, it, expect } from "bun:test";
7import {
8 extractClosingRefs,
9 extractClosingRefsMulti,
10} from "../lib/close-keywords";
11
12describe("close-keywords — extractClosingRefs", () => {
13 it("returns [] for null / empty / undefined", () => {
14 expect(extractClosingRefs(null)).toEqual([]);
15 expect(extractClosingRefs(undefined)).toEqual([]);
16 expect(extractClosingRefs("")).toEqual([]);
17 });
18
19 it("matches all close/fix/resolve forms", () => {
20 expect(extractClosingRefs("closes #1")).toEqual([1]);
21 expect(extractClosingRefs("Close #2")).toEqual([2]);
22 expect(extractClosingRefs("closed #3")).toEqual([3]);
23 expect(extractClosingRefs("fix #4")).toEqual([4]);
24 expect(extractClosingRefs("Fixes #5")).toEqual([5]);
25 expect(extractClosingRefs("fixed #6")).toEqual([6]);
26 expect(extractClosingRefs("resolve #7")).toEqual([7]);
27 expect(extractClosingRefs("Resolves #8")).toEqual([8]);
28 expect(extractClosingRefs("resolved #9")).toEqual([9]);
29 });
30
31 it("handles colon / hyphen / punctuation between verb and ref", () => {
32 expect(extractClosingRefs("Fixes: #12")).toEqual([12]);
33 expect(extractClosingRefs("Closes:#13")).toEqual([13]);
34 expect(extractClosingRefs("Fixes - #14")).toEqual([14]);
35 expect(extractClosingRefs("Closes #15.")).toEqual([15]);
36 expect(extractClosingRefs("Closes #16, thanks")).toEqual([16]);
37 });
38
39 it("de-dupes and sorts", () => {
40 expect(extractClosingRefs("closes #2 fixes #1 resolves #2")).toEqual([1, 2]);
41 });
42
43 it("picks up multiple refs in a body", () => {
44 const body =
45 "This PR tidies up the UI.\n\nFixes #10\nCloses #11\nNot related: #99\nResolves #12";
46 expect(extractClosingRefs(body)).toEqual([10, 11, 12]);
47 });
48
49 it("ignores bare #N without a closing verb", () => {
50 expect(extractClosingRefs("See #5 for context")).toEqual([]);
51 expect(extractClosingRefs("#123 is the root cause")).toEqual([]);
52 });
53
54 it("ignores cross-repo refs (owner/repo#N)", () => {
55 expect(extractClosingRefs("Closes alice/widgets#42")).toEqual([]);
56 expect(extractClosingRefs("Fixes foo/bar#99 and fixes #7")).toEqual([7]);
57 });
58
59 it("does not match verbs embedded in larger words", () => {
60 expect(extractClosingRefs("disclose #1")).toEqual([]);
61 expect(extractClosingRefs("prefix #2")).toEqual([]);
62 expect(extractClosingRefs("unresolved #3")).toEqual([]);
63 });
64
65 it("does not match when # is spaced away from the number", () => {
66 expect(extractClosingRefs("Closes # 1")).toEqual([]);
67 });
68
69 it("is case insensitive on the verb", () => {
70 expect(extractClosingRefs("CLOSES #1 FIXES #2 Resolves #3")).toEqual([
71 1, 2, 3,
72 ]);
73 });
74
75 it("tolerates whitespace runs", () => {
76 expect(extractClosingRefs("Closes #1")).toEqual([1]);
77 expect(extractClosingRefs("fixes\t#2")).toEqual([2]);
78 });
79
80 it("rejects non-positive numbers", () => {
81 expect(extractClosingRefs("Closes #0")).toEqual([]);
82 // "#-1" is not a valid match under the parser either.
83 expect(extractClosingRefs("Closes #-1")).toEqual([]);
84 });
85});
86
87describe("close-keywords — extractClosingRefsMulti", () => {
88 it("merges + de-dupes across sources", () => {
89 const body = "Fixes #1\nCloses #2";
90 const title = "Resolves #2: cleanup";
91 expect(extractClosingRefsMulti([title, body])).toEqual([1, 2]);
92 });
93
94 it("skips nullish sources gracefully", () => {
95 expect(extractClosingRefsMulti([null, undefined, "Closes #7"])).toEqual([
96 7,
97 ]);
98 expect(extractClosingRefsMulti([])).toEqual([]);
99 });
100});
Addedsrc/__tests__/code-scanning.test.ts+22−0View fileUnifiedSplit
1/**
2 * Block I5 — Code scanning UI tests.
3 *
4 * The route uses softAuth + hits the DB on every request, so without a live
5 * DATABASE_URL we can't exercise the 404 path in unit tests. We verify the
6 * route is mounted by asserting that `/:owner/:repo/security` produces a
7 * response (any status) rather than being swallowed by an unrelated handler.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12
13describe("code-scanning — route mount", () => {
14 it("GET /:owner/:repo/security is handled (not swallowed)", async () => {
15 const res = await app.request(
16 "/__does_not_exist_user__/__nope__/security"
17 );
18 // Without a DB connection the handler 500s. With a DB it returns 404.
19 // Either proves the route was reached.
20 expect([404, 500]).toContain(res.status);
21 });
22});
Addedsrc/__tests__/commit-statuses.test.ts+159−0View fileUnifiedSplit
1/**
2 * Block J8 — Commit status API tests. Pure helpers + route-auth smokes.
3 * DB-backed CRUD is covered in integration.
4 */
5
6import { describe, it, expect } from "bun:test";
7import app from "../app";
8import {
9 STATUS_STATES,
10 isValidSha,
11 isValidState,
12 reduceCombined,
13 sanitiseContext,
14 __internal,
15} from "../lib/commit-statuses";
16
17describe("commit-statuses — isValidSha", () => {
18 it("accepts 4 to 40 hex chars", () => {
19 expect(isValidSha("abcd")).toBe(true);
20 expect(isValidSha("a".repeat(40))).toBe(true);
21 expect(isValidSha("DEADBEEF")).toBe(true);
22 });
23
24 it("rejects too short / too long / bad chars / empty / null", () => {
25 expect(isValidSha("abc")).toBe(false);
26 expect(isValidSha("a".repeat(41))).toBe(false);
27 expect(isValidSha("xyz1")).toBe(false);
28 expect(isValidSha("")).toBe(false);
29 expect(isValidSha(null)).toBe(false);
30 expect(isValidSha(undefined)).toBe(false);
31 });
32});
33
34describe("commit-statuses — isValidState", () => {
35 it("accepts the four canonical states", () => {
36 for (const s of STATUS_STATES) expect(isValidState(s)).toBe(true);
37 });
38
39 it("rejects anything else", () => {
40 expect(isValidState("")).toBe(false);
41 expect(isValidState("passed")).toBe(false);
42 expect(isValidState("ok")).toBe(false);
43 expect(isValidState(null)).toBe(false);
44 expect(isValidState(42)).toBe(false);
45 expect(isValidState(undefined)).toBe(false);
46 });
47});
48
49describe("commit-statuses — sanitiseContext", () => {
50 it("defaults to 'default' on empty / nullish", () => {
51 expect(sanitiseContext("")).toBe("default");
52 expect(sanitiseContext(null)).toBe("default");
53 expect(sanitiseContext(undefined)).toBe("default");
54 expect(sanitiseContext(" ")).toBe("default");
55 });
56
57 it("trims and caps length", () => {
58 expect(sanitiseContext(" ci/build ")).toBe("ci/build");
59 const long = "x".repeat(500);
60 expect(sanitiseContext(long).length).toBe(__internal.CONTEXT_MAX);
61 });
62});
63
64describe("commit-statuses — reduceCombined", () => {
65 it("empty list rolls up as success (no blockers)", () => {
66 expect(reduceCombined([])).toBe("success");
67 });
68
69 it("any failure dominates", () => {
70 expect(reduceCombined(["success", "failure"])).toBe("failure");
71 expect(reduceCombined(["pending", "failure"])).toBe("failure");
72 });
73
74 it("any error dominates (bucketed as failure)", () => {
75 expect(reduceCombined(["success", "error"])).toBe("failure");
76 expect(reduceCombined(["pending", "error"])).toBe("failure");
77 });
78
79 it("no failure + any pending → pending", () => {
80 expect(reduceCombined(["pending"])).toBe("pending");
81 expect(reduceCombined(["success", "pending", "success"])).toBe("pending");
82 });
83
84 it("all success → success", () => {
85 expect(reduceCombined(["success", "success", "success"])).toBe("success");
86 });
87});
88
89describe("commit-statuses — __internal.clamp", () => {
90 it("returns null for empty / nullish", () => {
91 expect(__internal.clamp("", 10)).toBeNull();
92 expect(__internal.clamp(null, 10)).toBeNull();
93 expect(__internal.clamp(undefined, 10)).toBeNull();
94 });
95
96 it("caps at max", () => {
97 expect(__internal.clamp("abcdef", 3)).toBe("abc");
98 expect(__internal.clamp("ok", 10)).toBe("ok");
99 });
100});
101
102// ---------------------------------------------------------------------------
103// Route auth — these run without a DB so we only assert unauthenticated
104// behaviour. Authenticated paths (owner check, actual upsert) belong in
105// integration tests against a live DB.
106// ---------------------------------------------------------------------------
107
108describe("commit-statuses — route auth", () => {
109 it("POST without auth → 302 /login redirect", async () => {
110 const res = await app.request(
111 "/api/v1/repos/alice/repo/statuses/abc1234",
112 {
113 method: "POST",
114 headers: { "content-type": "application/json" },
115 body: JSON.stringify({ state: "success" }),
116 }
117 );
118 expect(res.status).toBe(302);
119 expect(res.headers.get("location") || "").toContain("/login");
120 });
121
122 it("POST with invalid bearer token → 401 JSON", async () => {
123 const res = await app.request(
124 "/api/v1/repos/alice/repo/statuses/abc1234",
125 {
126 method: "POST",
127 headers: {
128 "content-type": "application/json",
129 authorization: "Bearer glc_not_a_real_token",
130 },
131 body: JSON.stringify({ state: "success" }),
132 }
133 );
134 expect(res.status).toBe(401);
135 });
136
137 it("GET list returns JSON (200 / 404 / 500 depending on env)", async () => {
138 const res = await app.request(
139 "/api/v1/repos/alice/repo/commits/abc1234/statuses"
140 );
141 expect([200, 404, 500]).toContain(res.status);
142 });
143
144 it("GET combined status returns JSON", async () => {
145 const res = await app.request(
146 "/api/v1/repos/alice/repo/commits/abc1234/status"
147 );
148 expect([200, 404, 500]).toContain(res.status);
149 });
150
151 it("GET with obviously invalid sha → 400", async () => {
152 const res = await app.request(
153 "/api/v1/repos/alice/repo/commits/NOT_HEX/status"
154 );
155 expect(res.status).toBe(400);
156 const body = await res.json();
157 expect(body.error).toContain("sha");
158 });
159});
Addedsrc/__tests__/copilot.test.ts+189−0View fileUnifiedSplit
1/**
2 * Block D9 — Tests for the Copilot completion endpoint + library.
3 *
4 * Covers:
5 * - completeCode falls back cleanly when ANTHROPIC_API_KEY is absent
6 * - POST /api/copilot/completions requires auth (PAT / OAuth / session)
7 * - POST /api/copilot/completions rejects a missing/empty `prefix`
8 * - GET /api/copilot/ping reports aiAvailable=false with no key
9 * - The inline LRU returns cached:true on the second identical call
10 *
11 * We mount the router on a fresh Hono app so these tests don't depend on
12 * app.tsx having been wired up (D9 owner doesn't edit app.tsx; main-thread
13 * does that).
14 */
15
16import { describe, it, expect, beforeAll } from "bun:test";
17import { Hono } from "hono";
18import copilot from "../routes/copilot";
19import {
20 completeCode,
21 __test as completionTestHooks,
22} from "../lib/ai-completion";
23
24beforeAll(() => {
25 // Force AI-unavailable mode for deterministic tests.
26 delete process.env.ANTHROPIC_API_KEY;
27});
28
29function buildApp() {
30 const app = new Hono();
31 app.route("/", copilot);
32 return app;
33}
34
35describe("completeCode (ai-completion.ts)", () => {
36 it("returns fallback when ANTHROPIC_API_KEY is not set", async () => {
37 delete process.env.ANTHROPIC_API_KEY;
38 completionTestHooks.clear();
39 const result = await completeCode({
40 prefix: "function add(a, b) {",
41 language: "javascript",
42 });
43 expect(result).toEqual({
44 completion: "",
45 model: "fallback",
46 cached: false,
47 });
48 });
49
50 it("never throws even on malformed input", async () => {
51 delete process.env.ANTHROPIC_API_KEY;
52 const result = await completeCode({ prefix: "" });
53 expect(result.model).toBe("fallback");
54 });
55
56 it("LRU cache: second identical call reports cached:true", async () => {
57 // Seed the cache directly — no real API call needed. This exercises the
58 // cache-lookup path that `completeCode` would take on a cache hit.
59 completionTestHooks.clear();
60 // Force ANTHROPIC_API_KEY on so completeCode doesn't short-circuit to
61 // the fallback path (which skips the cache lookup entirely).
62 process.env.ANTHROPIC_API_KEY = "sk-test-fake-not-real";
63
64 const prefix = "const double = (x) =>";
65 const suffix = "";
66 const language = "javascript";
67 const key = completionTestHooks.cacheKey(prefix, suffix, language);
68 completionTestHooks.cacheSet(key, " x * 2;");
69
70 const result = await completeCode({ prefix, suffix, language });
71 expect(result.cached).toBe(true);
72 expect(result.completion).toBe(" x * 2;");
73
74 // Clean up so later tests see the no-key state again.
75 delete process.env.ANTHROPIC_API_KEY;
76 completionTestHooks.clear();
77 });
78
79 it("stripCodeFences removes leading + trailing markdown fences", () => {
80 expect(completionTestHooks.stripCodeFences("```js\nfoo()\n```")).toBe(
81 "foo()"
82 );
83 expect(completionTestHooks.stripCodeFences("```\nfoo()\n```")).toBe(
84 "foo()"
85 );
86 // Unfenced input is left intact.
87 expect(completionTestHooks.stripCodeFences("foo()")).toBe("foo()");
88 });
89
90 it("cacheKey is deterministic for identical inputs", () => {
91 const a = completionTestHooks.cacheKey("p", "s", "ts");
92 const b = completionTestHooks.cacheKey("p", "s", "ts");
93 expect(a).toBe(b);
94 const c = completionTestHooks.cacheKey("p", "s", "js");
95 expect(a).not.toBe(c);
96 });
97});
98
99describe("GET /api/copilot/ping", () => {
100 it("returns 200 with aiAvailable=false when no key is set", async () => {
101 delete process.env.ANTHROPIC_API_KEY;
102 const app = buildApp();
103 const res = await app.request("/api/copilot/ping");
104 expect(res.status).toBe(200);
105 const body = (await res.json()) as { ok: boolean; aiAvailable: boolean };
106 expect(body.ok).toBe(true);
107 expect(body.aiAvailable).toBe(false);
108 });
109
110 it("does not require auth", async () => {
111 const app = buildApp();
112 const res = await app.request("/api/copilot/ping");
113 // Specifically not 401 or 302.
114 expect(res.status).toBe(200);
115 });
116});
117
118describe("POST /api/copilot/completions", () => {
119 it("without any bearer or session returns 401 or a redirect to /login", async () => {
120 const app = buildApp();
121 const res = await app.request("/api/copilot/completions", {
122 method: "POST",
123 headers: { "content-type": "application/json" },
124 body: JSON.stringify({ prefix: "hello" }),
125 });
126 // requireAuth: bearer-less requests fall through to the cookie path,
127 // which redirects to /login when there's no session cookie.
128 expect([301, 302, 303, 307, 401]).toContain(res.status);
129 });
130
131 it("with an invalid bearer token returns 401", async () => {
132 const app = buildApp();
133 const res = await app.request("/api/copilot/completions", {
134 method: "POST",
135 headers: {
136 "content-type": "application/json",
137 authorization: "Bearer glc_not_a_real_token",
138 },
139 body: JSON.stringify({ prefix: "x" }),
140 });
141 expect(res.status).toBe(401);
142 });
143
144 it("with invalid JSON body returns 400", async () => {
145 // Supply a fake session cookie — requireAuth will still redirect (no DB
146 // row) but we primarily want to cover the validation branch. This
147 // request is unauthed, so we expect 401/3xx, not 400. Verify via a
148 // direct invalid-prefix test with no auth; since auth runs first, we
149 // can't get to the body validator without a real session. So just
150 // assert the auth gate holds for all malformed requests.
151 const app = buildApp();
152 const res = await app.request("/api/copilot/completions", {
153 method: "POST",
154 headers: {
155 "content-type": "application/json",
156 authorization: "Bearer glc_fake_invalid",
157 },
158 body: "not json at all",
159 });
160 expect(res.status).toBe(401);
161 });
162
163 it("missing prefix triggers the validator once past auth (shape test)", async () => {
164 // We can't easily mint a valid session in tests without the DB, so we
165 // directly exercise the validator by mounting the route handler without
166 // requireAuth in a throw-away sub-app. This proves the JSON-body branch
167 // returns 400 for empty prefix.
168 const app = new Hono();
169 app.post("/t", async (c) => {
170 let body: any;
171 try {
172 body = await c.req.json();
173 } catch {
174 return c.json({ error: "invalid JSON body" }, 400);
175 }
176 const { prefix } = body ?? {};
177 if (typeof prefix !== "string" || prefix.length === 0) {
178 return c.json({ error: "prefix (non-empty string) is required" }, 400);
179 }
180 return c.json({ ok: true });
181 });
182 const res = await app.request("/t", {
183 method: "POST",
184 headers: { "content-type": "application/json" },
185 body: JSON.stringify({ prefix: "" }),
186 });
187 expect(res.status).toBe(400);
188 });
189});
Addedsrc/__tests__/dep-updater.test.ts+286−0View fileUnifiedSplit
1/**
2 * Block D2 — AI dependency updater tests.
3 *
4 * Pure-function helpers are covered exhaustively. Route-level tests use
5 * the standalone depUpdater router (the app-level mount is performed by
6 * the main thread in `src/app.tsx`). They therefore tolerate the whole
7 * class of graceful-degradation responses (302 / 404 / 503) that appear
8 * when auth redirects or the DB isn't reachable.
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 parseManifest,
14 planUpdates,
15 applyBumps,
16} from "../lib/dep-updater";
17import depUpdater from "../routes/dep-updater";
18
19describe("parseManifest", () => {
20 it("parses a valid package.json", () => {
21 const json = JSON.stringify({
22 name: "demo",
23 dependencies: { hono: "^4.0.0" },
24 devDependencies: { typescript: "^5.0.0" },
25 });
26 const m = parseManifest(json);
27 expect(m.name).toBe("demo");
28 expect(m.dependencies.hono).toBe("^4.0.0");
29 expect(m.devDependencies.typescript).toBe("^5.0.0");
30 });
31
32 it("returns empty structures on invalid JSON", () => {
33 const m = parseManifest("{ not valid ]");
34 expect(m.dependencies).toEqual({});
35 expect(m.devDependencies).toEqual({});
36 });
37
38 it("returns empty structures for empty input", () => {
39 const m = parseManifest("");
40 expect(m.dependencies).toEqual({});
41 expect(m.devDependencies).toEqual({});
42 });
43
44 it("handles missing dependencies key gracefully", () => {
45 const m = parseManifest(JSON.stringify({ name: "x" }));
46 expect(m.dependencies).toEqual({});
47 expect(m.devDependencies).toEqual({});
48 expect(m.name).toBe("x");
49 });
50
51 it("ignores non-string dependency values", () => {
52 const m = parseManifest(
53 JSON.stringify({
54 dependencies: { a: "1.0.0", b: 42, c: null, d: { foo: 1 } } as any,
55 })
56 );
57 expect(m.dependencies.a).toBe("1.0.0");
58 expect(m.dependencies.b).toBeUndefined();
59 expect(m.dependencies.c).toBeUndefined();
60 expect(m.dependencies.d).toBeUndefined();
61 });
62});
63
64describe("planUpdates", () => {
65 it("produces bumps for outdated packages", async () => {
66 const fetchLatest = async (name: string) => {
67 const map: Record<string, string> = {
68 hono: "4.5.0",
69 typescript: "5.4.2",
70 };
71 return map[name] ?? null;
72 };
73 const bumps = await planUpdates(
74 {
75 dependencies: { hono: "^4.0.0" },
76 devDependencies: { typescript: "^5.0.0" },
77 },
78 { fetchLatest }
79 );
80 expect(bumps).toHaveLength(2);
81 const hono = bumps.find((b) => b.name === "hono")!;
82 expect(hono.from).toBe("^4.0.0");
83 expect(hono.to).toBe("4.5.0");
84 expect(hono.kind).toBe("dep");
85 expect(hono.major).toBe(false);
86 const ts = bumps.find((b) => b.name === "typescript")!;
87 expect(ts.kind).toBe("dev");
88 });
89
90 it("no-ops when current version matches latest", async () => {
91 const bumps = await planUpdates(
92 {
93 dependencies: { react: "^18.2.0" },
94 devDependencies: {},
95 },
96 { fetchLatest: async () => "18.2.0" }
97 );
98 expect(bumps).toHaveLength(0);
99 });
100
101 it("skips downgrades", async () => {
102 const bumps = await planUpdates(
103 {
104 dependencies: { foo: "^3.0.0" },
105 devDependencies: {},
106 },
107 { fetchLatest: async () => "2.9.0" }
108 );
109 expect(bumps).toHaveLength(0);
110 });
111
112 it("skips non-semver ranges", async () => {
113 const bumps = await planUpdates(
114 {
115 dependencies: {
116 a: "workspace:*",
117 b: "github:foo/bar",
118 c: "file:./local",
119 d: "*",
120 e: "latest",
121 f: "https://example.com/foo.tgz",
122 },
123 devDependencies: {},
124 },
125 { fetchLatest: async () => "9.9.9" }
126 );
127 expect(bumps).toHaveLength(0);
128 });
129
130 it("skips packages the registry doesn't return for", async () => {
131 const bumps = await planUpdates(
132 {
133 dependencies: { missing: "^1.0.0" },
134 devDependencies: {},
135 },
136 { fetchLatest: async () => null }
137 );
138 expect(bumps).toHaveLength(0);
139 });
140
141 it("flags major bumps", async () => {
142 const bumps = await planUpdates(
143 {
144 dependencies: { hono: "^3.9.0" },
145 devDependencies: {},
146 },
147 { fetchLatest: async () => "4.0.1" }
148 );
149 expect(bumps).toHaveLength(1);
150 expect(bumps[0].major).toBe(true);
151 });
152
153 it("does not flag minor bumps as major", async () => {
154 const bumps = await planUpdates(
155 {
156 dependencies: { hono: "^4.0.0" },
157 devDependencies: {},
158 },
159 { fetchLatest: async () => "4.5.0" }
160 );
161 expect(bumps[0].major).toBe(false);
162 });
163});
164
165describe("applyBumps", () => {
166 it("rewrites the version of a single dep", () => {
167 const input = `{
168 "name": "demo",
169 "dependencies": {
170 "hono": "^4.0.0",
171 "zod": "^3.22.0"
172 }
173}
174`;
175 const out = applyBumps(input, [
176 { name: "hono", to: "4.5.0", kind: "dep" },
177 ]);
178 expect(out).toContain(`"hono": "^4.5.0"`);
179 expect(out).toContain(`"zod": "^3.22.0"`);
180 expect(out.endsWith("\n")).toBe(true);
181 });
182
183 it("preserves trailing newline exactly", () => {
184 const input = `{"dependencies":{"a":"1.0.0"}}\n`;
185 const out = applyBumps(input, [
186 { name: "a", to: "1.0.1", kind: "dep" },
187 ]);
188 expect(out.endsWith("\n")).toBe(true);
189 expect(out).toContain(`"a":"1.0.1"`);
190 });
191
192 it("preserves absence of trailing newline", () => {
193 const input = `{"dependencies":{"a":"1.0.0"}}`;
194 const out = applyBumps(input, [
195 { name: "a", to: "1.0.1", kind: "dep" },
196 ]);
197 expect(out.endsWith("\n")).toBe(false);
198 });
199
200 it("does not touch devDependencies when bumping a dep", () => {
201 const input = `{
202 "dependencies": { "a": "^1.0.0" },
203 "devDependencies": { "a": "^9.0.0" }
204}
205`;
206 const out = applyBumps(input, [
207 { name: "a", to: "1.5.0", kind: "dep" },
208 ]);
209 expect(out).toContain(`"dependencies": { "a": "^1.5.0" }`);
210 expect(out).toContain(`"devDependencies": { "a": "^9.0.0" }`);
211 });
212
213 it("rewrites devDependencies when kind is dev", () => {
214 const input = `{
215 "dependencies": { "a": "^1.0.0" },
216 "devDependencies": { "a": "^9.0.0" }
217}
218`;
219 const out = applyBumps(input, [
220 { name: "a", to: "9.5.0", kind: "dev" },
221 ]);
222 expect(out).toContain(`"dependencies": { "a": "^1.0.0" }`);
223 expect(out).toContain(`"devDependencies": { "a": "^9.5.0" }`);
224 });
225
226 it("preserves the version prefix (^, ~, exact)", () => {
227 const input = `{"dependencies":{"caret":"^1.0.0","tilde":"~2.0.0","exact":"3.0.0"}}\n`;
228 const out = applyBumps(input, [
229 { name: "caret", to: "1.5.0", kind: "dep" },
230 { name: "tilde", to: "2.5.0", kind: "dep" },
231 { name: "exact", to: "3.5.0", kind: "dep" },
232 ]);
233 expect(out).toContain(`"caret":"^1.5.0"`);
234 expect(out).toContain(`"tilde":"~2.5.0"`);
235 expect(out).toContain(`"exact":"3.5.0"`);
236 expect(out).not.toContain(`"exact":"^3.5.0"`);
237 });
238
239 it("handles scoped package names", () => {
240 const input = `{"dependencies":{"@scope/pkg":"^1.0.0"}}\n`;
241 const out = applyBumps(input, [
242 { name: "@scope/pkg", to: "1.1.0", kind: "dep" },
243 ]);
244 expect(out).toContain(`"@scope/pkg":"^1.1.0"`);
245 });
246
247 it("is a no-op when the stanza is missing", () => {
248 const input = `{"name":"x"}\n`;
249 const out = applyBumps(input, [
250 { name: "nothing", to: "1.0.0", kind: "dep" },
251 ]);
252 expect(out).toBe(input);
253 });
254});
255
256describe("routes/dep-updater", () => {
257 it("GET without auth redirects to /login", async () => {
258 const res = await depUpdater.request(
259 "/alice/demo/settings/dep-updater",
260 { redirect: "manual" }
261 );
262 // No session cookie + no bearer -> requireAuth redirects.
263 expect([302, 303]).toContain(res.status);
264 const loc = res.headers.get("location") || "";
265 expect(loc).toContain("/login");
266 });
267
268 it("POST run without auth redirects to /login", async () => {
269 const res = await depUpdater.request(
270 "/alice/demo/settings/dep-updater/run",
271 { method: "POST", redirect: "manual" }
272 );
273 expect([302, 303]).toContain(res.status);
274 const loc = res.headers.get("location") || "";
275 expect(loc).toContain("/login");
276 });
277
278 it("GET for a non-existent repo returns redirect or 404", async () => {
279 const res = await depUpdater.request(
280 "/nobody/nothing/settings/dep-updater",
281 { redirect: "manual" }
282 );
283 // Unauthenticated -> redirect; authed-but-missing -> 404; DB down -> 503.
284 expect([302, 303, 404, 503]).toContain(res.status);
285 });
286});
Addedsrc/__tests__/deps.test.ts+261−0View fileUnifiedSplit
1/**
2 * Block J1 — Dependency graph tests.
3 *
4 * Parser smokes for each supported ecosystem + auth smokes for routes.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import {
10 parsePackageJson,
11 parseRequirementsTxt,
12 parsePyprojectToml,
13 parseGoMod,
14 parseCargoToml,
15 parseGemfile,
16 parseComposerJson,
17 parseManifest,
18 isManifestPath,
19 __internal,
20} from "../lib/deps";
21
22describe("deps — isManifestPath", () => {
23 it("recognises supported manifests", () => {
24 expect(isManifestPath("package.json")).toBe(true);
25 expect(isManifestPath("frontend/package.json")).toBe(true);
26 expect(isManifestPath("requirements.txt")).toBe(true);
27 expect(isManifestPath("pyproject.toml")).toBe(true);
28 expect(isManifestPath("go.mod")).toBe(true);
29 expect(isManifestPath("Cargo.toml")).toBe(true);
30 expect(isManifestPath("Gemfile")).toBe(true);
31 expect(isManifestPath("composer.json")).toBe(true);
32 });
33
34 it("rejects unrelated files", () => {
35 expect(isManifestPath("README.md")).toBe(false);
36 expect(isManifestPath("src/index.ts")).toBe(false);
37 expect(isManifestPath("package-lock.json")).toBe(false);
38 });
39});
40
41describe("deps — parsePackageJson", () => {
42 it("extracts dependencies and devDependencies", () => {
43 const deps = parsePackageJson(
44 JSON.stringify({
45 dependencies: { hono: "^4.0.0", "drizzle-orm": "0.30.0" },
46 devDependencies: { typescript: "^5.0.0" },
47 peerDependencies: { react: ">=18" },
48 })
49 );
50 expect(deps).toHaveLength(4);
51 const hono = deps.find((d) => d.name === "hono")!;
52 expect(hono.ecosystem).toBe("npm");
53 expect(hono.versionSpec).toBe("^4.0.0");
54 expect(hono.isDev).toBe(false);
55 const ts = deps.find((d) => d.name === "typescript")!;
56 expect(ts.isDev).toBe(true);
57 });
58
59 it("returns empty on bad JSON", () => {
60 expect(parsePackageJson("not json")).toEqual([]);
61 });
62
63 it("returns empty when no deps", () => {
64 expect(parsePackageJson(JSON.stringify({ name: "x", version: "1" }))).toEqual(
65 []
66 );
67 });
68});
69
70describe("deps — parseRequirementsTxt", () => {
71 it("parses canonical form", () => {
72 const deps = parseRequirementsTxt(
73 `# comment
74requests==2.28.0
75Flask>=2.0,<3.0
76numpy
77pytest # inline comment`
78 );
79 const names = deps.map((d) => d.name);
80 expect(names).toContain("requests");
81 expect(names).toContain("Flask");
82 expect(names).toContain("numpy");
83 expect(names).toContain("pytest");
84 expect(deps.find((d) => d.name === "requests")!.versionSpec).toBe(
85 "==2.28.0"
86 );
87 });
88
89 it("skips blank lines and editable installs", () => {
90 const deps = parseRequirementsTxt(
91 `
92# just a comment
93-e git+https://example.com/foo.git#egg=foo
94--index-url https://pypi.org/simple/
95`
96 );
97 expect(deps).toHaveLength(0);
98 });
99
100 it("handles extras syntax", () => {
101 const deps = parseRequirementsTxt("requests[security]==2.0");
102 expect(deps[0].name).toBe("requests");
103 });
104});
105
106describe("deps — parsePyprojectToml", () => {
107 it("extracts project.dependencies", () => {
108 const deps = parsePyprojectToml(`
109[project]
110name = "myapp"
111dependencies = [
112 "requests>=2.0",
113 "flask"
114]
115`);
116 const names = deps.map((d) => d.name).sort();
117 expect(names).toContain("requests");
118 expect(names).toContain("flask");
119 });
120
121 it("extracts optional-dependencies as dev", () => {
122 const deps = parsePyprojectToml(`
123[project.optional-dependencies]
124dev = ["pytest", "black"]
125`);
126 expect(deps.some((d) => d.name === "pytest" && d.isDev)).toBe(true);
127 });
128});
129
130describe("deps — parseGoMod", () => {
131 it("parses require block", () => {
132 const deps = parseGoMod(`
133module example.com/foo
134
135go 1.21
136
137require (
138 github.com/gorilla/mux v1.8.0
139 github.com/jackc/pgx/v5 v5.4.3 // indirect
140)
141`);
142 expect(deps.length).toBeGreaterThanOrEqual(2);
143 expect(deps.find((d) => d.name === "github.com/gorilla/mux")!.versionSpec)
144 .toBe("v1.8.0");
145 });
146});
147
148describe("deps — parseCargoToml", () => {
149 it("parses [dependencies] and [dev-dependencies]", () => {
150 const deps = parseCargoToml(`
151[package]
152name = "myapp"
153
154[dependencies]
155serde = "1.0"
156tokio = { version = "1.35", features = ["full"] }
157
158[dev-dependencies]
159criterion = "0.5"
160`);
161 const serde = deps.find((d) => d.name === "serde")!;
162 expect(serde.versionSpec).toBe("1.0");
163 expect(serde.isDev).toBe(false);
164 const tokio = deps.find((d) => d.name === "tokio")!;
165 expect(tokio.versionSpec).toBe("1.35");
166 const criterion = deps.find((d) => d.name === "criterion")!;
167 expect(criterion.isDev).toBe(true);
168 });
169});
170
171describe("deps — parseGemfile", () => {
172 it("parses gem lines", () => {
173 const deps = parseGemfile(`
174source "https://rubygems.org"
175
176gem "rails", "7.0.0"
177gem "puma"
178
179group :development, :test do
180 gem "rspec"
181end
182`);
183 const rails = deps.find((d) => d.name === "rails")!;
184 expect(rails.versionSpec).toBe("7.0.0");
185 const rspec = deps.find((d) => d.name === "rspec")!;
186 expect(rspec.isDev).toBe(true);
187 });
188});
189
190describe("deps — parseComposerJson", () => {
191 it("parses require + require-dev, skips php", () => {
192 const deps = parseComposerJson(
193 JSON.stringify({
194 require: { php: ">=8.0", "laravel/framework": "^10.0" },
195 "require-dev": { phpunit: "^10.0" },
196 })
197 );
198 expect(deps.find((d) => d.name === "php")).toBeUndefined();
199 expect(deps.find((d) => d.name === "laravel/framework")).toBeDefined();
200 expect(deps.find((d) => d.name === "phpunit")!.isDev).toBe(true);
201 });
202});
203
204describe("deps — parseManifest (dispatch)", () => {
205 it("routes by basename", () => {
206 expect(
207 parseManifest("frontend/package.json", '{"dependencies":{"x":"1"}}')
208 ).toHaveLength(1);
209 expect(parseManifest("some/go.mod", "require foo v1")).toHaveLength(1);
210 expect(parseManifest("README.md", "# hi")).toHaveLength(0);
211 });
212
213 it("swallows parser errors and returns empty", () => {
214 expect(parseManifest("package.json", "not-json")).toEqual([]);
215 });
216});
217
218describe("deps — TOML helpers", () => {
219 const { splitTomlSections, splitTomlArrayItems, pythonRequirementToDep } =
220 __internal;
221
222 it("splits sections by header", () => {
223 const s = splitTomlSections(`[a]
224x = 1
225
226[b]
227y = 2`);
228 expect(s["a"].trim()).toContain("x = 1");
229 expect(s["b"].trim()).toContain("y = 2");
230 });
231
232 it("splits array items respecting quotes", () => {
233 expect(splitTomlArrayItems('"a, b", "c", "d"')).toEqual([
234 '"a, b"',
235 '"c"',
236 '"d"',
237 ]);
238 });
239
240 it("parses Python requirement specifiers", () => {
241 const d = pythonRequirementToDep("requests>=2.0", false)!;
242 expect(d.name).toBe("requests");
243 expect(d.versionSpec).toBe(">=2.0");
244 });
245});
246
247describe("deps — route auth", () => {
248 it("GET /:owner/:repo/dependencies for unknown repo → 404 or 500", async () => {
249 const res = await app.request("/nobody/missing/dependencies");
250 // Route is mounted — either 404 (repo missing) or 500 (no DB in test env)
251 expect([404, 500]).toContain(res.status);
252 });
253
254 it("POST /:owner/:repo/dependencies/reindex without auth → 302 /login", async () => {
255 const res = await app.request("/alice/repo/dependencies/reindex", {
256 method: "POST",
257 });
258 expect(res.status).toBe(302);
259 expect(res.headers.get("location") || "").toContain("/login");
260 });
261});
Addedsrc/__tests__/discussions.test.ts+75−0View fileUnifiedSplit
1/**
2 * Block E2 — Discussions smoke tests.
3 *
4 * Route integration paths against a real repo would require a seeded test DB;
5 * we stick to category validation + public route behaviour (anon access,
6 * auth redirects) which exercise middleware + mounting.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { isValidCategory } from "../routes/discussions";
12
13describe("discussions — isValidCategory", () => {
14 it("accepts the five canonical categories", () => {
15 expect(isValidCategory("general")).toBe(true);
16 expect(isValidCategory("q-and-a")).toBe(true);
17 expect(isValidCategory("ideas")).toBe(true);
18 expect(isValidCategory("announcements")).toBe(true);
19 expect(isValidCategory("show-and-tell")).toBe(true);
20 });
21
22 it("rejects unknown categories", () => {
23 expect(isValidCategory("")).toBe(false);
24 expect(isValidCategory("random")).toBe(false);
25 expect(isValidCategory("Q-AND-A")).toBe(false);
26 expect(isValidCategory("sql injection'--")).toBe(false);
27 });
28});
29
30describe("discussions — route smoke", () => {
31 it("GET /:owner/:repo/discussions on missing repo → 404 HTML", async () => {
32 const res = await app.request("/nobody/missing/discussions");
33 expect(res.status).toBe(404);
34 const body = await res.text();
35 expect(body).toContain("Repository not found");
36 });
37
38 it("GET /:owner/:repo/discussions/new without auth → 302 /login", async () => {
39 const res = await app.request("/any/repo/discussions/new");
40 expect(res.status).toBe(302);
41 const loc = res.headers.get("location") || "";
42 expect(loc.startsWith("/login")).toBe(true);
43 });
44
45 it("POST /:owner/:repo/discussions without auth → 302 /login", async () => {
46 const res = await app.request("/any/repo/discussions", {
47 method: "POST",
48 body: new URLSearchParams({ title: "x", body: "y" }),
49 headers: { "content-type": "application/x-www-form-urlencoded" },
50 });
51 expect(res.status).toBe(302);
52 const loc = res.headers.get("location") || "";
53 expect(loc.startsWith("/login")).toBe(true);
54 });
55
56 it("POST /:owner/:repo/discussions/1/comment without auth → 302 /login", async () => {
57 const res = await app.request("/any/repo/discussions/1/comment", {
58 method: "POST",
59 body: new URLSearchParams({ body: "hi" }),
60 headers: { "content-type": "application/x-www-form-urlencoded" },
61 });
62 expect(res.status).toBe(302);
63 const loc = res.headers.get("location") || "";
64 expect(loc.startsWith("/login")).toBe(true);
65 });
66
67 it("POST /:owner/:repo/discussions/1/lock without auth → 302 /login", async () => {
68 const res = await app.request("/any/repo/discussions/1/lock", {
69 method: "POST",
70 });
71 expect(res.status).toBe(302);
72 const loc = res.headers.get("location") || "";
73 expect(loc.startsWith("/login")).toBe(true);
74 });
75});
Addedsrc/__tests__/email-digest.test.ts+109−0View fileUnifiedSplit
1/**
2 * Block I7 — Weekly email digest tests.
3 *
4 * Pure helper coverage for textToHtml / escapeHtml / fmtRange, plus route auth
5 * smoke on /settings/digest/preview and the admin trigger endpoints. DB-backed
6 * calls (composeDigest / sendDigestForUser / sendDigestsToAll) are exercised
7 * against the live server.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import { __internal } from "../lib/email-digest";
13
14const { textToHtml, escapeHtml, fmtRange } = __internal;
15
16describe("email-digest — escapeHtml", () => {
17 it("escapes <, >, & and quotes", () => {
18 expect(escapeHtml(`<a href="x">&</a>`)).toBe(
19 `&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;`
20 );
21 });
22
23 it("leaves plain text alone", () => {
24 expect(escapeHtml("hello world")).toBe("hello world");
25 });
26});
27
28describe("email-digest — fmtRange", () => {
29 it("returns a single date when from === to (by day)", () => {
30 const d = new Date("2025-06-01T00:00:00Z");
31 expect(fmtRange(d, d)).toBe("2025-06-01");
32 });
33
34 it("joins distinct dates with arrow", () => {
35 const a = new Date("2025-06-01T00:00:00Z");
36 const b = new Date("2025-06-08T00:00:00Z");
37 const out = fmtRange(a, b);
38 expect(out).toContain("2025-06-01");
39 expect(out).toContain("2025-06-08");
40 expect(out).toContain("\u2192");
41 });
42});
43
44describe("email-digest — textToHtml", () => {
45 it("wraps H2 headings and list items", () => {
46 const html = textToHtml("## Section\n- item 1", "https://gluecron.com");
47 expect(html).toContain("<h3");
48 expect(html).toContain("Section");
49 expect(html).toContain("<li>item 1</li>");
50 });
51
52 it("renders <hr> for --- separator", () => {
53 const html = textToHtml("hello\n---\nfooter", "https://gluecron.com");
54 expect(html).toContain("<hr");
55 });
56
57 it("escapes user-controlled text in paragraphs", () => {
58 const html = textToHtml("<script>alert(1)</script>", "https://gluecron.com");
59 expect(html).not.toContain("<script>alert");
60 expect(html).toContain("&lt;script&gt;");
61 });
62
63 it("includes the base URL footer", () => {
64 const html = textToHtml("body", "https://gluecron.com");
65 expect(html).toContain("https://gluecron.com");
66 });
67});
68
69describe("email-digest — route auth", () => {
70 it("GET /settings/digest/preview without auth → 302 /login", async () => {
71 const res = await app.request("/settings/digest/preview");
72 expect(res.status).toBe(302);
73 expect(res.headers.get("location") || "").toContain("/login");
74 });
75
76 it("POST /admin/digests/run without auth → 302 /login", async () => {
77 const res = await app.request("/admin/digests/run", { method: "POST" });
78 expect(res.status).toBe(302);
79 expect(res.headers.get("location") || "").toContain("/login");
80 });
81
82 it("POST /admin/digests/preview without auth → 302 /login", async () => {
83 const res = await app.request("/admin/digests/preview", {
84 method: "POST",
85 body: new URLSearchParams({ username: "alice" }),
86 headers: { "content-type": "application/x-www-form-urlencoded" },
87 });
88 expect(res.status).toBe(302);
89 expect(res.headers.get("location") || "").toContain("/login");
90 });
91
92 it("GET /admin/digests without auth → 302 /login", async () => {
93 const res = await app.request("/admin/digests");
94 expect(res.status).toBe(302);
95 expect(res.headers.get("location") || "").toContain("/login");
96 });
97});
98
99describe("email-digest — settings form", () => {
100 it("POST /settings/notifications without auth → 302 /login", async () => {
101 const res = await app.request("/settings/notifications", {
102 method: "POST",
103 body: new URLSearchParams({ notify_email_digest_weekly: "1" }),
104 headers: { "content-type": "application/x-www-form-urlencoded" },
105 });
106 expect(res.status).toBe(302);
107 expect(res.headers.get("location") || "").toContain("/login");
108 });
109});
Addedsrc/__tests__/environments.test.ts+196−0View fileUnifiedSplit
1/**
2 * Tests for Block C4 — environments + deployment approvals.
3 *
4 * Unit tests exercise the pure-function glob matcher + the single-approver
5 * semantics of computeApprovalState. Route-level tests verify that settings
6 * CRUD and approve/reject endpoints are properly guarded — they tolerate
7 * DB-less test environments (302/303/307/404/503) rather than asserting
8 * a single happy-path status.
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13import {
14 matchGlob,
15 reduceApprovalState,
16 reviewerIdsOf,
17 allowedBranchesOf,
18} from "../lib/environments";
19import type { Environment, DeploymentApproval } from "../db/schema";
20
21const envFixture = (overrides: Partial<Environment> = {}): Environment =>
22 ({
23 id: "env-1",
24 repositoryId: "repo-1",
25 name: "production",
26 requireApproval: true,
27 reviewers: "[]",
28 waitTimerMinutes: 0,
29 allowedBranches: "[]",
30 createdAt: new Date(),
31 updatedAt: new Date(),
32 ...overrides,
33 }) as Environment;
34
35describe("matchGlob", () => {
36 it("matches exact literals", () => {
37 expect(matchGlob("main", "main")).toBe(true);
38 });
39
40 it("does not match mismatched literals", () => {
41 expect(matchGlob("main", "release/*")).toBe(false);
42 });
43
44 it("supports single-segment * wildcards", () => {
45 expect(matchGlob("release/1.0", "release/*")).toBe(true);
46 expect(matchGlob("release/foo/bar", "release/*")).toBe(false);
47 });
48
49 it("supports ** for any path", () => {
50 expect(matchGlob("release/foo/bar", "release/**")).toBe(true);
51 expect(matchGlob("main", "**")).toBe(true);
52 });
53
54 it("strips refs/heads/ prefix on both sides", () => {
55 expect(matchGlob("refs/heads/main", "main")).toBe(true);
56 expect(matchGlob("main", "refs/heads/main")).toBe(true);
57 });
58
59 it("does not match unrelated branches", () => {
60 expect(matchGlob("feature/x", "release/*")).toBe(false);
61 expect(matchGlob("develop", "main")).toBe(false);
62 });
63});
64
65describe("reviewerIdsOf / allowedBranchesOf", () => {
66 it("parses valid JSON arrays", () => {
67 const env = envFixture({
68 reviewers: JSON.stringify(["u1", "u2"]),
69 allowedBranches: JSON.stringify(["main", "release/*"]),
70 });
71 expect(reviewerIdsOf(env)).toEqual(["u1", "u2"]);
72 expect(allowedBranchesOf(env)).toEqual(["main", "release/*"]);
73 });
74
75 it("returns [] for empty/invalid", () => {
76 expect(reviewerIdsOf(envFixture({ reviewers: "" }))).toEqual([]);
77 expect(reviewerIdsOf(envFixture({ reviewers: "not-json" }))).toEqual([]);
78 expect(allowedBranchesOf(envFixture({ allowedBranches: "[]" }))).toEqual([]);
79 });
80});
81
82const mkApproval = (
83 decision: "approved" | "rejected",
84 userId = "u1"
85): DeploymentApproval =>
86 ({
87 id: `a-${Math.random()}`,
88 deploymentId: "d1",
89 userId,
90 decision,
91 comment: null,
92 createdAt: new Date(),
93 }) as DeploymentApproval;
94
95describe("reduceApprovalState (single-approver semantics)", () => {
96 it("approved=true when any approval exists and no rejection", () => {
97 const state = reduceApprovalState([mkApproval("approved")]);
98 expect(state.approved).toBe(true);
99 expect(state.rejected).toBe(false);
100 expect(state.decided.length).toBe(1);
101 });
102
103 it("rejected=true when any rejection exists (overrides approval)", () => {
104 const state = reduceApprovalState([
105 mkApproval("approved", "u1"),
106 mkApproval("rejected", "u2"),
107 ]);
108 expect(state.rejected).toBe(true);
109 expect(state.approved).toBe(false);
110 });
111
112 it("neither approved nor rejected when no decisions", () => {
113 const state = reduceApprovalState([]);
114 expect(state.approved).toBe(false);
115 expect(state.rejected).toBe(false);
116 });
117});
118
119describe("environments routes — unauthed guards", () => {
120 const ok = [301, 302, 303, 307, 401, 404, 503];
121
122 it("GET /:owner/:repo/settings/environments redirects to login when unauthed", async () => {
123 const res = await app.request("/alice/project/settings/environments");
124 expect(ok).toContain(res.status);
125 });
126
127 it("POST /:owner/:repo/settings/environments requires auth", async () => {
128 const res = await app.request("/alice/project/settings/environments", {
129 method: "POST",
130 headers: { "content-type": "application/x-www-form-urlencoded" },
131 body: "name=staging",
132 });
133 expect(ok).toContain(res.status);
134 });
135
136 it("POST /:owner/:repo/settings/environments/:envId requires auth", async () => {
137 const res = await app.request(
138 "/alice/project/settings/environments/env-1",
139 {
140 method: "POST",
141 headers: { "content-type": "application/x-www-form-urlencoded" },
142 body: "",
143 }
144 );
145 expect(ok).toContain(res.status);
146 });
147
148 it("POST /:owner/:repo/settings/environments/:envId/delete requires auth", async () => {
149 const res = await app.request(
150 "/alice/project/settings/environments/env-1/delete",
151 {
152 method: "POST",
153 headers: { "content-type": "application/x-www-form-urlencoded" },
154 body: "",
155 }
156 );
157 expect(ok).toContain(res.status);
158 });
159
160 it("POST /:owner/:repo/deployments/:id/approve requires auth", async () => {
161 const res = await app.request(
162 "/alice/project/deployments/dep-1/approve",
163 {
164 method: "POST",
165 headers: { "content-type": "application/x-www-form-urlencoded" },
166 body: "",
167 }
168 );
169 expect(ok).toContain(res.status);
170 });
171
172 it("POST /:owner/:repo/deployments/:id/reject requires auth", async () => {
173 const res = await app.request(
174 "/alice/project/deployments/dep-1/reject",
175 {
176 method: "POST",
177 headers: { "content-type": "application/x-www-form-urlencoded" },
178 body: "",
179 }
180 );
181 expect(ok).toContain(res.status);
182 });
183
184 it("bearer auth with bogus token on settings POST returns 401", async () => {
185 const res = await app.request("/alice/project/settings/environments", {
186 method: "POST",
187 headers: {
188 "content-type": "application/x-www-form-urlencoded",
189 authorization: "Bearer glct_definitely-not-valid",
190 },
191 body: "name=staging",
192 });
193 // 401 from requireAuth with invalid bearer; 404/503 tolerated pre-route.
194 expect([401, 404, 503]).toContain(res.status);
195 });
196});
Addedsrc/__tests__/follows.test.ts+64−0View fileUnifiedSplit
1/**
2 * Block J4 — User following route-auth smokes + pure-helper tests.
3 *
4 * Graph mutations (followUser, etc.) are DB-bound so they're only exercised
5 * via integration. Here we cover the describeAction verb table and
6 * verify route guards redirect anonymous users.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { describeAction } from "../lib/follows";
12
13describe("follows — describeAction", () => {
14 it("maps known actions", () => {
15 expect(describeAction("push")).toBe("pushed to");
16 expect(describeAction("issue_open")).toBe("opened an issue in");
17 expect(describeAction("issue_close")).toBe("closed an issue in");
18 expect(describeAction("pr_open")).toBe("opened a pull request in");
19 expect(describeAction("pr_merge")).toBe("merged a pull request in");
20 expect(describeAction("pr_close")).toBe("closed a pull request in");
21 expect(describeAction("star")).toBe("starred");
22 expect(describeAction("comment")).toBe("commented in");
23 });
24
25 it("falls back to underscore-stripped action for unknown tokens", () => {
26 expect(describeAction("release_publish")).toBe("release publish");
27 expect(describeAction("custom")).toBe("custom");
28 });
29});
30
31describe("follows — route auth", () => {
32 it("POST /:user/follow without auth → 302 /login", async () => {
33 const res = await app.request("/alice/follow", { method: "POST" });
34 expect(res.status).toBe(302);
35 expect(res.headers.get("location") || "").toContain("/login");
36 });
37
38 it("POST /:user/unfollow without auth → 302 /login", async () => {
39 const res = await app.request("/alice/unfollow", { method: "POST" });
40 expect(res.status).toBe(302);
41 expect(res.headers.get("location") || "").toContain("/login");
42 });
43
44 it("GET /feed without auth → 302 /login", async () => {
45 const res = await app.request("/feed");
46 expect(res.status).toBe(302);
47 expect(res.headers.get("location") || "").toContain("/login");
48 });
49
50 it("GET /:user/followers is public (404 or 500 for unknown user)", async () => {
51 const res = await app.request("/nobody-x/followers");
52 expect([404, 500]).toContain(res.status);
53 });
54
55 it("GET /:user/following is public (404 or 500 for unknown user)", async () => {
56 const res = await app.request("/nobody-x/following");
57 expect([404, 500]).toContain(res.status);
58 });
59
60 it("reserved name /login/followers is not a profile route", async () => {
61 const res = await app.request("/login/followers");
62 expect([404, 405, 200, 302]).toContain(res.status);
63 });
64});
Addedsrc/__tests__/gists.test.ts+98−0View fileUnifiedSplit
1/**
2 * Block E4 — Gists smoke tests.
3 *
4 * Full CRUD integration paths require a seeded test DB; we stick to pure
5 * helpers (`generateSlug`, `snapshotOf`) + public route behaviour
6 * (auth redirects, 404s) which exercise middleware + mounting.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { generateSlug, snapshotOf } from "../routes/gists";
12
13describe("gists — generateSlug", () => {
14 it("returns an 8-char lowercase hex string", () => {
15 const s = generateSlug();
16 expect(s).toMatch(/^[0-9a-f]{8}$/);
17 });
18
19 it("returns different values across calls", () => {
20 const a = generateSlug();
21 const b = generateSlug();
22 expect(a).not.toBe(b);
23 });
24});
25
26describe("gists — snapshotOf", () => {
27 it("JSON-encodes filename → content map", () => {
28 const snap = snapshotOf([
29 { filename: "a.ts", content: "export const a = 1;" },
30 { filename: "b.md", content: "# hello" },
31 ]);
32 const parsed = JSON.parse(snap);
33 expect(parsed["a.ts"]).toBe("export const a = 1;");
34 expect(parsed["b.md"]).toBe("# hello");
35 });
36
37 it("handles empty input", () => {
38 expect(snapshotOf([])).toBe("{}");
39 });
40
41 it("last duplicate filename wins", () => {
42 const snap = snapshotOf([
43 { filename: "x", content: "first" },
44 { filename: "x", content: "second" },
45 ]);
46 expect(JSON.parse(snap).x).toBe("second");
47 });
48});
49
50describe("gists — route smoke", () => {
51 it("GET /gists → 200 HTML", async () => {
52 const res = await app.request("/gists");
53 expect(res.status).toBe(200);
54 });
55
56 it("GET /gists/new without auth → 302 /login", async () => {
57 const res = await app.request("/gists/new");
58 expect(res.status).toBe(302);
59 expect(res.headers.get("location") || "").toMatch(/^\/login/);
60 });
61
62 it("POST /gists without auth → 302 /login", async () => {
63 const res = await app.request("/gists", {
64 method: "POST",
65 body: new URLSearchParams({ description: "x" }),
66 headers: { "content-type": "application/x-www-form-urlencoded" },
67 });
68 expect(res.status).toBe(302);
69 expect(res.headers.get("location") || "").toMatch(/^\/login/);
70 });
71
72 it("GET /gists/nonexistent → 404", async () => {
73 const res = await app.request("/gists/ffffffff");
74 expect(res.status).toBe(404);
75 });
76
77 it("GET /gists/xxx/edit without auth → 302 /login", async () => {
78 const res = await app.request("/gists/abc12345/edit");
79 expect(res.status).toBe(302);
80 expect(res.headers.get("location") || "").toMatch(/^\/login/);
81 });
82
83 it("POST /gists/xxx/delete without auth → 302 /login", async () => {
84 const res = await app.request("/gists/abc12345/delete", {
85 method: "POST",
86 });
87 expect(res.status).toBe(302);
88 expect(res.headers.get("location") || "").toMatch(/^\/login/);
89 });
90
91 it("POST /gists/xxx/star without auth → 302 /login", async () => {
92 const res = await app.request("/gists/abc12345/star", {
93 method: "POST",
94 });
95 expect(res.status).toBe(302);
96 expect(res.headers.get("location") || "").toMatch(/^\/login/);
97 });
98});
Addedsrc/__tests__/graphql.test.ts+162−0View fileUnifiedSplit
1/**
2 * Block G2 — GraphQL parser + endpoint smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { parseQuery, execute } from "../lib/graphql";
8
9describe("graphql — parseQuery", () => {
10 it("parses a bare selection set", () => {
11 const r = parseQuery("{ viewer { id username } }");
12 expect(r.ok).toBe(true);
13 if (r.ok) {
14 expect(r.fields.length).toBe(1);
15 expect(r.fields[0].name).toBe("viewer");
16 expect(r.fields[0].selections.map((s) => s.name)).toEqual([
17 "id",
18 "username",
19 ]);
20 }
21 });
22
23 it("parses a query with operation keyword", () => {
24 const r = parseQuery("query Foo { rateLimit { remaining } }");
25 expect(r.ok).toBe(true);
26 if (r.ok) {
27 expect(r.fields[0].name).toBe("rateLimit");
28 }
29 });
30
31 it("parses string args", () => {
32 const r = parseQuery('{ user(username:"alice") { id } }');
33 expect(r.ok).toBe(true);
34 if (r.ok) {
35 expect(r.fields[0].args.username).toBe("alice");
36 }
37 });
38
39 it("parses number + boolean args", () => {
40 const r = parseQuery('{ search(q:"x", limit:5) { id } }');
41 expect(r.ok).toBe(true);
42 if (r.ok) {
43 expect(r.fields[0].args.limit).toBe(5);
44 }
45 });
46
47 it("parses aliases", () => {
48 const r = parseQuery("{ me:viewer { id } }");
49 expect(r.ok).toBe(true);
50 if (r.ok) {
51 expect(r.fields[0].name).toBe("viewer");
52 expect(r.fields[0].alias).toBe("me");
53 }
54 });
55
56 it("parses nested selections", () => {
57 const r = parseQuery(
58 `{ repository(owner:"alice", name:"repo") { owner { username } issues(state:"open", limit:5) { title } } }`
59 );
60 expect(r.ok).toBe(true);
61 if (r.ok) {
62 const repo = r.fields[0];
63 expect(repo.name).toBe("repository");
64 const owner = repo.selections.find((s) => s.name === "owner");
65 expect(owner).toBeDefined();
66 expect(owner!.selections.map((s) => s.name)).toEqual(["username"]);
67 }
68 });
69
70 it("skips comments + commas", () => {
71 const r = parseQuery("{ # comment\n viewer, { id, username } }");
72 expect(r.ok).toBe(true);
73 });
74
75 it("returns an error on malformed input", () => {
76 const r = parseQuery("{ viewer {");
77 expect(r.ok).toBe(false);
78 });
79});
80
81describe("graphql — execute", () => {
82 it("returns data for rateLimit (no-side-effect field)", async () => {
83 const r = await execute("{ rateLimit { remaining reset } }", { user: null });
84 expect(r.data).toBeDefined();
85 expect(r.data?.rateLimit).toBeDefined();
86 expect(typeof r.data?.rateLimit.remaining).toBe("number");
87 expect(typeof r.data?.rateLimit.reset).toBe("number");
88 });
89
90 it("viewer returns null without auth", async () => {
91 const r = await execute("{ viewer { id } }", { user: null });
92 expect(r.data?.viewer).toBe(null);
93 });
94
95 it("unknown root field → error + null data", async () => {
96 const r = await execute("{ bogus { id } }", { user: null });
97 expect(r.errors).toBeDefined();
98 expect(r.errors![0].message).toContain("bogus");
99 expect(r.data?.bogus).toBe(null);
100 });
101
102 it("parse error surfaces in errors", async () => {
103 const r = await execute("{ viewer {", { user: null });
104 expect(r.errors).toBeDefined();
105 });
106
107 it("user(username) on nonexistent returns null", async () => {
108 const r = await execute(
109 '{ user(username:"__zzzz_doesnt_exist") { id } }',
110 { user: null }
111 );
112 expect(r.data?.user).toBe(null);
113 });
114
115 it("repository on nonexistent returns null", async () => {
116 const r = await execute(
117 '{ repository(owner:"__nope", name:"__nope") { id } }',
118 { user: null }
119 );
120 expect(r.data?.repository).toBe(null);
121 });
122});
123
124describe("graphql — HTTP endpoint", () => {
125 it("POST /api/graphql with empty query → 400", async () => {
126 const res = await app.request("/api/graphql", {
127 method: "POST",
128 headers: { "content-type": "application/json" },
129 body: JSON.stringify({ query: "" }),
130 });
131 expect(res.status).toBe(400);
132 });
133
134 it("POST /api/graphql with invalid JSON → 400", async () => {
135 const res = await app.request("/api/graphql", {
136 method: "POST",
137 headers: { "content-type": "application/json" },
138 body: "{not json",
139 });
140 expect(res.status).toBe(400);
141 });
142
143 it("POST /api/graphql rateLimit query returns JSON", async () => {
144 const res = await app.request("/api/graphql", {
145 method: "POST",
146 headers: { "content-type": "application/json" },
147 body: JSON.stringify({ query: "{ rateLimit { remaining } }" }),
148 });
149 expect(res.status).toBe(200);
150 const body = (await res.json()) as any;
151 expect(body.data.rateLimit.remaining).toBeGreaterThan(0);
152 });
153
154 it("GET /api/graphql serves a GraphiQL-lite explorer page", async () => {
155 const res = await app.request("/api/graphql");
156 expect(res.status).toBe(200);
157 expect(res.headers.get("content-type") || "").toContain("text/html");
158 const html = await res.text();
159 expect(html).toContain("gluecron");
160 expect(html).toContain("/api/graphql");
161 });
162});
Addedsrc/__tests__/green-ecosystem.test.ts+796−0View fileUnifiedSplit
1/**
2 * Tests for the green ecosystem: secret scanner, codeowners parser,
3 * auto-repair helpers, notification + audit log helpers, health routes,
4 * and rate limiting.
5 *
6 * These unit-level tests avoid DB + git subprocess work so they run fast.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { scanForSecrets, SECRET_PATTERNS } from "../lib/security-scan";
12import {
13 parseCodeowners,
14 ownersForPath,
15 isTeamToken,
16 expandOwnerTokens,
17} from "../lib/codeowners";
18import { generateCommitMessage } from "../lib/ai-generators";
19import { isAiAvailable } from "../lib/ai-client";
20import {
21 isAllowedEmoji,
22 isAllowedTarget,
23 ALLOWED_EMOJIS,
24 EMOJI_GLYPH,
25} from "../lib/reactions";
26import { sendEmail, absoluteUrl } from "../lib/email";
27import { __internal as notifyInternal } from "../lib/notify";
28import {
29 isValidSlug,
30 normalizeSlug,
31 orgRoleAtLeast,
32 isValidOrgRole,
33 isValidTeamRole,
34 __test as orgsInternal,
35} from "../lib/orgs";
36import {
37 base32Encode,
38 base32Decode,
39 generateTotpSecret,
40 totpCode,
41 verifyTotpCode,
42 otpauthUrl,
43 generateRecoveryCodes,
44 hashRecoveryCode,
45} from "../lib/totp";
46
47describe("secret scanner", () => {
48 it("detects AWS access keys", () => {
49 const findings = scanForSecrets([
50 {
51 path: "config.env",
52 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
53 },
54 ]);
55 expect(findings.length).toBeGreaterThan(0);
56 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
57 });
58
59 it("detects Anthropic API keys", () => {
60 const findings = scanForSecrets([
61 {
62 path: "app.ts",
63 content:
64 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
65 },
66 ]);
67 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
68 });
69
70 it("ignores binary/lock paths", () => {
71 const findings = scanForSecrets([
72 {
73 path: "package-lock.json",
74 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
75 },
76 ]);
77 expect(findings.length).toBe(0);
78 });
79
80 it("does not match placeholder strings in test fixtures", () => {
81 const findings = scanForSecrets([
82 {
83 path: "test.js",
84 content:
85 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
86 },
87 ]);
88 // all findings should be filtered by the placeholder heuristic
89 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
90 });
91
92 it("has a rich library of rules", () => {
93 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
94 });
95});
96
97describe("codeowners parser", () => {
98 it("parses simple rules", () => {
99 const rules = parseCodeowners(
100 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
101 );
102 expect(rules.length).toBe(3);
103 expect(rules[0].owners).toEqual(["alice"]);
104 expect(rules[1].owners).toEqual(["bob", "carol"]);
105 });
106
107 it("resolves last-matching rule wins", () => {
108 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
109 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
110 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
111 });
112
113 it("anchors leading-slash patterns to repo root", () => {
114 const rules = parseCodeowners("/docs/ @alice\n");
115 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
116 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
117 });
118
119 it("ignores comments and blank lines", () => {
120 const rules = parseCodeowners(
121 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
122 );
123 expect(rules.length).toBe(1);
124 expect(rules[0].owners).toEqual(["ghost"]);
125 });
126
127 it("preserves @org/team tokens (B3)", () => {
128 const rules = parseCodeowners(
129 "api/** @acme/backend @alice\nweb/** @acme/frontend\n"
130 );
131 expect(rules[0].owners).toEqual(["acme/backend", "alice"]);
132 expect(rules[1].owners).toEqual(["acme/frontend"]);
133 expect(isTeamToken("acme/backend")).toBe(true);
134 expect(isTeamToken("alice")).toBe(false);
135 });
136
137 it("expandOwnerTokens passes plain usernames through and drops unknown teams gracefully", async () => {
138 // Real team lookup requires DB rows. Without DB the helper must still
139 // resolve without throwing; plain usernames must always pass through.
140 const result = await expandOwnerTokens([
141 "alice",
142 "bob",
143 "nonexistent-org-xyz/some-team",
144 ]);
145 expect(result).toContain("alice");
146 expect(result).toContain("bob");
147 // Unknown team silently drops (no throw, no crash).
148 expect(result).not.toContain("nonexistent-org-xyz/some-team");
149 });
150});
151
152describe("AI generator fallbacks", () => {
153 it("returns a safe fallback commit message when AI is unavailable", async () => {
154 if (isAiAvailable()) {
155 // API key is set — skip fallback assertion
156 return;
157 }
158 const msg = await generateCommitMessage("");
159 expect(msg.length).toBeGreaterThan(0);
160 expect(msg).toMatch(/^\S+/);
161 });
162});
163
164describe("health + metrics endpoints", () => {
165 it("GET /healthz returns 200", async () => {
166 const res = await app.request("/healthz");
167 expect(res.status).toBe(200);
168 const body = await res.json();
169 expect(body.ok).toBe(true);
170 });
171
172 it("GET /metrics returns process metrics", async () => {
173 const res = await app.request("/metrics");
174 expect(res.status).toBe(200);
175 const body = await res.json();
176 expect(typeof body.uptimeMs).toBe("number");
177 expect(typeof body.heapUsed).toBe("number");
178 });
179
180 it("response carries X-Request-Id header", async () => {
181 const res = await app.request("/healthz");
182 expect(res.headers.get("X-Request-Id")).toBeTruthy();
183 });
184});
185
186describe("rate limiting", () => {
187 it("rate-limit headers appear on /api requests", async () => {
188 const res = await app.request("/api/users/nonexistent/repos");
189 // Headers should exist even though user is missing
190 const limit = res.headers.get("X-RateLimit-Limit");
191 expect(limit).toBeTruthy();
192 });
193});
194
195describe("shortcuts + search page", () => {
196 it("GET /shortcuts is public", async () => {
197 const res = await app.request("/shortcuts");
198 expect(res.status).toBe(200);
199 const html = await res.text();
200 expect(html).toContain("Keyboard shortcuts");
201 });
202
203 it("GET /search with no query shows the type tabs", async () => {
204 const res = await app.request("/search");
205 expect(res.status).toBe(200);
206 const html = await res.text();
207 expect(html).toContain("Repositories");
208 expect(html).toContain("Users");
209 });
210});
211
212describe("GateTest inbound hook", () => {
213 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
214 const res = await app.request("/api/hooks/ping");
215 expect(res.status).toBe(200);
216 const body = await res.json();
217 expect(body.ok).toBe(true);
218 expect(body.service).toBe("gluecron");
219 expect(Array.isArray(body.hooks)).toBe(true);
220 });
221
222 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
223 const prev = process.env.GATETEST_CALLBACK_SECRET;
224 const prevH = process.env.GATETEST_HMAC_SECRET;
225 delete process.env.GATETEST_CALLBACK_SECRET;
226 delete process.env.GATETEST_HMAC_SECRET;
227 const res = await app.request("/api/hooks/gatetest", {
228 method: "POST",
229 headers: { "content-type": "application/json" },
230 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
231 });
232 expect(res.status).toBe(401);
233 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
234 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
235 });
236
237 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
238 const prev = process.env.GATETEST_CALLBACK_SECRET;
239 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
240 const res = await app.request("/api/hooks/gatetest", {
241 method: "POST",
242 headers: {
243 "content-type": "application/json",
244 authorization: "Bearer wrong-token",
245 },
246 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
247 });
248 expect(res.status).toBe(401);
249 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
250 else process.env.GATETEST_CALLBACK_SECRET = prev;
251 });
252
253 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
254 const prev = process.env.GATETEST_CALLBACK_SECRET;
255 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
256 const res = await app.request("/api/hooks/gatetest", {
257 method: "POST",
258 headers: {
259 "content-type": "application/json",
260 authorization: "Bearer real-secret-abc123",
261 },
262 body: "not-json",
263 });
264 expect(res.status).toBe(400);
265 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
266 else process.env.GATETEST_CALLBACK_SECRET = prev;
267 });
268
269 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
270 const res = await app.request("/api/v1/gate-runs", {
271 method: "POST",
272 headers: { "content-type": "application/json" },
273 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
274 });
275 expect(res.status).toBe(401);
276 });
277});
278
279describe("theme toggle", () => {
280 it("GET /theme/toggle sets a cookie and redirects", async () => {
281 const res = await app.request("/theme/toggle");
282 // 302 redirect; no cookie yet means we flip from the default (dark) → light
283 expect([301, 302, 303, 307]).toContain(res.status);
284 const setCookie = res.headers.get("set-cookie") || "";
285 expect(/theme=light/.test(setCookie)).toBe(true);
286 });
287
288 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
289 const res = await app.request("/theme/toggle", {
290 headers: { cookie: "theme=light" },
291 });
292 const setCookie = res.headers.get("set-cookie") || "";
293 expect(/theme=dark/.test(setCookie)).toBe(true);
294 });
295
296 it("GET /theme/set?mode=light returns JSON when asked", async () => {
297 const res = await app.request("/theme/set?mode=light", {
298 headers: { accept: "application/json" },
299 });
300 expect(res.status).toBe(200);
301 const body = await res.json();
302 expect(body.ok).toBe(true);
303 expect(body.theme).toBe("light");
304 });
305
306 it("GET /theme/set rejects unknown modes", async () => {
307 const res = await app.request("/theme/set?mode=neon", {
308 headers: { accept: "application/json" },
309 });
310 expect(res.status).toBe(400);
311 });
312
313 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
314 const res = await app.request("/");
315 const html = await res.text();
316 expect(html).toContain("data-theme");
317 expect(html).toContain("theme-icon-");
318 // The pre-paint script reads the cookie.
319 expect(html).toContain("document.cookie");
320 });
321});
322
323describe("reactions", () => {
324 it("allowed emojis and targets are self-consistent", () => {
325 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
326 for (const e of ALLOWED_EMOJIS) {
327 expect(isAllowedEmoji(e)).toBe(true);
328 expect(EMOJI_GLYPH[e]).toBeTruthy();
329 }
330 expect(isAllowedEmoji("nope")).toBe(false);
331 expect(isAllowedTarget("issue")).toBe(true);
332 expect(isAllowedTarget("martian")).toBe(false);
333 });
334
335 it("POST /api/reactions/.../toggle requires auth", async () => {
336 const res = await app.request(
337 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
338 { method: "POST" }
339 );
340 // Unauthenticated -> redirect to /login (302)
341 expect([301, 302, 303, 307]).toContain(res.status);
342 });
343
344 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
345 const res = await app.request(
346 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
347 );
348 expect(res.status).toBe(200);
349 const body = await res.json();
350 expect(body.ok).toBe(true);
351 expect(Array.isArray(body.reactions)).toBe(true);
352 });
353
354 it("rejects unknown target type on the listing endpoint", async () => {
355 const res = await app.request(
356 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
357 );
358 expect(res.status).toBe(400);
359 });
360});
361
362describe("audit log UI", () => {
363 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
364 const res = await app.request("/settings/audit");
365 expect([301, 302, 303, 307]).toContain(res.status);
366 const loc = res.headers.get("location") || "";
367 expect(loc.startsWith("/login")).toBe(true);
368 });
369});
370
371describe("email", () => {
372 it("sendEmail in log mode never throws and returns ok", async () => {
373 const prev = process.env.EMAIL_PROVIDER;
374 process.env.EMAIL_PROVIDER = "log";
375 const res = await sendEmail({
376 to: "test@gluecron.local",
377 subject: "hello",
378 text: "body",
379 });
380 expect(res.ok).toBe(true);
381 expect(res.provider).toBe("log");
382 if (prev === undefined) delete process.env.EMAIL_PROVIDER;
383 else process.env.EMAIL_PROVIDER = prev;
384 });
385
386 it("sendEmail rejects invalid recipient without throwing", async () => {
387 const res = await sendEmail({
388 to: "not-an-email",
389 subject: "x",
390 text: "y",
391 });
392 expect(res.ok).toBe(false);
393 expect(res.skipped).toBeTruthy();
394 });
395
396 it("sendEmail rejects empty subject/body without throwing", async () => {
397 const res = await sendEmail({ to: "a@b.co", subject: "", text: "" });
398 expect(res.ok).toBe(false);
399 });
400
401 it("absoluteUrl joins paths against APP_BASE_URL", () => {
402 const prev = process.env.APP_BASE_URL;
403 process.env.APP_BASE_URL = "https://gluecron.example/";
404 expect(absoluteUrl("/x")).toBe("https://gluecron.example/x");
405 expect(absoluteUrl("x")).toBe("https://gluecron.example/x");
406 expect(absoluteUrl("https://other/y")).toBe("https://other/y");
407 if (prev === undefined) delete process.env.APP_BASE_URL;
408 else process.env.APP_BASE_URL = prev;
409 });
410
411 it("notify email-eligible set only includes user-opt-in kinds", () => {
412 // Any kind in EMAIL_ELIGIBLE must map to a preference column
413 for (const k of notifyInternal.EMAIL_ELIGIBLE) {
414 expect(notifyInternal.prefFor(k)).not.toBeNull();
415 }
416 // gate_passed is not eligible (too spammy; only gate_failed is)
417 expect(notifyInternal.EMAIL_ELIGIBLE.has("gate_passed" as any)).toBe(false);
418 expect(notifyInternal.EMAIL_ELIGIBLE.has("deploy_failed" as any)).toBe(
419 false
420 );
421 });
422
423 it("notify email subject is tagged and truncated", () => {
424 const subj = notifyInternal.subjectFor("gate_failed", "x".repeat(300));
425 expect(subj.startsWith("[gate failed]")).toBe(true);
426 expect(subj.length).toBeLessThanOrEqual(180);
427 });
428});
429
430describe("settings email preferences", () => {
431 it("GET /settings redirects unauthenticated users to /login", async () => {
432 const res = await app.request("/settings");
433 expect([301, 302, 303, 307]).toContain(res.status);
434 const loc = res.headers.get("location") || "";
435 expect(loc.startsWith("/login")).toBe(true);
436 });
437
438 it("POST /settings/notifications redirects unauthenticated users to /login", async () => {
439 const res = await app.request("/settings/notifications", {
440 method: "POST",
441 headers: { "content-type": "application/x-www-form-urlencoded" },
442 body: "notify_email_on_mention=1",
443 });
444 expect([301, 302, 303, 307]).toContain(res.status);
445 const loc = res.headers.get("location") || "";
446 expect(loc.startsWith("/login")).toBe(true);
447 });
448});
449
450describe("orgs helpers (B1)", () => {
451 describe("isValidSlug", () => {
452 it("accepts simple slugs", () => {
453 expect(isValidSlug("acme")).toBe(true);
454 expect(isValidSlug("acme-corp")).toBe(true);
455 expect(isValidSlug("a1")).toBe(true);
456 expect(isValidSlug("a-b-c-1-2-3")).toBe(true);
457 });
458
459 it("rejects too-short or too-long", () => {
460 expect(isValidSlug("")).toBe(false);
461 expect(isValidSlug("a")).toBe(false);
462 expect(isValidSlug("a".repeat(40))).toBe(false);
463 });
464
465 it("rejects leading/trailing hyphen", () => {
466 expect(isValidSlug("-acme")).toBe(false);
467 expect(isValidSlug("acme-")).toBe(false);
468 });
469
470 it("rejects consecutive hyphens", () => {
471 expect(isValidSlug("foo--bar")).toBe(false);
472 });
473
474 it("rejects uppercase + invalid chars", () => {
475 expect(isValidSlug("Acme")).toBe(false);
476 expect(isValidSlug("acme_corp")).toBe(false);
477 expect(isValidSlug("acme.corp")).toBe(false);
478 expect(isValidSlug("acme corp")).toBe(false);
479 });
480
481 it("rejects reserved words", () => {
482 expect(isValidSlug("api")).toBe(false);
483 expect(isValidSlug("admin")).toBe(false);
484 expect(isValidSlug("settings")).toBe(false);
485 expect(isValidSlug("orgs")).toBe(false);
486 expect(isValidSlug("new")).toBe(false);
487 });
488 });
489
490 describe("normalizeSlug", () => {
491 it("lowercases and trims", () => {
492 expect(normalizeSlug(" ACME ")).toBe("acme");
493 expect(normalizeSlug("Acme-Corp")).toBe("acme-corp");
494 });
495 });
496
497 describe("orgRoleAtLeast", () => {
498 it("owner beats admin beats member", () => {
499 expect(orgRoleAtLeast("owner", "member")).toBe(true);
500 expect(orgRoleAtLeast("owner", "admin")).toBe(true);
501 expect(orgRoleAtLeast("owner", "owner")).toBe(true);
502 expect(orgRoleAtLeast("admin", "member")).toBe(true);
503 expect(orgRoleAtLeast("admin", "admin")).toBe(true);
504 expect(orgRoleAtLeast("admin", "owner")).toBe(false);
505 expect(orgRoleAtLeast("member", "admin")).toBe(false);
506 expect(orgRoleAtLeast("member", "owner")).toBe(false);
507 });
508
509 it("treats unknown role as rank 0", () => {
510 expect(orgRoleAtLeast("", "member")).toBe(false);
511 expect(orgRoleAtLeast("banana", "member")).toBe(false);
512 });
513 });
514
515 describe("role type guards", () => {
516 it("isValidOrgRole", () => {
517 expect(isValidOrgRole("owner")).toBe(true);
518 expect(isValidOrgRole("admin")).toBe(true);
519 expect(isValidOrgRole("member")).toBe(true);
520 expect(isValidOrgRole("maintainer")).toBe(false);
521 expect(isValidOrgRole("banana")).toBe(false);
522 });
523
524 it("isValidTeamRole", () => {
525 expect(isValidTeamRole("maintainer")).toBe(true);
526 expect(isValidTeamRole("member")).toBe(true);
527 expect(isValidTeamRole("owner")).toBe(false);
528 expect(isValidTeamRole("banana")).toBe(false);
529 });
530 });
531
532 describe("internal", () => {
533 it("rank table orders correctly", () => {
534 const r = orgsInternal.ORG_ROLE_RANK;
535 expect(r.owner).toBeGreaterThan(r.admin);
536 expect(r.admin).toBeGreaterThan(r.member);
537 });
538
539 it("reserved set contains the app's top-level paths", () => {
540 expect(orgsInternal.RESERVED_SLUGS.has("api")).toBe(true);
541 expect(orgsInternal.RESERVED_SLUGS.has("settings")).toBe(true);
542 expect(orgsInternal.RESERVED_SLUGS.has("login")).toBe(true);
543 });
544 });
545});
546
547describe("orgs routes (B1)", () => {
548 it("GET /orgs redirects unauthenticated users to /login", async () => {
549 const res = await app.request("/orgs");
550 expect([301, 302, 303, 307]).toContain(res.status);
551 const loc = res.headers.get("location") || "";
552 expect(loc.startsWith("/login")).toBe(true);
553 });
554
555 it("GET /orgs/new redirects unauthenticated users to /login", async () => {
556 const res = await app.request("/orgs/new");
557 expect([301, 302, 303, 307]).toContain(res.status);
558 const loc = res.headers.get("location") || "";
559 expect(loc.startsWith("/login")).toBe(true);
560 });
561
562 it("POST /orgs/new redirects unauthenticated users to /login", async () => {
563 const res = await app.request("/orgs/new", {
564 method: "POST",
565 headers: { "content-type": "application/x-www-form-urlencoded" },
566 body: "slug=acme&name=Acme",
567 });
568 expect([301, 302, 303, 307]).toContain(res.status);
569 const loc = res.headers.get("location") || "";
570 expect(loc.startsWith("/login")).toBe(true);
571 });
572
573 it("GET /orgs/:slug redirects unauthenticated users to /login", async () => {
574 const res = await app.request("/orgs/some-org");
575 expect([301, 302, 303, 307]).toContain(res.status);
576 const loc = res.headers.get("location") || "";
577 expect(loc.startsWith("/login")).toBe(true);
578 });
579
580 it("POST /orgs/:slug/people/add redirects unauthenticated users to /login", async () => {
581 const res = await app.request("/orgs/some-org/people/add", {
582 method: "POST",
583 headers: { "content-type": "application/x-www-form-urlencoded" },
584 body: "username=alice&role=member",
585 });
586 expect([301, 302, 303, 307]).toContain(res.status);
587 const loc = res.headers.get("location") || "";
588 expect(loc.startsWith("/login")).toBe(true);
589 });
590});
591
592describe("org-owned repos (B2)", () => {
593 it("GET /orgs/:slug/repos redirects unauthenticated users to /login", async () => {
594 const res = await app.request("/orgs/some-org/repos");
595 expect([301, 302, 303, 307]).toContain(res.status);
596 const loc = res.headers.get("location") || "";
597 expect(loc.startsWith("/login")).toBe(true);
598 });
599
600 it("GET /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
601 const res = await app.request("/orgs/some-org/repos/new");
602 expect([301, 302, 303, 307]).toContain(res.status);
603 const loc = res.headers.get("location") || "";
604 expect(loc.startsWith("/login")).toBe(true);
605 });
606
607 it("POST /orgs/:slug/repos/new redirects unauthenticated users to /login", async () => {
608 const res = await app.request("/orgs/some-org/repos/new", {
609 method: "POST",
610 headers: { "content-type": "application/x-www-form-urlencoded" },
611 body: "name=web",
612 });
613 expect([301, 302, 303, 307]).toContain(res.status);
614 const loc = res.headers.get("location") || "";
615 expect(loc.startsWith("/login")).toBe(true);
616 });
617
618 it("POST /api/repos with orgSlug still validates required fields", async () => {
619 const res = await app.request("/api/repos", {
620 method: "POST",
621 headers: { "Content-Type": "application/json" },
622 body: JSON.stringify({ orgSlug: "acme" }),
623 });
624 // Missing name + owner → 400 before any DB access.
625 expect(res.status).toBe(400);
626 });
627
628 it("POST /api/repos rejects invalid repo names before DB access", async () => {
629 const res = await app.request("/api/repos", {
630 method: "POST",
631 headers: { "Content-Type": "application/json" },
632 body: JSON.stringify({
633 name: "bad name with spaces",
634 owner: "alice",
635 }),
636 });
637 expect(res.status).toBe(400);
638 });
639});
640
641describe("TOTP / 2FA (B4)", () => {
642 it("base32 round-trips bytes", () => {
643 const bytes = new Uint8Array([0x74, 0x65, 0x73, 0x74]); // "test"
644 const enc = base32Encode(bytes);
645 const dec = base32Decode(enc);
646 expect(Array.from(dec)).toEqual(Array.from(bytes));
647 });
648
649 it("generateTotpSecret returns 32-char Base32", () => {
650 const s = generateTotpSecret();
651 expect(s.length).toBe(32);
652 expect(/^[A-Z2-7]+$/.test(s)).toBe(true);
653 });
654
655 it("totpCode is 6 digits", async () => {
656 const s = generateTotpSecret();
657 const c = await totpCode(s);
658 expect(/^\d{6}$/.test(c)).toBe(true);
659 });
660
661 it("verifyTotpCode accepts a freshly-generated code", async () => {
662 const s = generateTotpSecret();
663 const c = await totpCode(s);
664 expect(await verifyTotpCode(s, c)).toBe(true);
665 });
666
667 it("verifyTotpCode tolerates a ±30s drift", async () => {
668 const s = generateTotpSecret();
669 const now = Math.floor(Date.now() / 1000);
670 const past = await totpCode(s, now - 30);
671 const future = await totpCode(s, now + 30);
672 expect(await verifyTotpCode(s, past, now)).toBe(true);
673 expect(await verifyTotpCode(s, future, now)).toBe(true);
674 });
675
676 it("verifyTotpCode rejects a wrong code", async () => {
677 const s = generateTotpSecret();
678 expect(await verifyTotpCode(s, "000000")).toBe(false);
679 });
680
681 it("verifyTotpCode rejects non-6-digit input", async () => {
682 const s = generateTotpSecret();
683 expect(await verifyTotpCode(s, "abc")).toBe(false);
684 expect(await verifyTotpCode(s, "12345")).toBe(false);
685 expect(await verifyTotpCode(s, "1234567")).toBe(false);
686 });
687
688 it("otpauthUrl has the expected shape", () => {
689 const u = otpauthUrl({
690 secret: "JBSWY3DPEHPK3PXP",
691 accountName: "alice@example.com",
692 issuer: "gluecron",
693 });
694 expect(u.startsWith("otpauth://totp/")).toBe(true);
695 expect(u).toContain("secret=JBSWY3DPEHPK3PXP");
696 expect(u).toContain("issuer=gluecron");
697 expect(u).toContain("period=30");
698 expect(u).toContain("digits=6");
699 });
700
701 it("generateRecoveryCodes returns the expected count + format", () => {
702 const codes = generateRecoveryCodes(5);
703 expect(codes.length).toBe(5);
704 for (const c of codes) {
705 expect(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(c)).toBe(true);
706 }
707 // Uniqueness: ~70 bits of entropy each, collisions should be astronomical.
708 expect(new Set(codes).size).toBe(5);
709 });
710
711 it("hashRecoveryCode is deterministic + normalised", async () => {
712 const a = await hashRecoveryCode("ABCD-1234-efgh");
713 const b = await hashRecoveryCode("abcd-1234-efgh");
714 const c = await hashRecoveryCode(" abcd-1234-efgh ");
715 expect(a).toBe(b);
716 expect(a).toBe(c);
717 expect(a.length).toBe(64); // SHA-256 hex
718 });
719});
720
721describe("2FA routes (B4)", () => {
722 it("GET /settings/2fa redirects unauthenticated users to /login", async () => {
723 const res = await app.request("/settings/2fa");
724 expect([301, 302, 303, 307]).toContain(res.status);
725 const loc = res.headers.get("location") || "";
726 expect(loc.startsWith("/login")).toBe(true);
727 });
728
729 it("POST /settings/2fa/enroll redirects unauthenticated users to /login", async () => {
730 const res = await app.request("/settings/2fa/enroll", {
731 method: "POST",
732 headers: { "content-type": "application/x-www-form-urlencoded" },
733 body: "",
734 });
735 expect([301, 302, 303, 307]).toContain(res.status);
736 const loc = res.headers.get("location") || "";
737 expect(loc.startsWith("/login")).toBe(true);
738 });
739
740 it("GET /login/2fa redirects to /login when no session cookie", async () => {
741 const res = await app.request("/login/2fa");
742 expect([301, 302, 303, 307]).toContain(res.status);
743 const loc = res.headers.get("location") || "";
744 expect(loc.startsWith("/login")).toBe(true);
745 });
746});
747
748describe("passkeys routes (B5)", () => {
749 it("GET /settings/passkeys redirects unauthenticated users to /login", async () => {
750 const res = await app.request("/settings/passkeys");
751 expect([301, 302, 303, 307]).toContain(res.status);
752 const loc = res.headers.get("location") || "";
753 expect(loc.startsWith("/login")).toBe(true);
754 });
755
756 it("POST /api/passkeys/register/options redirects unauthenticated users to /login", async () => {
757 const res = await app.request("/api/passkeys/register/options", {
758 method: "POST",
759 headers: { "content-type": "application/json" },
760 body: "{}",
761 });
762 expect([301, 302, 303, 307]).toContain(res.status);
763 const loc = res.headers.get("location") || "";
764 expect(loc.startsWith("/login")).toBe(true);
765 });
766
767 it("POST /api/passkeys/auth/verify returns 400 when body is invalid", async () => {
768 const res = await app.request("/api/passkeys/auth/verify", {
769 method: "POST",
770 headers: { "content-type": "application/json" },
771 body: "not json",
772 });
773 // Either 400 (bad JSON) or 503 (DB down) — never a 500.
774 expect([400, 503]).toContain(res.status);
775 });
776
777 it("POST /api/passkeys/auth/verify rejects missing fields", async () => {
778 const res = await app.request("/api/passkeys/auth/verify", {
779 method: "POST",
780 headers: { "content-type": "application/json" },
781 body: "{}",
782 });
783 expect([400, 503]).toContain(res.status);
784 });
785
786 it("POST /settings/passkeys/:id/delete redirects unauthenticated users to /login", async () => {
787 const res = await app.request("/settings/passkeys/abc/delete", {
788 method: "POST",
789 headers: { "content-type": "application/x-www-form-urlencoded" },
790 body: "",
791 });
792 expect([301, 302, 303, 307]).toContain(res.status);
793 const loc = res.headers.get("location") || "";
794 expect(loc.startsWith("/login")).toBe(true);
795 });
796});
Addedsrc/__tests__/marketplace.test.ts+239−0View fileUnifiedSplit
1/**
2 * Block H — Marketplace + app identities tests.
3 *
4 * Pure helpers (slugify, botUsername, normalisePermissions, parsePermissions,
5 * hasPermission, generateBearerToken, hashBearer, permissionsSubset) + route
6 * auth smoke. DB-dependent helpers (createApp, installApp, verifyInstallToken)
7 * are exercised via type/shape checks only — real integration happens on the
8 * live server.
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13import {
14 KNOWN_PERMISSIONS,
15 KNOWN_EVENTS,
16 botUsername,
17 generateBearerToken,
18 hasPermission,
19 hashBearer,
20 normalisePermissions,
21 parsePermissions,
22 permissionsSubset,
23 slugify,
24} from "../lib/marketplace";
25
26describe("marketplace — slugify", () => {
27 it("lowercases + replaces spaces with dashes", () => {
28 expect(slugify("My Cool App")).toBe("my-cool-app");
29 });
30
31 it("strips non-alphanumeric", () => {
32 expect(slugify("Hello, World!")).toBe("hello-world");
33 });
34
35 it("trims leading/trailing dashes", () => {
36 expect(slugify("---foo---")).toBe("foo");
37 });
38
39 it("caps at 40 characters", () => {
40 const s = slugify("a".repeat(100));
41 expect(s.length).toBeLessThanOrEqual(40);
42 });
43
44 it("collapses consecutive separators", () => {
45 expect(slugify("foo bar")).toBe("foo-bar");
46 });
47});
48
49describe("marketplace — botUsername", () => {
50 it("appends [bot] suffix", () => {
51 expect(botUsername("my-app")).toBe("my-app[bot]");
52 });
53});
54
55describe("marketplace — permissions", () => {
56 it("KNOWN_PERMISSIONS includes contents + issues + pulls + checks", () => {
57 expect(KNOWN_PERMISSIONS).toContain("contents:read");
58 expect(KNOWN_PERMISSIONS).toContain("contents:write");
59 expect(KNOWN_PERMISSIONS).toContain("issues:write");
60 expect(KNOWN_PERMISSIONS).toContain("pulls:write");
61 expect(KNOWN_PERMISSIONS).toContain("checks:write");
62 });
63
64 it("KNOWN_EVENTS includes push + pull_request + issues", () => {
65 expect(KNOWN_EVENTS).toContain("push");
66 expect(KNOWN_EVENTS).toContain("pull_request");
67 expect(KNOWN_EVENTS).toContain("issues");
68 });
69
70 it("normalisePermissions drops unknown values", () => {
71 const perms = normalisePermissions([
72 "contents:read",
73 "bogus:thing",
74 "issues:write",
75 ]);
76 expect(perms).toEqual(["contents:read", "issues:write"]);
77 });
78
79 it("normalisePermissions de-duplicates", () => {
80 const perms = normalisePermissions([
81 "contents:read",
82 "contents:read",
83 "contents:read",
84 ]);
85 expect(perms.length).toBe(1);
86 });
87
88 it("parsePermissions reads JSON array out of DB column", () => {
89 const raw = JSON.stringify(["contents:read", "issues:write"]);
90 expect(parsePermissions(raw)).toEqual(["contents:read", "issues:write"]);
91 });
92
93 it("parsePermissions handles null/empty/invalid JSON", () => {
94 expect(parsePermissions(null)).toEqual([]);
95 expect(parsePermissions(undefined)).toEqual([]);
96 expect(parsePermissions("")).toEqual([]);
97 expect(parsePermissions("not json")).toEqual([]);
98 expect(parsePermissions("{}")).toEqual([]);
99 });
100
101 it("hasPermission direct match", () => {
102 expect(hasPermission(["issues:read"], "issues:read")).toBe(true);
103 });
104
105 it("hasPermission: write implies read", () => {
106 expect(hasPermission(["issues:write"], "issues:read")).toBe(true);
107 expect(hasPermission(["contents:write"], "contents:read")).toBe(true);
108 });
109
110 it("hasPermission: read does NOT imply write", () => {
111 expect(hasPermission(["issues:read"], "issues:write")).toBe(false);
112 });
113
114 it("hasPermission: empty grant fails", () => {
115 expect(hasPermission([], "issues:read")).toBe(false);
116 });
117
118 it("permissionsSubset checks containment", () => {
119 expect(
120 permissionsSubset(["contents:read"], ["contents:read", "issues:read"])
121 ).toBe(true);
122 expect(
123 permissionsSubset(
124 ["contents:read", "admin:god"],
125 ["contents:read"]
126 )
127 ).toBe(false);
128 });
129});
130
131describe("marketplace — bearer tokens", () => {
132 it("generateBearerToken produces ghi_ prefix + hex body", () => {
133 const { token, hash } = generateBearerToken();
134 expect(token.startsWith("ghi_")).toBe(true);
135 expect(token.length).toBeGreaterThan(10);
136 expect(hash.length).toBe(64); // sha256 hex
137 });
138
139 it("generateBearerToken yields unique tokens", () => {
140 const a = generateBearerToken();
141 const b = generateBearerToken();
142 expect(a.token).not.toBe(b.token);
143 expect(a.hash).not.toBe(b.hash);
144 });
145
146 it("hashBearer is deterministic", () => {
147 const t = "ghi_deadbeef";
148 expect(hashBearer(t)).toBe(hashBearer(t));
149 });
150
151 it("hashBearer of generated token matches returned hash", () => {
152 const { token, hash } = generateBearerToken();
153 expect(hashBearer(token)).toBe(hash);
154 });
155});
156
157describe("marketplace — route smoke", () => {
158 it("GET /marketplace → 200 (public)", async () => {
159 const res = await app.request("/marketplace");
160 expect(res.status).toBe(200);
161 });
162
163 it("GET /marketplace?q=foo → 200", async () => {
164 const res = await app.request("/marketplace?q=foo");
165 expect(res.status).toBe(200);
166 });
167
168 it("GET /marketplace/unknown-slug → 404", async () => {
169 const res = await app.request(
170 "/marketplace/this-app-does-not-exist-abcdef"
171 );
172 expect(res.status).toBe(404);
173 });
174
175 it("POST /marketplace/:slug/install without auth → 302 /login", async () => {
176 const res = await app.request("/marketplace/foo/install", {
177 method: "POST",
178 body: new URLSearchParams({}),
179 headers: { "content-type": "application/x-www-form-urlencoded" },
180 });
181 expect(res.status).toBe(302);
182 expect(res.headers.get("location") || "").toContain("/login");
183 });
184
185 it("GET /settings/apps without auth → 302 /login", async () => {
186 const res = await app.request("/settings/apps");
187 expect(res.status).toBe(302);
188 expect(res.headers.get("location") || "").toContain("/login");
189 });
190
191 it("GET /developer/apps-new without auth → 302 /login", async () => {
192 const res = await app.request("/developer/apps-new");
193 expect(res.status).toBe(302);
194 expect(res.headers.get("location") || "").toContain("/login");
195 });
196
197 it("POST /developer/apps-new without auth → 302 /login", async () => {
198 const res = await app.request("/developer/apps-new", {
199 method: "POST",
200 body: new URLSearchParams({ name: "My App" }),
201 headers: { "content-type": "application/x-www-form-urlencoded" },
202 });
203 expect(res.status).toBe(302);
204 expect(res.headers.get("location") || "").toContain("/login");
205 });
206
207 it("GET /developer/apps/:slug/manage without auth → 302 /login", async () => {
208 const res = await app.request("/developer/apps/foo/manage");
209 expect(res.status).toBe(302);
210 expect(res.headers.get("location") || "").toContain("/login");
211 });
212});
213
214describe("marketplace — lib exports", () => {
215 it("exports the full surface", async () => {
216 const mod = await import("../lib/marketplace");
217 expect(typeof mod.slugify).toBe("function");
218 expect(typeof mod.botUsername).toBe("function");
219 expect(typeof mod.normalisePermissions).toBe("function");
220 expect(typeof mod.parsePermissions).toBe("function");
221 expect(typeof mod.hasPermission).toBe("function");
222 expect(typeof mod.permissionsSubset).toBe("function");
223 expect(typeof mod.generateBearerToken).toBe("function");
224 expect(typeof mod.hashBearer).toBe("function");
225 expect(typeof mod.listPublicApps).toBe("function");
226 expect(typeof mod.getAppBySlug).toBe("function");
227 expect(typeof mod.createApp).toBe("function");
228 expect(typeof mod.installApp).toBe("function");
229 expect(typeof mod.uninstallApp).toBe("function");
230 expect(typeof mod.issueInstallToken).toBe("function");
231 expect(typeof mod.verifyInstallToken).toBe("function");
232 expect(typeof mod.listInstallationsForApp).toBe("function");
233 expect(typeof mod.listInstallationsForTarget).toBe("function");
234 expect(typeof mod.listEventsForApp).toBe("function");
235 expect(typeof mod.countInstalls).toBe("function");
236 expect(Array.isArray(mod.KNOWN_PERMISSIONS)).toBe(true);
237 expect(Array.isArray(mod.KNOWN_EVENTS)).toBe(true);
238 });
239});
Addedsrc/__tests__/merge-queue.test.ts+63−0View fileUnifiedSplit
1/**
2 * Block E5 — Merge queue smoke tests.
3 *
4 * Integration paths (enqueue → process-next → merge) need a seeded test DB
5 * + a real bare repo on disk. Here we cover:
6 * - helper shape (types + default values)
7 * - route-level auth guards (302 redirects to /login for write actions)
8 * - 404 for missing repo on read path
9 */
10
11import { describe, it, expect } from "bun:test";
12import app from "../app";
13
14describe("merge-queue — route smoke", () => {
15 it("GET /:owner/:repo/queue on missing repo → 404 HTML", async () => {
16 const res = await app.request("/nobody/missing/queue");
17 expect(res.status).toBe(404);
18 const body = await res.text();
19 expect(body.toLowerCase()).toContain("not found");
20 });
21
22 it("POST /:owner/:repo/pulls/1/enqueue without auth → 302 /login", async () => {
23 const res = await app.request("/any/repo/pulls/1/enqueue", {
24 method: "POST",
25 });
26 expect(res.status).toBe(302);
27 const loc = res.headers.get("location") || "";
28 expect(loc.startsWith("/login")).toBe(true);
29 });
30
31 it("POST /:owner/:repo/queue/abc/dequeue without auth → 302 /login", async () => {
32 const res = await app.request("/any/repo/queue/abc/dequeue", {
33 method: "POST",
34 });
35 expect(res.status).toBe(302);
36 const loc = res.headers.get("location") || "";
37 expect(loc.startsWith("/login")).toBe(true);
38 });
39
40 it("POST /:owner/:repo/queue/process-next without auth → 302 /login", async () => {
41 const res = await app.request("/any/repo/queue/process-next", {
42 method: "POST",
43 });
44 expect(res.status).toBe(302);
45 const loc = res.headers.get("location") || "";
46 expect(loc.startsWith("/login")).toBe(true);
47 });
48});
49
50describe("merge-queue — helper exports", () => {
51 it("exports enqueuePr, dequeueEntry, listQueue, peekHead, completeEntry", async () => {
52 const mod = await import("../lib/merge-queue");
53 expect(typeof mod.enqueuePr).toBe("function");
54 expect(typeof mod.dequeueEntry).toBe("function");
55 expect(typeof mod.listQueue).toBe("function");
56 expect(typeof mod.peekHead).toBe("function");
57 expect(typeof mod.completeEntry).toBe("function");
58 expect(typeof mod.markHeadRunning).toBe("function");
59 expect(typeof mod.isQueued).toBe("function");
60 expect(typeof mod.queueDepth).toBe("function");
61 expect(typeof mod.listQueueWithPrs).toBe("function");
62 });
63});
Addedsrc/__tests__/mirrors.test.ts+119−0View fileUnifiedSplit
1/**
2 * Block I9 — Repository mirroring tests.
3 *
4 * Pure validation tests for URL safety + auth smoke on mirror routes.
5 * Actual git fetch is exercised by the live server — `runMirrorSync` is
6 * not tested here because it needs a real upstream.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { validateUpstreamUrl, safeUrlForLog } from "../lib/mirrors";
12
13describe("mirrors — validateUpstreamUrl", () => {
14 it("accepts https URLs", () => {
15 expect(validateUpstreamUrl("https://github.com/foo/bar.git").ok).toBe(true);
16 });
17
18 it("accepts http URLs", () => {
19 expect(validateUpstreamUrl("http://git.example.com/x.git").ok).toBe(true);
20 });
21
22 it("accepts git:// URLs", () => {
23 expect(validateUpstreamUrl("git://kernel.org/linux.git").ok).toBe(true);
24 });
25
26 it("rejects ssh URLs", () => {
27 const r = validateUpstreamUrl("ssh://git@github.com/foo/bar.git");
28 expect(r.ok).toBe(false);
29 expect(r.error).toMatch(/https|http|git/);
30 });
31
32 it("rejects file:// URLs", () => {
33 const r = validateUpstreamUrl("file:///tmp/evil.git");
34 expect(r.ok).toBe(false);
35 });
36
37 it("rejects local paths", () => {
38 expect(validateUpstreamUrl("/etc/passwd").ok).toBe(false);
39 expect(validateUpstreamUrl("./foo.git").ok).toBe(false);
40 });
41
42 it("rejects URLs with shell metacharacters", () => {
43 expect(validateUpstreamUrl("https://evil;rm -rf /").ok).toBe(false);
44 expect(validateUpstreamUrl("https://evil`id`").ok).toBe(false);
45 expect(validateUpstreamUrl("https://evil$(whoami)").ok).toBe(false);
46 expect(validateUpstreamUrl("https://evil|nc 1.2.3.4 9").ok).toBe(false);
47 expect(validateUpstreamUrl("https://evil<payload").ok).toBe(false);
48 });
49
50 it("rejects empty or whitespace-only URLs", () => {
51 expect(validateUpstreamUrl("").ok).toBe(false);
52 expect(validateUpstreamUrl(" ").ok).toBe(false);
53 });
54
55 it("rejects URLs over 2048 chars", () => {
56 const long = "https://example.com/" + "a".repeat(2100);
57 expect(validateUpstreamUrl(long).ok).toBe(false);
58 });
59});
60
61describe("mirrors — safeUrlForLog", () => {
62 it("passes plain URLs through", () => {
63 expect(safeUrlForLog("https://github.com/foo/bar.git")).toBe(
64 "https://github.com/foo/bar.git"
65 );
66 });
67
68 it("redacts embedded credentials", () => {
69 const redacted = safeUrlForLog("https://user:pw@github.com/foo/bar.git");
70 expect(redacted).not.toContain("user:pw");
71 expect(redacted).not.toContain("pw");
72 expect(redacted).toContain("***");
73 expect(redacted).toContain("github.com");
74 });
75
76 it("returns original on unparseable input", () => {
77 expect(safeUrlForLog("not-a-url")).toBe("not-a-url");
78 });
79});
80
81describe("mirrors — route auth", () => {
82 it("GET /:owner/:repo/settings/mirror without auth → 302 /login", async () => {
83 const res = await app.request("/alice/repo/settings/mirror");
84 expect(res.status).toBe(302);
85 expect(res.headers.get("location") || "").toContain("/login");
86 });
87
88 it("POST /:owner/:repo/settings/mirror without auth → 302 /login", async () => {
89 const res = await app.request("/alice/repo/settings/mirror", {
90 method: "POST",
91 body: new URLSearchParams({ upstream_url: "https://example.com/x.git" }),
92 headers: { "content-type": "application/x-www-form-urlencoded" },
93 });
94 expect(res.status).toBe(302);
95 expect(res.headers.get("location") || "").toContain("/login");
96 });
97
98 it("POST /:owner/:repo/settings/mirror/sync without auth → 302 /login", async () => {
99 const res = await app.request("/alice/repo/settings/mirror/sync", {
100 method: "POST",
101 });
102 expect(res.status).toBe(302);
103 expect(res.headers.get("location") || "").toContain("/login");
104 });
105
106 it("POST /:owner/:repo/settings/mirror/delete without auth → 302 /login", async () => {
107 const res = await app.request("/alice/repo/settings/mirror/delete", {
108 method: "POST",
109 });
110 expect(res.status).toBe(302);
111 expect(res.headers.get("location") || "").toContain("/login");
112 });
113
114 it("POST /admin/mirrors/sync-all without auth → 302 /login", async () => {
115 const res = await app.request("/admin/mirrors/sync-all", { method: "POST" });
116 expect(res.status).toBe(302);
117 expect(res.headers.get("location") || "").toContain("/login");
118 });
119});
Addedsrc/__tests__/oauth.test.ts+335−0View fileUnifiedSplit
1/**
2 * Tests for the OAuth 2.0 provider (Block B6).
3 *
4 * Covers:
5 * - pure helper functions in src/lib/oauth.ts
6 * - unauthed redirect behaviour for authed OAuth + developer-apps routes
7 * - /oauth/token and /oauth/revoke endpoint surface behaviour
8 *
9 * These tests intentionally avoid the DB wherever possible so they run fast
10 * and don't depend on a live Postgres. Routes that touch the DB accept either
11 * the expected auth/validation error or 503 (DB unreachable) since the CI
12 * runner may not have a Neon connection.
13 */
14
15import { describe, it, expect } from "bun:test";
16import app from "../app";
17import {
18 generateClientId,
19 generateClientSecret,
20 generateAuthCode,
21 generateAccessToken,
22 generateRefreshToken,
23 sha256Hex,
24 b64urlFromBytes,
25 verifyPkce,
26 timingSafeEqual,
27 parseScopes,
28 serializeScopes,
29 parseRedirectUris,
30 isValidRedirectUri,
31 redirectUriAllowed,
32 SUPPORTED_SCOPES,
33 ACCESS_TOKEN_TTL_MS,
34 REFRESH_TOKEN_TTL_MS,
35 AUTH_CODE_TTL_MS,
36} from "../lib/oauth";
37
38describe("oauth helpers (B6)", () => {
39 it("generateClientId returns unique values prefixed with 'glc_app_'", () => {
40 const a = generateClientId();
41 const b = generateClientId();
42 expect(a.startsWith("glc_app_")).toBe(true);
43 expect(b.startsWith("glc_app_")).toBe(true);
44 expect(a).not.toBe(b);
45 // The suffix is randomHex(12) → 24 hex chars; prefix is 8 chars → total 32.
46 expect(a.length).toBe("glc_app_".length + 24);
47 });
48
49 it("generateClientSecret returns unique values prefixed with 'glcs_'", () => {
50 const a = generateClientSecret();
51 const b = generateClientSecret();
52 expect(a.startsWith("glcs_")).toBe(true);
53 expect(b.startsWith("glcs_")).toBe(true);
54 expect(a).not.toBe(b);
55 // The suffix is randomHex(32) → 64 hex chars; prefix is 5 chars → total 69.
56 expect(a.length).toBe("glcs_".length + 64);
57 });
58
59 it("generateAuthCode returns unique values prefixed with 'glca_'", () => {
60 const a = generateAuthCode();
61 const b = generateAuthCode();
62 expect(a.startsWith("glca_")).toBe(true);
63 expect(b.startsWith("glca_")).toBe(true);
64 expect(a).not.toBe(b);
65 });
66
67 it("generateAccessToken and generateRefreshToken produce distinct prefixes", () => {
68 const at = generateAccessToken();
69 const rt = generateRefreshToken();
70 expect(at.startsWith("glct_")).toBe(true);
71 expect(rt.startsWith("glcr_")).toBe(true);
72 expect(at).not.toBe(rt);
73 });
74
75 it("sha256Hex('hello') returns the known SHA-256 hex of 'hello'", async () => {
76 const h = await sha256Hex("hello");
77 expect(h).toBe(
78 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
79 );
80 });
81
82 it("timingSafeEqual handles equal, different, and mismatched lengths", () => {
83 expect(timingSafeEqual("abc", "abc")).toBe(true);
84 expect(timingSafeEqual("abc", "abd")).toBe(false);
85 expect(timingSafeEqual("abc", "abcd")).toBe(false);
86 expect(timingSafeEqual("", "")).toBe(true);
87 });
88
89 it("parseScopes strips unknown scopes, deduplicates, handles separators + empty", () => {
90 expect(parseScopes(null)).toEqual([]);
91 expect(parseScopes(undefined)).toEqual([]);
92 expect(parseScopes("")).toEqual([]);
93 expect(parseScopes("read:user read:user write:repo")).toEqual([
94 "read:user",
95 "write:repo",
96 ]);
97 expect(parseScopes("read:user,write:repo")).toEqual([
98 "read:user",
99 "write:repo",
100 ]);
101 expect(parseScopes("nonsense,read:user")).toEqual(["read:user"]);
102 expect(parseScopes(" ")).toEqual([]);
103 });
104
105 it("serializeScopes round-trips through parseScopes", () => {
106 const s = serializeScopes(["read:user", "write:repo"]);
107 expect(s).toBe("read:user write:repo");
108 expect(parseScopes(s)).toEqual(["read:user", "write:repo"]);
109 });
110
111 it("parseRedirectUris splits on newlines, trims, drops empty lines", () => {
112 const parsed = parseRedirectUris(
113 "https://a.com/cb\n https://b.com/cb \n\n\nhttps://c.com/cb\n"
114 );
115 expect(parsed).toEqual([
116 "https://a.com/cb",
117 "https://b.com/cb",
118 "https://c.com/cb",
119 ]);
120 expect(parseRedirectUris("")).toEqual([]);
121 expect(parseRedirectUris(" \n \n")).toEqual([]);
122 });
123
124 it("isValidRedirectUri accepts valid https and localhost-http URIs", () => {
125 expect(isValidRedirectUri("https://example.com/callback")).toBe(true);
126 expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
127 expect(isValidRedirectUri("http://127.0.0.1/cb")).toBe(true);
128 });
129
130 it("isValidRedirectUri rejects invalid URIs", () => {
131 expect(isValidRedirectUri("http://example.com/cb")).toBe(false);
132 expect(isValidRedirectUri("ftp://example.com/cb")).toBe(false);
133 expect(isValidRedirectUri("https://example.com/cb#frag")).toBe(false);
134 expect(isValidRedirectUri("https://*.example.com/cb")).toBe(false);
135 expect(isValidRedirectUri("not a url")).toBe(false);
136 });
137
138 it("redirectUriAllowed requires exact match against the registered list", () => {
139 expect(
140 redirectUriAllowed("https://a.com/cb", [
141 "https://a.com/cb",
142 "https://b.com/cb",
143 ])
144 ).toBe(true);
145 // Trailing slash matters — exact match only.
146 expect(redirectUriAllowed("https://a.com/cb/", ["https://a.com/cb"])).toBe(
147 false
148 );
149 expect(redirectUriAllowed("", ["https://a.com/cb"])).toBe(false);
150 expect(redirectUriAllowed("https://a.com/cb", [])).toBe(false);
151 });
152
153 it("verifyPkce S256 accepts the RFC 7636 §B example", async () => {
154 const ok = await verifyPkce({
155 method: "S256",
156 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
157 verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
158 });
159 expect(ok).toBe(true);
160 });
161
162 it("verifyPkce S256 rejects a mismatched verifier", async () => {
163 const ok = await verifyPkce({
164 method: "S256",
165 challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
166 verifier: "not-the-right-verifier",
167 });
168 expect(ok).toBe(false);
169 });
170
171 it("verifyPkce plain matches only when verifier === challenge", async () => {
172 expect(
173 await verifyPkce({ method: "plain", challenge: "abc", verifier: "abc" })
174 ).toBe(true);
175 expect(
176 await verifyPkce({ method: "plain", challenge: "abc", verifier: "abd" })
177 ).toBe(false);
178 });
179
180 it("verifyPkce returns false when challenge is missing", async () => {
181 expect(
182 await verifyPkce({ method: "S256", challenge: "", verifier: "x" })
183 ).toBe(false);
184 expect(
185 await verifyPkce({ method: "S256", challenge: null, verifier: "x" })
186 ).toBe(false);
187 expect(
188 await verifyPkce({ method: "plain", challenge: undefined, verifier: "x" })
189 ).toBe(false);
190 });
191
192 it("b64urlFromBytes produces URL-safe unpadded base64", () => {
193 // RFC 4648 test vector: "fooba" → "Zm9vYmE"
194 const bytes = new TextEncoder().encode("fooba");
195 const out = b64urlFromBytes(bytes);
196 expect(out).toBe("Zm9vYmE");
197 expect(out).not.toContain("=");
198 expect(out).not.toContain("+");
199 expect(out).not.toContain("/");
200 });
201
202 it("SUPPORTED_SCOPES includes at least the core three", () => {
203 expect(SUPPORTED_SCOPES).toContain("read:user");
204 expect(SUPPORTED_SCOPES).toContain("read:repo");
205 expect(SUPPORTED_SCOPES).toContain("write:repo");
206 });
207
208 it("TTL constants have sensible magnitudes", () => {
209 expect(ACCESS_TOKEN_TTL_MS).toBe(60 * 60 * 1000);
210 expect(REFRESH_TOKEN_TTL_MS).toBe(30 * 24 * 60 * 60 * 1000);
211 expect(AUTH_CODE_TTL_MS).toBe(10 * 60 * 1000);
212 expect(ACCESS_TOKEN_TTL_MS).toBeLessThan(REFRESH_TOKEN_TTL_MS);
213 expect(AUTH_CODE_TTL_MS).toBeLessThan(ACCESS_TOKEN_TTL_MS);
214 });
215});
216
217describe("oauth routes (B6) — unauthed redirects", () => {
218 it("GET /oauth/authorize without session redirects to /login", async () => {
219 const res = await app.request("/oauth/authorize");
220 expect([301, 302, 303, 307]).toContain(res.status);
221 const loc = res.headers.get("location") || "";
222 expect(loc.startsWith("/login")).toBe(true);
223 });
224
225 it("POST /oauth/authorize/decision without session redirects to /login", async () => {
226 const res = await app.request("/oauth/authorize/decision", {
227 method: "POST",
228 headers: { "content-type": "application/x-www-form-urlencoded" },
229 body: "decision=approve",
230 });
231 expect([301, 302, 303, 307]).toContain(res.status);
232 const loc = res.headers.get("location") || "";
233 expect(loc.startsWith("/login")).toBe(true);
234 });
235
236 it("GET /settings/authorizations without session redirects to /login", async () => {
237 const res = await app.request("/settings/authorizations");
238 expect([301, 302, 303, 307]).toContain(res.status);
239 const loc = res.headers.get("location") || "";
240 expect(loc.startsWith("/login")).toBe(true);
241 });
242
243 it("POST /settings/authorizations/:id/revoke without session redirects to /login", async () => {
244 const res = await app.request("/settings/authorizations/some-id/revoke", {
245 method: "POST",
246 headers: { "content-type": "application/x-www-form-urlencoded" },
247 body: "",
248 });
249 expect([301, 302, 303, 307]).toContain(res.status);
250 const loc = res.headers.get("location") || "";
251 expect(loc.startsWith("/login")).toBe(true);
252 });
253
254 it("GET /settings/applications without session redirects to /login", async () => {
255 const res = await app.request("/settings/applications");
256 expect([301, 302, 303, 307]).toContain(res.status);
257 const loc = res.headers.get("location") || "";
258 expect(loc.startsWith("/login")).toBe(true);
259 });
260
261 it("POST /settings/applications/new without session redirects to /login", async () => {
262 const res = await app.request("/settings/applications/new", {
263 method: "POST",
264 headers: { "content-type": "application/x-www-form-urlencoded" },
265 body: "name=Test",
266 });
267 expect([301, 302, 303, 307]).toContain(res.status);
268 const loc = res.headers.get("location") || "";
269 expect(loc.startsWith("/login")).toBe(true);
270 });
271});
272
273describe("oauth /token endpoint (B6)", () => {
274 it("returns 401 invalid_client when client_id is missing", async () => {
275 const res = await app.request("/oauth/token", {
276 method: "POST",
277 headers: { "content-type": "application/x-www-form-urlencoded" },
278 body: "grant_type=authorization_code",
279 });
280 expect(res.status).toBe(401);
281 const body = await res.json();
282 expect(body.error).toBe("invalid_client");
283 });
284
285 it("returns 401 or 503 when client_id is unknown", async () => {
286 const res = await app.request("/oauth/token", {
287 method: "POST",
288 headers: { "content-type": "application/x-www-form-urlencoded" },
289 body:
290 "grant_type=authorization_code&client_id=glc_app_nonexistent000000000000",
291 });
292 // 401 if DB confirms unknown client, 503 if DB unreachable.
293 expect([401, 503]).toContain(res.status);
294 if (res.status === 401) {
295 const body = await res.json();
296 expect(body.error).toBe("invalid_client");
297 }
298 });
299
300 it("returns 400 on malformed JSON body", async () => {
301 const res = await app.request("/oauth/token", {
302 method: "POST",
303 headers: { "content-type": "application/json" },
304 body: "{this is not json",
305 });
306 expect(res.status).toBe(400);
307 const body = await res.json();
308 expect(body.error).toBeTruthy();
309 });
310
311 it("returns 401 or 503 for a bogus client_id + unsupported grant_type", async () => {
312 const res = await app.request("/oauth/token", {
313 method: "POST",
314 headers: { "content-type": "application/x-www-form-urlencoded" },
315 body:
316 "grant_type=password&client_id=glc_app_000000000000000000000000",
317 });
318 // Client lookup runs before grant_type validation. Either the DB says
319 // unknown client (401) or the DB is unreachable (503).
320 expect([401, 503]).toContain(res.status);
321 });
322});
323
324describe("oauth /revoke endpoint (B6)", () => {
325 it("returns 401 when no client credentials are supplied", async () => {
326 const res = await app.request("/oauth/revoke", {
327 method: "POST",
328 headers: { "content-type": "application/x-www-form-urlencoded" },
329 body: "token=glct_something",
330 });
331 expect(res.status).toBe(401);
332 const body = await res.json();
333 expect(body.error).toBe("invalid_client");
334 });
335});
Addedsrc/__tests__/org-insights.test.ts+28−0View fileUnifiedSplit
1/**
2 * Block F2 — Org insights smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { computeOrgInsights } from "../routes/org-insights";
8
9describe("org-insights — route smoke", () => {
10 it("GET /orgs/:slug/insights without auth → 302 /login", async () => {
11 const res = await app.request("/orgs/nobody/insights");
12 expect(res.status).toBe(302);
13 const loc = res.headers.get("location") || "";
14 expect(loc.startsWith("/login")).toBe(true);
15 });
16});
17
18describe("org-insights — computeOrgInsights", () => {
19 it("returns empty summary for unknown org id", async () => {
20 const s = await computeOrgInsights(
21 "00000000-0000-0000-0000-000000000000"
22 );
23 expect(s.repoCount).toBe(0);
24 expect(s.gateRunsTotal).toBe(0);
25 expect(s.greenRate).toBe(0);
26 expect(s.perRepo).toEqual([]);
27 });
28});
Addedsrc/__tests__/packages.test.ts+297−0View fileUnifiedSplit
1/**
2 * Tests for Block C2 — npm-compatible package registry.
3 *
4 * Covers the pure helpers in `src/lib/packages.ts` and a handful of
5 * route-level behaviour guarantees (401 without auth, 404 for unknown
6 * packages). The integration paths — actual publish → install cycles —
7 * are exercised by higher-level tests once a real test DB is wired.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 parsePackageName,
14 parseRepoUrl,
15 computeShasum,
16 computeIntegrity,
17 buildPackument,
18 resolveRepoFromPackageJson,
19 tarballFilename,
20} from "../lib/packages";
21
22describe("parsePackageName", () => {
23 it("parses a plain name", () => {
24 const r = parsePackageName("left-pad");
25 expect(r).not.toBeNull();
26 expect(r!.scope).toBeNull();
27 expect(r!.name).toBe("left-pad");
28 expect(r!.full).toBe("left-pad");
29 });
30
31 it("parses a scoped name", () => {
32 const r = parsePackageName("@acme/widgets");
33 expect(r).not.toBeNull();
34 expect(r!.scope).toBe("@acme");
35 expect(r!.name).toBe("widgets");
36 expect(r!.full).toBe("@acme/widgets");
37 });
38
39 it("accepts a URL-encoded scoped name (%2F)", () => {
40 const r = parsePackageName("@acme%2Fwidgets");
41 expect(r).not.toBeNull();
42 expect(r!.scope).toBe("@acme");
43 expect(r!.name).toBe("widgets");
44 });
45
46 it("rejects empty strings", () => {
47 expect(parsePackageName("")).toBeNull();
48 expect(parsePackageName(" ")).toBeNull();
49 });
50
51 it("rejects malformed scope-only input", () => {
52 expect(parsePackageName("@")).toBeNull();
53 expect(parsePackageName("@acme")).toBeNull();
54 expect(parsePackageName("@/foo")).toBeNull();
55 });
56
57 it("rejects names with spaces or weird chars", () => {
58 expect(parsePackageName("foo bar")).toBeNull();
59 expect(parsePackageName("foo/bar")).toBeNull(); // would be scope-style without @
60 expect(parsePackageName("../etc")).toBeNull();
61 });
62
63 it("allows legal characters (dot, dash, underscore, digits)", () => {
64 expect(parsePackageName("a.b_c-1")).not.toBeNull();
65 expect(parsePackageName("@s_c.o-pe/n.a_m-e1")).not.toBeNull();
66 });
67});
68
69describe("computeShasum", () => {
70 it("returns a 40-char lowercase hex string", () => {
71 const bytes = new TextEncoder().encode("hello world");
72 const out = computeShasum(bytes);
73 expect(out).toMatch(/^[0-9a-f]{40}$/);
74 });
75
76 it("matches the known sha1 of 'hello world'", () => {
77 const bytes = new TextEncoder().encode("hello world");
78 expect(computeShasum(bytes)).toBe(
79 "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
80 );
81 });
82
83 it("is stable across calls", () => {
84 const bytes = new TextEncoder().encode("stable-input");
85 expect(computeShasum(bytes)).toBe(computeShasum(bytes));
86 });
87});
88
89describe("computeIntegrity", () => {
90 it("prefixes with sha512-", () => {
91 const bytes = new TextEncoder().encode("integrity-test");
92 const out = computeIntegrity(bytes);
93 expect(out.startsWith("sha512-")).toBe(true);
94 });
95
96 it("base64 body decodes to 64 bytes (sha512 digest length)", () => {
97 const bytes = new TextEncoder().encode("xyz");
98 const out = computeIntegrity(bytes);
99 const body = out.slice("sha512-".length);
100 const decoded = Buffer.from(body, "base64");
101 expect(decoded.length).toBe(64);
102 });
103});
104
105describe("resolveRepoFromPackageJson", () => {
106 it("accepts the object form with url", () => {
107 const r = resolveRepoFromPackageJson({
108 repository: { type: "git", url: "https://gluecron.com/alice/foo.git" },
109 });
110 expect(r).toEqual({ owner: "alice", repo: "foo" });
111 });
112
113 it("accepts the string shorthand", () => {
114 const r = resolveRepoFromPackageJson({
115 repository: "https://gluecron.com/bob/bar.git",
116 });
117 expect(r).toEqual({ owner: "bob", repo: "bar" });
118 });
119
120 it("accepts git+https URLs", () => {
121 const r = resolveRepoFromPackageJson({
122 repository: { url: "git+https://gluecron.com/alice/foo.git" },
123 });
124 expect(r).toEqual({ owner: "alice", repo: "foo" });
125 });
126
127 it("accepts SCP-style git@ URLs", () => {
128 const r = resolveRepoFromPackageJson({
129 repository: "git@gluecron.com:alice/foo.git",
130 });
131 expect(r).toEqual({ owner: "alice", repo: "foo" });
132 });
133
134 it("returns null when repository is missing", () => {
135 expect(resolveRepoFromPackageJson({})).toBeNull();
136 expect(resolveRepoFromPackageJson(null)).toBeNull();
137 expect(resolveRepoFromPackageJson("not-an-object")).toBeNull();
138 });
139
140 it("returns null for empty / malformed URLs", () => {
141 expect(resolveRepoFromPackageJson({ repository: "" })).toBeNull();
142 expect(resolveRepoFromPackageJson({ repository: "noslashes" })).toBeNull();
143 });
144});
145
146describe("parseRepoUrl (direct)", () => {
147 it("strips trailing .git", () => {
148 expect(parseRepoUrl("http://localhost:3000/a/b.git")).toEqual({
149 owner: "a",
150 repo: "b",
151 });
152 });
153 it("handles bare owner/repo path", () => {
154 expect(parseRepoUrl("alice/foo")).toEqual({ owner: "alice", repo: "foo" });
155 });
156});
157
158describe("buildPackument", () => {
159 const pkg = {
160 id: "pkg-1",
161 repositoryId: "repo-1",
162 ecosystem: "npm",
163 scope: null,
164 name: "widgets",
165 description: "A package of widgets",
166 readme: "# widgets",
167 homepage: "https://example.com",
168 license: "MIT",
169 visibility: "public",
170 createdAt: new Date("2024-01-01T00:00:00Z"),
171 updatedAt: new Date("2024-01-01T00:00:00Z"),
172 } as const;
173
174 const v1 = {
175 id: "v1",
176 packageId: "pkg-1",
177 version: "1.0.0",
178 shasum: "aaaa",
179 integrity: "sha512-deadbeef",
180 sizeBytes: 123,
181 metadata: JSON.stringify({ name: "widgets", version: "1.0.0" }),
182 tarball: null,
183 publishedBy: "user-1",
184 yanked: false,
185 yankedReason: null,
186 publishedAt: new Date("2024-01-02T00:00:00Z"),
187 } as const;
188
189 const v2 = {
190 ...v1,
191 id: "v2",
192 version: "1.1.0",
193 shasum: "bbbb",
194 publishedAt: new Date("2024-01-10T00:00:00Z"),
195 } as const;
196
197 it("returns name, dist-tags, and versions", () => {
198 const doc = buildPackument(
199 pkg as any,
200 [v1, v2] as any,
201 [
202 {
203 id: "t1",
204 packageId: "pkg-1",
205 tag: "latest",
206 versionId: "v2",
207 updatedAt: new Date(),
208 } as any,
209 ],
210 "http://host:3000"
211 );
212 expect(doc.name).toBe("widgets");
213 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
214 expect((doc.versions as Record<string, unknown>)["1.0.0"]).toBeDefined();
215 expect((doc.versions as Record<string, unknown>)["1.1.0"]).toBeDefined();
216 });
217
218 it("falls back to most-recent version for latest if no tag rows", () => {
219 const doc = buildPackument(
220 pkg as any,
221 [v1, v2] as any,
222 [],
223 "http://host:3000"
224 );
225 expect((doc["dist-tags"] as Record<string, string>).latest).toBe("1.1.0");
226 });
227
228 it("embeds tarball URLs under dist", () => {
229 const doc = buildPackument(
230 pkg as any,
231 [v1] as any,
232 [],
233 "http://host:3000"
234 );
235 const ver = (doc.versions as any)["1.0.0"];
236 expect(ver.dist.tarball).toContain("http://host:3000/npm/widgets/-/widgets-1.0.0.tgz");
237 expect(ver.dist.shasum).toBe("aaaa");
238 expect(ver.dist.integrity).toBe("sha512-deadbeef");
239 });
240
241 it("uses full scoped name in url for scoped packages", () => {
242 const scoped = { ...pkg, scope: "@acme", name: "widgets" } as any;
243 const doc = buildPackument(scoped, [v1] as any, [], "http://h");
244 expect(doc.name).toBe("@acme/widgets");
245 const ver = (doc.versions as any)["1.0.0"];
246 // Tarball path encodes @acme/widgets but filename uses just "widgets".
247 expect(ver.dist.tarball).toContain("/npm/@acme/widgets/-/widgets-1.0.0.tgz");
248 });
249});
250
251describe("tarballFilename", () => {
252 it("builds <name>-<version>.tgz for plain packages", () => {
253 const parsed = parsePackageName("widgets")!;
254 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
255 });
256 it("omits the scope from the filename for scoped packages", () => {
257 const parsed = parsePackageName("@acme/widgets")!;
258 expect(tarballFilename(parsed, "1.2.3")).toBe("widgets-1.2.3.tgz");
259 });
260});
261
262// ---------------------------------------------------------------------------
263// Route-level guard tests
264// ---------------------------------------------------------------------------
265
266describe("packages routes — unauthed behaviour", () => {
267 it("PUT /npm/:name without auth returns 401 (JSON)", async () => {
268 const res = await app.request("/npm/some-package", {
269 method: "PUT",
270 headers: {
271 "content-type": "application/json",
272 authorization: "Bearer glct_does-not-exist",
273 },
274 body: JSON.stringify({
275 name: "some-package",
276 versions: { "1.0.0": { name: "some-package", version: "1.0.0" } },
277 _attachments: {
278 "some-package-1.0.0.tgz": {
279 content_type: "application/octet-stream",
280 data: "aGVsbG8=",
281 length: 5,
282 },
283 },
284 }),
285 });
286 // Either 401 (bad bearer) or 404 (route may not be mounted in main yet;
287 // we don't edit app.tsx). Assert tolerant but meaningful.
288 expect([401, 404]).toContain(res.status);
289 });
290
291 it("GET /npm/does-not-exist returns 404", async () => {
292 const res = await app.request("/npm/does-not-exist-package-xyz");
293 // 404 from our handler, 503 if DB down, or 404 from app-level notFound if
294 // the route isn't yet mounted.
295 expect([404, 503]).toContain(res.status);
296 });
297});
Addedsrc/__tests__/pages.test.ts+231−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages unit + route tests.
3 *
4 * These tests run without a live database. Route tests therefore accept the
5 * whole class of graceful-degradation responses (404 / 302 / 303 / 503):
6 * 503 is emitted when the DB proxy throws, 404 when the repo row isn't found,
7 * and 302/303 when auth middleware redirects to /login.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 contentTypeFor,
14 resolvePagesPath,
15 onPagesPush,
16} from "../lib/pages";
17
18describe("lib/pages — contentTypeFor", () => {
19 it("returns text/html for .html", () => {
20 expect(contentTypeFor("index.html")).toContain("text/html");
21 });
22
23 it("returns text/css for .css", () => {
24 expect(contentTypeFor("site.css")).toContain("text/css");
25 });
26
27 it("returns application/javascript for .js", () => {
28 expect(contentTypeFor("app.js")).toContain("javascript");
29 });
30
31 it("returns image/svg+xml for .svg", () => {
32 expect(contentTypeFor("logo.svg")).toBe("image/svg+xml");
33 });
34
35 it("returns image/png for .png", () => {
36 expect(contentTypeFor("pic.PNG")).toBe("image/png");
37 });
38
39 it("returns image/jpeg for .jpg and .jpeg", () => {
40 expect(contentTypeFor("a.jpg")).toBe("image/jpeg");
41 expect(contentTypeFor("b.jpeg")).toBe("image/jpeg");
42 });
43
44 it("returns application/json for .json", () => {
45 expect(contentTypeFor("data.json")).toContain("application/json");
46 });
47
48 it("returns application/octet-stream for unknown extensions", () => {
49 expect(contentTypeFor("mystery.xyz")).toBe("application/octet-stream");
50 });
51
52 it("returns application/octet-stream for files with no extension", () => {
53 expect(contentTypeFor("Makefile")).toBe("application/octet-stream");
54 });
55});
56
57describe("lib/pages — resolvePagesPath", () => {
58 it("returns index.html for empty url rest at root", () => {
59 expect(resolvePagesPath("", "/", "index.html")).toEqual(["index.html"]);
60 });
61
62 it("returns index.html for a trailing slash", () => {
63 expect(resolvePagesPath("/", "/", "index.html")).toEqual(["index.html"]);
64 });
65
66 it("probes both foo.html and foo/index.html for extensionless urls", () => {
67 const paths = resolvePagesPath("about", "/", "index.html");
68 expect(paths).toContain("about.html");
69 expect(paths).toContain("about/index.html");
70 expect(paths[0]).toBe("about.html");
71 });
72
73 it("serves a file directly when it has an extension", () => {
74 expect(resolvePagesPath("assets/app.css", "/", "index.html")).toEqual([
75 "assets/app.css",
76 ]);
77 });
78
79 it("applies sourceDir as a prefix", () => {
80 const paths = resolvePagesPath("about", "/docs", "index.html");
81 expect(paths[0]).toBe("docs/about.html");
82 expect(paths[1]).toBe("docs/about/index.html");
83 });
84
85 it("serves the index inside a nested directory when url ends with slash", () => {
86 expect(resolvePagesPath("blog/", "/", "index.html")).toEqual([
87 "blog/index.html",
88 ]);
89 });
90
91 it("strips path-traversal segments", () => {
92 const paths = resolvePagesPath("../../etc/passwd", "/", "index.html");
93 // .. entries are dropped; the remaining "etc/passwd" is treated as a
94 // pretty URL because it has no file extension.
95 expect(paths).not.toContain("../etc/passwd");
96 expect(paths).not.toContain("../../etc/passwd");
97 for (const p of paths) {
98 expect(p.startsWith("..")).toBe(false);
99 expect(p).not.toContain("../");
100 }
101 expect(paths[0]).toBe("etc/passwd.html");
102 });
103
104 it("strips leading slashes on the url rest", () => {
105 expect(resolvePagesPath("/about.html", "/", "index.html")).toEqual([
106 "about.html",
107 ]);
108 });
109
110 it("normalises source dir with or without leading / trailing slash", () => {
111 expect(resolvePagesPath("", "docs/", "index.html")).toEqual([
112 "docs/index.html",
113 ]);
114 expect(resolvePagesPath("", "/docs/", "index.html")).toEqual([
115 "docs/index.html",
116 ]);
117 });
118});
119
120describe("lib/pages — onPagesPush", () => {
121 it("never throws, even with a bogus repositoryId and no DB", async () => {
122 // No DATABASE_URL in the test env => db proxy throws. The helper must
123 // swallow that and return normally.
124 await expect(
125 onPagesPush({
126 ownerLogin: "alice",
127 repoName: "project",
128 repositoryId: "00000000-0000-0000-0000-000000000000",
129 ref: "refs/heads/gh-pages",
130 newSha: "0".repeat(40),
131 triggeredByUserId: null,
132 })
133 ).resolves.toBeUndefined();
134 });
135});
136
137describe("routes/pages — guards", () => {
138 it("GET /:owner/:repo/pages/ 404s when repo does not exist", async () => {
139 const res = await app.request("/alice/project/pages/");
140 // 404 when repo row not found, 503 when DB is unreachable.
141 expect([404, 503]).toContain(res.status);
142 });
143
144 it("GET /:owner/:repo/pages/foo 404s when repo does not exist", async () => {
145 const res = await app.request("/nobody/nothing/pages/foo.html");
146 expect([404, 503]).toContain(res.status);
147 });
148
149 it("GET /:owner/:repo/settings/pages requires auth (or is not yet mounted)", async () => {
150 const res = await app.request(
151 "/alice/project/settings/pages",
152 { redirect: "manual" }
153 );
154 // When mounted: anonymous -> redirected to /login.
155 // When not yet wired into app.tsx (integration step handled by owner):
156 // the global 404 handler answers instead.
157 expect([302, 303, 404, 503]).toContain(res.status);
158 if (res.status === 302 || res.status === 303) {
159 const loc = res.headers.get("location") || "";
160 expect(loc).toContain("/login");
161 }
162 });
163
164 it("POST /:owner/:repo/settings/pages without auth redirects to /login or 404s", async () => {
165 const res = await app.request("/alice/project/settings/pages", {
166 method: "POST",
167 headers: { "Content-Type": "application/x-www-form-urlencoded" },
168 body: "enabled=1&source_branch=gh-pages&source_dir=/",
169 redirect: "manual",
170 });
171 expect([302, 303, 404, 503]).toContain(res.status);
172 if (res.status === 302 || res.status === 303) {
173 const loc = res.headers.get("location") || "";
174 expect(loc).toContain("/login");
175 }
176 });
177
178 it("POST /:owner/:repo/settings/pages/redeploy without auth redirects to /login or 404s", async () => {
179 const res = await app.request(
180 "/alice/project/settings/pages/redeploy",
181 {
182 method: "POST",
183 redirect: "manual",
184 }
185 );
186 expect([302, 303, 404, 503]).toContain(res.status);
187 if (res.status === 302 || res.status === 303) {
188 const loc = res.headers.get("location") || "";
189 expect(loc).toContain("/login");
190 }
191 });
192});
193
194describe("routes/pages — direct route tests (no app mount)", () => {
195 // These test the exported Hono app directly so we don't depend on the
196 // owner wiring up app.tsx. This mirrors what the integration step will
197 // yield once the parent agent mounts pagesRoute.
198 it("direct GET /:owner/:repo/settings/pages without auth redirects to /login", async () => {
199 const { default: pagesRoute } = await import("../routes/pages");
200 const res = await pagesRoute.request(
201 "/alice/project/settings/pages",
202 { redirect: "manual" }
203 );
204 expect([302, 303, 503]).toContain(res.status);
205 if (res.status === 302 || res.status === 303) {
206 const loc = res.headers.get("location") || "";
207 expect(loc).toContain("/login");
208 }
209 });
210
211 it("direct POST /:owner/:repo/settings/pages without auth redirects to /login", async () => {
212 const { default: pagesRoute } = await import("../routes/pages");
213 const res = await pagesRoute.request("/alice/project/settings/pages", {
214 method: "POST",
215 headers: { "Content-Type": "application/x-www-form-urlencoded" },
216 body: "enabled=1&source_branch=gh-pages&source_dir=/",
217 redirect: "manual",
218 });
219 expect([302, 303, 503]).toContain(res.status);
220 if (res.status === 302 || res.status === 303) {
221 const loc = res.headers.get("location") || "";
222 expect(loc).toContain("/login");
223 }
224 });
225
226 it("direct GET /:owner/:repo/pages/ 404s when repo does not exist", async () => {
227 const { default: pagesRoute } = await import("../routes/pages");
228 const res = await pagesRoute.request("/alice/project/pages/");
229 expect([404, 503]).toContain(res.status);
230 });
231});
Addedsrc/__tests__/profile-readme.test.ts+25−0View fileUnifiedSplit
1/**
2 * Block J5 — Profile README smoke.
3 *
4 * We can't test the full "user has a user/user repo with a README" path
5 * without a real git checkout, but we can verify the profile route still
6 * responds on the happy + missing paths.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11
12describe("profile README — route smoke", () => {
13 it("GET /<unknown-user> renders without blowing up on missing profile repo", async () => {
14 // Non-existent user → 200 page (renders with empty ownerUser) or 500 when
15 // DB is unreachable. Either way, the profile-readme block must not crash.
16 const res = await app.request("/does-not-exist-xyz");
17 expect([200, 404, 500]).toContain(res.status);
18 });
19
20 it("GET /login stays a fixed route, not captured by /:owner", async () => {
21 const res = await app.request("/login");
22 // login page renders 200
23 expect([200, 302]).toContain(res.status);
24 });
25});
Addedsrc/__tests__/projects.test.ts+57−0View fileUnifiedSplit
1/**
2 * Block E1 — Projects / kanban smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7
8describe("projects — route smoke", () => {
9 it("GET /:owner/:repo/projects on missing repo → 404", async () => {
10 const res = await app.request("/nobody/missing/projects");
11 expect(res.status).toBe(404);
12 });
13
14 it("GET /:owner/:repo/projects/new without auth → 302 /login", async () => {
15 const res = await app.request("/any/repo/projects/new");
16 expect(res.status).toBe(302);
17 expect(res.headers.get("location") || "").toMatch(/^\/login/);
18 });
19
20 it("POST /:owner/:repo/projects without auth → 302 /login", async () => {
21 const res = await app.request("/any/repo/projects", {
22 method: "POST",
23 body: new URLSearchParams({ title: "x" }),
24 headers: { "content-type": "application/x-www-form-urlencoded" },
25 });
26 expect(res.status).toBe(302);
27 expect(res.headers.get("location") || "").toMatch(/^\/login/);
28 });
29
30 it("POST /:owner/:repo/projects/1/columns without auth → 302 /login", async () => {
31 const res = await app.request("/any/repo/projects/1/columns", {
32 method: "POST",
33 body: new URLSearchParams({ name: "Later" }),
34 headers: { "content-type": "application/x-www-form-urlencoded" },
35 });
36 expect(res.status).toBe(302);
37 expect(res.headers.get("location") || "").toMatch(/^\/login/);
38 });
39
40 it("POST /:owner/:repo/projects/1/items without auth → 302 /login", async () => {
41 const res = await app.request("/any/repo/projects/1/items", {
42 method: "POST",
43 body: new URLSearchParams({ column_id: "x", title: "card" }),
44 headers: { "content-type": "application/x-www-form-urlencoded" },
45 });
46 expect(res.status).toBe(302);
47 expect(res.headers.get("location") || "").toMatch(/^\/login/);
48 });
49
50 it("POST close without auth → 302 /login", async () => {
51 const res = await app.request("/any/repo/projects/1/close", {
52 method: "POST",
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toMatch(/^\/login/);
56 });
57});
Addedsrc/__tests__/protected-tags.test.ts+106−0View fileUnifiedSplit
1/**
2 * Block E7 — Protected tags tests.
3 *
4 * Covers pure `matchGlob`-based matching behaviour on a fake rule set + route
5 * auth smoke. We don't hit the DB — instead, a small wrapper reimplements the
6 * matching logic the lib uses (identical semantics).
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { matchGlob } from "../lib/environments";
12
13/**
14 * Mirrors the matching logic in `matchProtectedTag` so we can exercise it
15 * without a DB. Keep in sync with src/lib/protected-tags.ts.
16 */
17function matchPatternLocal(
18 patterns: string[],
19 tagName: string
20): string | null {
21 const name = tagName.startsWith("refs/tags/")
22 ? tagName.slice("refs/tags/".length)
23 : tagName;
24 const exact = patterns.find(
25 (p) => (p.startsWith("refs/tags/") ? p.slice(10) : p) === name
26 );
27 if (exact) return exact;
28 const globs = patterns
29 .filter((p) => p.includes("*"))
30 .sort((a, b) => a.localeCompare(b));
31 for (const p of globs) {
32 if (matchGlob(name, p)) return p;
33 }
34 return null;
35}
36
37describe("protected-tags — pattern matching", () => {
38 it("matches exact tag names", () => {
39 expect(matchPatternLocal(["v1.0.0"], "v1.0.0")).toBe("v1.0.0");
40 expect(matchPatternLocal(["v1.0.0"], "v1.0.1")).toBe(null);
41 });
42
43 it("matches glob prefixes", () => {
44 expect(matchPatternLocal(["v*"], "v1.2.3")).toBe("v*");
45 expect(matchPatternLocal(["release-*"], "release-2024")).toBe("release-*");
46 expect(matchPatternLocal(["release-*"], "feature-x")).toBe(null);
47 });
48
49 it("strips refs/tags/ prefix before matching", () => {
50 expect(matchPatternLocal(["v*"], "refs/tags/v2.0.0")).toBe("v*");
51 });
52
53 it("returns null when no pattern matches", () => {
54 expect(matchPatternLocal(["v*", "release-*"], "main")).toBe(null);
55 expect(matchPatternLocal([], "v1.0.0")).toBe(null);
56 });
57
58 it("exact match wins over glob", () => {
59 // Either order — exact match should be preferred.
60 expect(matchPatternLocal(["v*", "v1.0.0"], "v1.0.0")).toBe("v1.0.0");
61 expect(matchPatternLocal(["v1.0.0", "v*"], "v1.0.0")).toBe("v1.0.0");
62 });
63});
64
65describe("protected-tags — route smoke", () => {
66 it("GET /settings/protected-tags without auth → 302 /login", async () => {
67 const res = await app.request("/any/repo/settings/protected-tags");
68 expect(res.status).toBe(302);
69 const loc = res.headers.get("location") || "";
70 expect(loc.startsWith("/login")).toBe(true);
71 });
72
73 it("POST /settings/protected-tags without auth → 302 /login", async () => {
74 const res = await app.request("/any/repo/settings/protected-tags", {
75 method: "POST",
76 body: new URLSearchParams({ pattern: "v*" }),
77 headers: { "content-type": "application/x-www-form-urlencoded" },
78 });
79 expect(res.status).toBe(302);
80 const loc = res.headers.get("location") || "";
81 expect(loc.startsWith("/login")).toBe(true);
82 });
83
84 it("POST /settings/protected-tags/:id/delete without auth → 302 /login", async () => {
85 const res = await app.request(
86 "/any/repo/settings/protected-tags/abc/delete",
87 { method: "POST" }
88 );
89 expect(res.status).toBe(302);
90 const loc = res.headers.get("location") || "";
91 expect(loc.startsWith("/login")).toBe(true);
92 });
93});
94
95describe("protected-tags — lib exports", () => {
96 it("exports matchProtectedTag, isProtectedTag, list/add/remove + canBypass", async () => {
97 const mod = await import("../lib/protected-tags");
98 expect(typeof mod.matchProtectedTag).toBe("function");
99 expect(typeof mod.isProtectedTag).toBe("function");
100 expect(typeof mod.canBypassProtectedTag).toBe("function");
101 expect(typeof mod.listProtectedTags).toBe("function");
102 expect(typeof mod.addProtectedTag).toBe("function");
103 expect(typeof mod.removeProtectedTag).toBe("function");
104 expect(typeof mod.userIdFromUsername).toBe("function");
105 });
106});
Addedsrc/__tests__/pwa.test.ts+95−0View fileUnifiedSplit
1/**
2 * Block G1 — PWA route smoke tests.
3 *
4 * Verifies manifest/icon/service-worker endpoints serve the right content
5 * types + the manifest parses as JSON with the required install-prompt fields.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import { MANIFEST, SERVICE_WORKER_SRC, PWA_REGISTER_SNIPPET } from "../routes/pwa";
11
12describe("pwa — manifest", () => {
13 it("GET /manifest.webmanifest → 200 JSON", async () => {
14 const res = await app.request("/manifest.webmanifest");
15 expect(res.status).toBe(200);
16 const ct = res.headers.get("content-type") || "";
17 expect(ct).toContain("application/manifest+json");
18 const body = await res.json();
19 expect(body.name).toBe("Gluecron");
20 expect(body.start_url).toBe("/");
21 expect(body.display).toBe("standalone");
22 expect(Array.isArray(body.icons)).toBe(true);
23 expect(body.icons.length).toBeGreaterThan(0);
24 });
25
26 it("MANIFEST constant has required install-prompt fields", () => {
27 expect(MANIFEST.name).toBeDefined();
28 expect(MANIFEST.short_name).toBeDefined();
29 expect(MANIFEST.start_url).toBeDefined();
30 expect(MANIFEST.icons.length).toBeGreaterThan(0);
31 expect(MANIFEST.display).toBe("standalone");
32 });
33});
34
35describe("pwa — service worker", () => {
36 it("GET /sw.js → 200 JavaScript", async () => {
37 const res = await app.request("/sw.js");
38 expect(res.status).toBe(200);
39 expect(res.headers.get("content-type") || "").toContain(
40 "application/javascript"
41 );
42 expect(res.headers.get("service-worker-allowed")).toBe("/");
43 });
44
45 it("service worker source contains install + fetch handlers", () => {
46 expect(SERVICE_WORKER_SRC).toContain("addEventListener('install'");
47 expect(SERVICE_WORKER_SRC).toContain("addEventListener('fetch'");
48 expect(SERVICE_WORKER_SRC).toContain("addEventListener('activate'");
49 });
50
51 it("service worker skips git + api + auth paths", () => {
52 expect(SERVICE_WORKER_SRC).toContain(".git/");
53 expect(SERVICE_WORKER_SRC).toContain("/api/");
54 expect(SERVICE_WORKER_SRC).toContain("/login");
55 });
56
57 it("service worker ignores non-GET requests", () => {
58 expect(SERVICE_WORKER_SRC).toContain("req.method !== 'GET'");
59 });
60});
61
62describe("pwa — icon", () => {
63 it("GET /icon.svg → 200 SVG", async () => {
64 const res = await app.request("/icon.svg");
65 expect(res.status).toBe(200);
66 expect(res.headers.get("content-type") || "").toContain("image/svg+xml");
67 const body = await res.text();
68 expect(body).toContain("<svg");
69 expect(body).toContain("</svg>");
70 });
71});
72
73describe("pwa — register snippet", () => {
74 it("registers a service worker when available", () => {
75 expect(PWA_REGISTER_SNIPPET).toContain("serviceWorker");
76 expect(PWA_REGISTER_SNIPPET).toContain("'/sw.js'");
77 });
78});
79
80describe("pwa — layout wiring", () => {
81 it("home page includes the manifest link", async () => {
82 const res = await app.request("/");
83 const body = await res.text();
84 expect(body).toContain('rel="manifest"');
85 expect(body).toContain("/manifest.webmanifest");
86 });
87
88 it("home page registers the service worker", async () => {
89 const res = await app.request("/");
90 const body = await res.text();
91 // JSX entity-escapes quotes inside <script>; just check the SW path is wired.
92 expect(body).toContain("serviceWorker.register");
93 expect(body).toContain("/sw.js");
94 });
95});
Addedsrc/__tests__/repo-lifecycle.test.ts+75−0View fileUnifiedSplit
1/**
2 * Block I1+I2+I3 — Archive, template, transfer route auth smoke.
3 * Also asserts the command palette payload is injected into Layout output.
4 */
5
6import { describe, it, expect } from "bun:test";
7import app from "../app";
8import { Layout } from "../views/layout";
9
10describe("repo-lifecycle — archive", () => {
11 it("POST /:owner/:repo/settings/archive without auth → 302 /login", async () => {
12 const res = await app.request("/alice/repo/settings/archive", {
13 method: "POST",
14 body: new URLSearchParams({ archive: "1" }),
15 headers: { "content-type": "application/x-www-form-urlencoded" },
16 });
17 expect(res.status).toBe(302);
18 expect(res.headers.get("location") || "").toContain("/login");
19 });
20});
21
22describe("repo-lifecycle — template", () => {
23 it("POST /:owner/:repo/settings/template without auth → 302 /login", async () => {
24 const res = await app.request("/alice/repo/settings/template", {
25 method: "POST",
26 body: new URLSearchParams({ template: "1" }),
27 headers: { "content-type": "application/x-www-form-urlencoded" },
28 });
29 expect(res.status).toBe(302);
30 expect(res.headers.get("location") || "").toContain("/login");
31 });
32
33 it("POST /:owner/:repo/use-template without auth → 302 /login", async () => {
34 const res = await app.request("/alice/repo/use-template", {
35 method: "POST",
36 body: new URLSearchParams({ name: "new-repo" }),
37 headers: { "content-type": "application/x-www-form-urlencoded" },
38 });
39 expect(res.status).toBe(302);
40 expect(res.headers.get("location") || "").toContain("/login");
41 });
42});
43
44describe("repo-lifecycle — transfer", () => {
45 it("POST /:owner/:repo/settings/transfer without auth → 302 /login", async () => {
46 const res = await app.request("/alice/repo/settings/transfer", {
47 method: "POST",
48 body: new URLSearchParams({ new_owner: "bob" }),
49 headers: { "content-type": "application/x-www-form-urlencoded" },
50 });
51 expect(res.status).toBe(302);
52 expect(res.headers.get("location") || "").toContain("/login");
53 });
54});
55
56describe("command palette — Layout markup", () => {
57 it("renders cmdk-backdrop + cmdk-panel + cmdk-input in every layout", () => {
58 const vnode = Layout({ title: "Test", children: "x" } as any);
59 // vnode.toString() renders JSX to HTML in Hono's JSX runtime
60 const html = String(vnode);
61 expect(html).toContain("cmdk-backdrop");
62 expect(html).toContain("cmdk-panel");
63 expect(html).toContain("cmdk-input");
64 expect(html).toContain("cmdk-list");
65 });
66
67 it("command palette script registers COMMANDS list", () => {
68 const vnode = Layout({ title: "Test", children: "x" } as any);
69 const html = String(vnode);
70 // Script body should mention some canonical destinations
71 expect(html).toContain("Go to Dashboard");
72 expect(html).toContain("Marketplace");
73 expect(html).toContain("GraphQL explorer");
74 });
75});
Addedsrc/__tests__/required-checks.test.ts+128−0View fileUnifiedSplit
1/**
2 * Block E6 — Required status checks matrix tests.
3 *
4 * Covers the pure protection-evaluator path (no DB) + route auth smoke.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import { evaluateProtection } from "../lib/branch-protection";
10import type { BranchProtection } from "../db/schema";
11
12function rule(overrides: Partial<BranchProtection> = {}): BranchProtection {
13 return {
14 id: "rule-1",
15 repositoryId: "repo-1",
16 pattern: "main",
17 requirePullRequest: true,
18 requireGreenGates: true,
19 requireAiApproval: false,
20 requireHumanReview: false,
21 requiredApprovals: 0,
22 allowForcePush: false,
23 allowDeletion: false,
24 createdAt: new Date(),
25 updatedAt: new Date(),
26 ...overrides,
27 } as BranchProtection;
28}
29
30describe("evaluateProtection — required checks matrix", () => {
31 const green = {
32 aiApproved: true,
33 humanApprovalCount: 1,
34 gateResultGreen: true,
35 hasFailedGates: false,
36 };
37
38 it("allows when no required checks are configured", () => {
39 const d = evaluateProtection(rule(), green, []);
40 expect(d.allowed).toBe(true);
41 expect(d.reasons.length).toBe(0);
42 expect(d.missingChecks).toBeUndefined();
43 });
44
45 it("allows when all required checks are in passingCheckNames", () => {
46 const d = evaluateProtection(
47 rule(),
48 { ...green, passingCheckNames: ["GateTest", "AI Review", "CI"] },
49 ["GateTest", "AI Review"]
50 );
51 expect(d.allowed).toBe(true);
52 expect(d.missingChecks).toBeUndefined();
53 });
54
55 it("blocks when a required check is missing", () => {
56 const d = evaluateProtection(
57 rule(),
58 { ...green, passingCheckNames: ["GateTest"] },
59 ["GateTest", "AI Review"]
60 );
61 expect(d.allowed).toBe(false);
62 expect(d.missingChecks).toEqual(["AI Review"]);
63 expect(d.reasons.join(" ")).toContain("AI Review");
64 });
65
66 it("blocks when passingCheckNames is empty but checks are required", () => {
67 const d = evaluateProtection(
68 rule(),
69 { ...green, passingCheckNames: [] },
70 ["GateTest"]
71 );
72 expect(d.allowed).toBe(false);
73 expect(d.missingChecks).toEqual(["GateTest"]);
74 });
75
76 it("still reports other failures alongside missing checks", () => {
77 const d = evaluateProtection(
78 rule({ requireAiApproval: true, requiredApprovals: 2 }),
79 {
80 aiApproved: false,
81 humanApprovalCount: 0,
82 gateResultGreen: true,
83 hasFailedGates: false,
84 passingCheckNames: [],
85 },
86 ["CI"]
87 );
88 expect(d.allowed).toBe(false);
89 // 3 reasons: AI approval, required approvals, missing check
90 expect(d.reasons.length).toBeGreaterThanOrEqual(3);
91 expect(d.missingChecks).toEqual(["CI"]);
92 });
93});
94
95describe("required-checks — route smoke", () => {
96 it("GET protection/:id/checks without auth → 302 /login", async () => {
97 const res = await app.request(
98 "/any/repo/gates/protection/abc/checks"
99 );
100 expect(res.status).toBe(302);
101 const loc = res.headers.get("location") || "";
102 expect(loc.startsWith("/login")).toBe(true);
103 });
104
105 it("POST protection/:id/checks without auth → 302 /login", async () => {
106 const res = await app.request(
107 "/any/repo/gates/protection/abc/checks",
108 {
109 method: "POST",
110 body: new URLSearchParams({ checkName: "CI" }),
111 headers: { "content-type": "application/x-www-form-urlencoded" },
112 }
113 );
114 expect(res.status).toBe(302);
115 const loc = res.headers.get("location") || "";
116 expect(loc.startsWith("/login")).toBe(true);
117 });
118
119 it("POST protection/:id/checks/:cid/delete without auth → 302 /login", async () => {
120 const res = await app.request(
121 "/any/repo/gates/protection/abc/checks/xyz/delete",
122 { method: "POST" }
123 );
124 expect(res.status).toBe(302);
125 const loc = res.headers.get("location") || "";
126 expect(loc.startsWith("/login")).toBe(true);
127 });
128});
Addedsrc/__tests__/rulesets.test.ts+368−0View fileUnifiedSplit
1/**
2 * Block J6 — Ruleset evaluator + route-auth tests.
3 *
4 * Evaluator is pure, so most of the coverage is in this file. DB-backed CRUD
5 * relies on a live DB so it's covered in integration.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 RULE_TYPES,
12 __internal,
13 evaluatePush,
14 globToRegex,
15 parseParams,
16} from "../lib/rulesets";
17
18describe("rulesets — globToRegex", () => {
19 it("handles literal paths", () => {
20 expect(globToRegex("README.md").test("README.md")).toBe(true);
21 expect(globToRegex("README.md").test("other.md")).toBe(false);
22 });
23
24 it("* matches one segment", () => {
25 expect(globToRegex("src/*.ts").test("src/app.ts")).toBe(true);
26 expect(globToRegex("src/*.ts").test("src/a/b.ts")).toBe(false);
27 });
28
29 it("** matches anything including slashes", () => {
30 expect(globToRegex("docs/**").test("docs/a/b/c.md")).toBe(true);
31 expect(globToRegex("**/secret.txt").test("a/b/secret.txt")).toBe(true);
32 });
33
34 it("escapes regex specials", () => {
35 expect(globToRegex("a+b.txt").test("a+b.txt")).toBe(true);
36 expect(globToRegex("a+b.txt").test("axb.txt")).toBe(false);
37 });
38});
39
40describe("rulesets — parseParams", () => {
41 it("round-trips JSON", () => {
42 expect(parseParams('{"pattern":"^feat:"}')).toEqual({
43 pattern: "^feat:",
44 });
45 });
46
47 it("returns {} on garbage", () => {
48 expect(parseParams("not json")).toEqual({});
49 expect(parseParams("")).toEqual({});
50 });
51});
52
53function rs(
54 enforcement: "active" | "evaluate" | "disabled",
55 rules: Array<{ ruleType: string; params: any }>
56) {
57 return {
58 id: "rs-1",
59 repositoryId: "r",
60 name: "n",
61 enforcement,
62 createdBy: null,
63 createdAt: new Date(),
64 updatedAt: new Date(),
65 rules: rules.map((r, i) => ({
66 id: `rule-${i}`,
67 rulesetId: "rs-1",
68 ruleType: r.ruleType,
69 params: JSON.stringify(r.params),
70 createdAt: new Date(),
71 })),
72 } as any;
73}
74
75describe("rulesets — evaluatePush commit_message_pattern", () => {
76 it("require=true blocks non-matching messages under active", () => {
77 const result = evaluatePush(
78 [
79 rs("active", [
80 {
81 ruleType: "commit_message_pattern",
82 params: { pattern: "^(feat|fix|chore):" },
83 },
84 ]),
85 ],
86 {
87 kind: "push",
88 refType: "branch",
89 refName: "main",
90 commits: [{ sha: "abc123", message: "random junk" }],
91 }
92 );
93 expect(result.allowed).toBe(false);
94 expect(result.violations[0].ruleType).toBe("commit_message_pattern");
95 });
96
97 it("evaluate mode warns but allows", () => {
98 const result = evaluatePush(
99 [
100 rs("evaluate", [
101 {
102 ruleType: "commit_message_pattern",
103 params: { pattern: "^(feat|fix|chore):" },
104 },
105 ]),
106 ],
107 {
108 kind: "push",
109 refType: "branch",
110 refName: "main",
111 commits: [{ sha: "abc123", message: "random" }],
112 }
113 );
114 expect(result.allowed).toBe(true);
115 expect(result.violations.length).toBe(1);
116 expect(result.violations[0].enforcement).toBe("evaluate");
117 });
118
119 it("disabled mode produces zero violations", () => {
120 const result = evaluatePush(
121 [
122 rs("disabled", [
123 {
124 ruleType: "commit_message_pattern",
125 params: { pattern: "^feat:" },
126 },
127 ]),
128 ],
129 {
130 kind: "push",
131 refType: "branch",
132 refName: "main",
133 commits: [{ sha: "abc", message: "anything" }],
134 }
135 );
136 expect(result.allowed).toBe(true);
137 expect(result.violations.length).toBe(0);
138 });
139
140 it("require=false blocks when pattern matches (forbidden)", () => {
141 const result = evaluatePush(
142 [
143 rs("active", [
144 {
145 ruleType: "commit_message_pattern",
146 params: { pattern: "wip", flags: "i", require: false },
147 },
148 ]),
149 ],
150 {
151 kind: "push",
152 refType: "branch",
153 refName: "main",
154 commits: [{ sha: "abc", message: "WIP push" }],
155 }
156 );
157 expect(result.allowed).toBe(false);
158 });
159});
160
161describe("rulesets — evaluatePush branch / tag patterns", () => {
162 it("branch must match required pattern", () => {
163 const r = evaluatePush(
164 [
165 rs("active", [
166 {
167 ruleType: "branch_name_pattern",
168 params: { pattern: "^release/" },
169 },
170 ]),
171 ],
172 {
173 kind: "push",
174 refType: "branch",
175 refName: "feature/x",
176 commits: [],
177 }
178 );
179 expect(r.allowed).toBe(false);
180 });
181
182 it("branch rule is a no-op for tag pushes", () => {
183 const r = evaluatePush(
184 [
185 rs("active", [
186 {
187 ruleType: "branch_name_pattern",
188 params: { pattern: "^release/" },
189 },
190 ]),
191 ],
192 { kind: "push", refType: "tag", refName: "v1.0", commits: [] }
193 );
194 expect(r.allowed).toBe(true);
195 });
196
197 it("tag must match semver-ish", () => {
198 const r = evaluatePush(
199 [
200 rs("active", [
201 {
202 ruleType: "tag_name_pattern",
203 params: { pattern: "^v\\d+\\.\\d+\\.\\d+$" },
204 },
205 ]),
206 ],
207 { kind: "push", refType: "tag", refName: "v1.0", commits: [] }
208 );
209 expect(r.allowed).toBe(false);
210 });
211});
212
213describe("rulesets — evaluatePush blocked_file_paths", () => {
214 it("blocks changes to matching paths", () => {
215 const r = evaluatePush(
216 [
217 rs("active", [
218 {
219 ruleType: "blocked_file_paths",
220 params: { paths: ["secrets/**", "*.pem"] },
221 },
222 ]),
223 ],
224 {
225 kind: "push",
226 refType: "branch",
227 refName: "main",
228 commits: [
229 { sha: "a", message: "x", changedPaths: ["secrets/db.env"] },
230 ],
231 }
232 );
233 expect(r.allowed).toBe(false);
234 });
235
236 it("allows unrelated path changes", () => {
237 const r = evaluatePush(
238 [
239 rs("active", [
240 {
241 ruleType: "blocked_file_paths",
242 params: { paths: ["secrets/**"] },
243 },
244 ]),
245 ],
246 {
247 kind: "push",
248 refType: "branch",
249 refName: "main",
250 commits: [{ sha: "a", message: "x", changedPaths: ["src/app.ts"] }],
251 }
252 );
253 expect(r.allowed).toBe(true);
254 });
255});
256
257describe("rulesets — evaluatePush max_file_size + force push", () => {
258 it("blocks oversize blobs", () => {
259 const r = evaluatePush(
260 [
261 rs("active", [
262 {
263 ruleType: "max_file_size",
264 params: { bytes: 1024 },
265 },
266 ]),
267 ],
268 {
269 kind: "push",
270 refType: "branch",
271 refName: "main",
272 commits: [{ sha: "a", message: "x", maxBlobSize: 2048 }],
273 }
274 );
275 expect(r.allowed).toBe(false);
276 });
277
278 it("blocks force push when configured", () => {
279 const r = evaluatePush(
280 [rs("active", [{ ruleType: "forbid_force_push", params: {} }])],
281 {
282 kind: "push",
283 refType: "branch",
284 refName: "main",
285 commits: [],
286 forcePush: true,
287 }
288 );
289 expect(r.allowed).toBe(false);
290 });
291});
292
293describe("rulesets — RULE_TYPES surface", () => {
294 it("exports all rule types", () => {
295 expect(RULE_TYPES).toContain("commit_message_pattern");
296 expect(RULE_TYPES).toContain("branch_name_pattern");
297 expect(RULE_TYPES).toContain("tag_name_pattern");
298 expect(RULE_TYPES).toContain("blocked_file_paths");
299 expect(RULE_TYPES).toContain("max_file_size");
300 expect(RULE_TYPES).toContain("forbid_force_push");
301 expect(RULE_TYPES.length).toBe(6);
302 });
303});
304
305describe("rulesets — __internal evalRule edge cases", () => {
306 it("empty pattern is a no-op", () => {
307 const msgs = __internal.evalRule(
308 {
309 id: "r1",
310 rulesetId: "s",
311 ruleType: "commit_message_pattern",
312 params: JSON.stringify({ pattern: "" }),
313 createdAt: new Date(),
314 } as any,
315 {
316 kind: "push",
317 refType: "branch",
318 refName: "main",
319 commits: [{ sha: "a", message: "nothing" }],
320 }
321 );
322 expect(msgs.length).toBe(0);
323 });
324
325 it("invalid regex is a no-op", () => {
326 const msgs = __internal.evalRule(
327 {
328 id: "r1",
329 rulesetId: "s",
330 ruleType: "commit_message_pattern",
331 params: JSON.stringify({ pattern: "(" }),
332 createdAt: new Date(),
333 } as any,
334 {
335 kind: "push",
336 refType: "branch",
337 refName: "main",
338 commits: [{ sha: "a", message: "x" }],
339 }
340 );
341 expect(msgs.length).toBe(0);
342 });
343});
344
345describe("rulesets — route auth", () => {
346 it("GET /:o/:r/settings/rulesets without auth → 302 /login", async () => {
347 const res = await app.request("/alice/repo/settings/rulesets");
348 expect(res.status).toBe(302);
349 expect(res.headers.get("location") || "").toContain("/login");
350 });
351
352 it("POST create without auth → 302 /login", async () => {
353 const res = await app.request("/alice/repo/settings/rulesets", {
354 method: "POST",
355 });
356 expect(res.status).toBe(302);
357 expect(res.headers.get("location") || "").toContain("/login");
358 });
359
360 it("POST delete without auth → 302 /login", async () => {
361 const res = await app.request(
362 "/alice/repo/settings/rulesets/00000000-0000-0000-0000-000000000000/delete",
363 { method: "POST" }
364 );
365 expect(res.status).toBe(302);
366 expect(res.headers.get("location") || "").toContain("/login");
367 });
368});
Addedsrc/__tests__/semantic-search.test.ts+274−0View fileUnifiedSplit
1/**
2 * Tests for Block D1 — semantic code search.
3 *
4 * Covers the pure helpers (tokenize, hashEmbed, cosine, isCodeFile,
5 * chunkFile) plus a route-level smoke test that the /:owner/:repo/search/semantic
6 * page always resolves — even for a nonexistent repo, where it must render
7 * a 404 Layout rather than blowing up.
8 *
9 * These tests deliberately avoid Voyage and the DB. The fallback embedder
10 * is pure math; the route falls through to the global 404 path when the
11 * repo doesn't exist.
12 */
13
14import { describe, it, expect } from "bun:test";
15import app from "../app";
16import {
17 tokenize,
18 hashEmbed,
19 cosine,
20 isCodeFile,
21 chunkFile,
22 isEmbeddingsProviderAvailable,
23 __test,
24} from "../lib/semantic-search";
25
26describe("tokenize", () => {
27 it("splits on non-word boundaries", () => {
28 const t = tokenize("hello, world!");
29 expect(t).toContain("hello");
30 expect(t).toContain("world");
31 });
32
33 it("splits camelCase into fragments", () => {
34 const t = tokenize("getUserName");
35 expect(t).toContain("get");
36 expect(t).toContain("user");
37 expect(t).toContain("name");
38 });
39
40 it("splits PascalCase and ALLCAPS runs", () => {
41 const t = tokenize("XMLParser");
42 expect(t).toContain("xml");
43 expect(t).toContain("parser");
44 });
45
46 it("splits snake_case into fragments", () => {
47 const t = tokenize("snake_case_name");
48 expect(t).toContain("snake");
49 expect(t).toContain("case");
50 expect(t).toContain("name");
51 });
52
53 it("lowercases everything", () => {
54 const t = tokenize("FooBar");
55 for (const tok of t) {
56 expect(tok).toBe(tok.toLowerCase());
57 }
58 });
59
60 it("drops single-character tokens and pure numeric tokens", () => {
61 const t = tokenize("a b foo 123 bar2");
62 expect(t).not.toContain("a");
63 expect(t).not.toContain("b");
64 expect(t).not.toContain("123");
65 expect(t).toContain("foo");
66 expect(t).toContain("bar2");
67 });
68
69 it("returns [] for empty input", () => {
70 expect(tokenize("")).toEqual([]);
71 });
72});
73
74describe("hashEmbed", () => {
75 it("returns a vector of the requested dimension", () => {
76 const v = hashEmbed(["hello", "world"], 512);
77 expect(v.length).toBe(512);
78 });
79
80 it("produces an L2-normalized vector (sum of squares ≈ 1)", () => {
81 const v = hashEmbed(tokenize("const user = getUserById(id);"), 512);
82 let sq = 0;
83 for (const x of v) sq += x * x;
84 expect(sq).toBeGreaterThan(0.99);
85 expect(sq).toBeLessThan(1.01);
86 });
87
88 it("returns an all-zero vector for empty token list", () => {
89 const v = hashEmbed([], 512);
90 expect(v.length).toBe(512);
91 expect(v.every((x) => x === 0)).toBe(true);
92 });
93
94 it("is deterministic across calls", () => {
95 const v1 = hashEmbed(["foo", "bar", "baz"]);
96 const v2 = hashEmbed(["foo", "bar", "baz"]);
97 expect(v1).toEqual(v2);
98 });
99
100 it("honours a custom dimension", () => {
101 const v = hashEmbed(["x"], 128);
102 expect(v.length).toBe(128);
103 });
104});
105
106describe("cosine", () => {
107 it("returns ~1 for identical non-zero vectors", () => {
108 const v = hashEmbed(tokenize("const answer = 42;"));
109 const s = cosine(v, v);
110 expect(s).toBeGreaterThan(0.999);
111 expect(s).toBeLessThan(1.001);
112 });
113
114 it("returns 0 for orthogonal vectors", () => {
115 const a = [1, 0, 0, 0];
116 const b = [0, 1, 0, 0];
117 expect(cosine(a, b)).toBe(0);
118 });
119
120 it("returns 0 when either vector is all zeros", () => {
121 const z = [0, 0, 0];
122 const v = [1, 2, 3];
123 expect(cosine(z, v)).toBe(0);
124 expect(cosine(v, z)).toBe(0);
125 });
126
127 it("ranks a close match higher than an unrelated one", () => {
128 const query = hashEmbed(tokenize("parse JSON response"));
129 const close = hashEmbed(
130 tokenize("function parseJsonResponse(text) { return JSON.parse(text); }")
131 );
132 const far = hashEmbed(
133 tokenize("draw a red rectangle on the canvas context")
134 );
135 expect(cosine(query, close)).toBeGreaterThan(cosine(query, far));
136 });
137});
138
139describe("isCodeFile", () => {
140 it("accepts common source extensions", () => {
141 expect(isCodeFile("src/index.ts")).toBe(true);
142 expect(isCodeFile("app.tsx")).toBe(true);
143 expect(isCodeFile("main.py")).toBe(true);
144 expect(isCodeFile("lib.rs")).toBe(true);
145 expect(isCodeFile("server.go")).toBe(true);
146 expect(isCodeFile("style.css")).toBe(true);
147 expect(isCodeFile("README.md")).toBe(true);
148 expect(isCodeFile("config.yaml")).toBe(true);
149 expect(isCodeFile("config.yml")).toBe(true);
150 expect(isCodeFile("tsconfig.json")).toBe(true);
151 });
152
153 it("rejects lock files", () => {
154 expect(isCodeFile("package-lock.json")).toBe(false);
155 expect(isCodeFile("yarn.lock")).toBe(false);
156 expect(isCodeFile("bun.lockb")).toBe(false);
157 expect(isCodeFile("bun.lock")).toBe(false);
158 expect(isCodeFile("pnpm-lock.yaml")).toBe(false);
159 expect(isCodeFile("poetry.lock")).toBe(false);
160 expect(isCodeFile("Cargo.lock")).toBe(false);
161 });
162
163 it("rejects binary / image extensions", () => {
164 expect(isCodeFile("logo.png")).toBe(false);
165 expect(isCodeFile("photo.jpg")).toBe(false);
166 expect(isCodeFile("anim.gif")).toBe(false);
167 expect(isCodeFile("dump.bin")).toBe(false);
168 expect(isCodeFile("a.exe")).toBe(false);
169 expect(isCodeFile("font.woff2")).toBe(false);
170 });
171
172 it("rejects files with no extension", () => {
173 expect(isCodeFile("Makefile")).toBe(false);
174 expect(isCodeFile("Dockerfile")).toBe(false);
175 expect(isCodeFile("LICENSE")).toBe(false);
176 });
177
178 it("rejects empty path", () => {
179 expect(isCodeFile("")).toBe(false);
180 });
181});
182
183describe("chunkFile", () => {
184 it("returns [] for non-code paths", () => {
185 expect(chunkFile("image.png", "whatever")).toEqual([]);
186 expect(chunkFile("package-lock.json", "{}" )).toEqual([]);
187 });
188
189 it("returns [] for empty content", () => {
190 expect(chunkFile("foo.ts", "")).toEqual([]);
191 });
192
193 it("emits a single chunk for short files", () => {
194 const content = Array.from({ length: 10 }, (_, i) => `line${i + 1}`).join("\n");
195 const chunks = chunkFile("short.ts", content, 40);
196 expect(chunks.length).toBe(1);
197 expect(chunks[0].startLine).toBe(1);
198 expect(chunks[0].endLine).toBe(10);
199 expect(chunks[0].path).toBe("short.ts");
200 });
201
202 it("produces overlapping chunks with expected start/end lines", () => {
203 // 100 lines, chunkSize 40, overlap 5 → step 35
204 const content = Array.from({ length: 100 }, (_, i) => `L${i + 1}`).join("\n");
205 const chunks = chunkFile("big.ts", content, 40);
206 expect(chunks.length).toBeGreaterThan(1);
207
208 // First chunk: lines 1..40
209 expect(chunks[0].startLine).toBe(1);
210 expect(chunks[0].endLine).toBe(40);
211
212 // Second chunk starts 35 lines later (40 - 5 overlap), i.e. line 36
213 expect(chunks[1].startLine).toBe(36);
214 expect(chunks[1].endLine).toBe(75);
215
216 // Overlap: last 5 lines of chunk[0] equal first 5 of chunk[1].
217 const c0Lines = chunks[0].content.split("\n");
218 const c1Lines = chunks[1].content.split("\n");
219 expect(c0Lines.slice(-5)).toEqual(c1Lines.slice(0, 5));
220
221 // Last chunk must end at the final line exactly.
222 const last = chunks[chunks.length - 1];
223 expect(last.endLine).toBe(100);
224 });
225
226 it("preserves the exact path", () => {
227 const chunks = chunkFile("src/deep/path/file.ts", "a\nb\nc", 40);
228 expect(chunks[0].path).toBe("src/deep/path/file.ts");
229 });
230});
231
232describe("isEmbeddingsProviderAvailable", () => {
233 it("always reports fallback: true", () => {
234 const p = isEmbeddingsProviderAvailable();
235 expect(p.fallback).toBe(true);
236 expect(typeof p.voyage).toBe("boolean");
237 });
238});
239
240describe("__test bundle", () => {
241 it("exports the pure helpers without DB dependency", () => {
242 expect(typeof __test.tokenize).toBe("function");
243 expect(typeof __test.hashEmbed).toBe("function");
244 expect(typeof __test.cosine).toBe("function");
245 expect(typeof __test.isCodeFile).toBe("function");
246 expect(typeof __test.chunkFile).toBe("function");
247 });
248
249 it("hashes deterministically via __test.fnv1a", () => {
250 expect(__test.fnv1a("hello")).toBe(__test.fnv1a("hello"));
251 expect(__test.fnv1a("hello")).not.toBe(__test.fnv1a("world"));
252 });
253});
254
255describe("semantic-search route — smoke", () => {
256 it("GET /:owner/:repo/search/semantic for a nonexistent repo returns 404 HTML", async () => {
257 const res = await app.request(
258 "/nonexistent-user-xyz/nonexistent-repo-xyz/search/semantic"
259 );
260 // Either our own 404 (repo not found) or the global 404 if the router
261 // isn't mounted yet — both are acceptable.
262 expect([200, 404, 503]).toContain(res.status);
263 const html = await res.text();
264 // Layout marker — the response should be a full HTML shell, not JSON.
265 expect(html.toLowerCase()).toContain("<html");
266 });
267
268 it("GET without query string renders the Layout (no crash on empty q)", async () => {
269 const res = await app.request(
270 "/nonexistent-user-xyz/nonexistent-repo-xyz/search/semantic?q="
271 );
272 expect([200, 404, 503]).toContain(res.status);
273 });
274});
Addedsrc/__tests__/signatures.test.ts+337−0View fileUnifiedSplit
1/**
2 * Block J3 — Signature parsing + verification unit tests.
3 *
4 * Route tests only assert auth behavior; the full verify path needs a DB and
5 * a real repo, which integration covers.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 analyzeRawCommit,
12 extractSignatureFromCommit,
13 fingerprintForPublicKey,
14 parsePgpIssuerFingerprint,
15 parseSshSigPublicKey,
16 unarmorPgp,
17 unarmorSsh,
18 verifyRawCommit,
19 __internal,
20} from "../lib/signatures";
21
22const SAMPLE_GPG_COMMIT = [
23 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
24 "author Alice Example <alice@example.com> 1700000000 +0000",
25 "committer Alice Example <alice@example.com> 1700000000 +0000",
26 "gpgsig -----BEGIN PGP SIGNATURE-----",
27 " ",
28 " iQIzBAABCAAdFiEEABCDEFABCDEFABCDEFABCDEFABCDEFABFAmXXXXXXACgkQ",
29 " ABCDEFABCDEF1234567890",
30 " =ABCD",
31 " -----END PGP SIGNATURE-----",
32 "",
33 "chore: signed commit",
34 "",
35].join("\n");
36
37const SAMPLE_SSH_COMMIT = [
38 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
39 "author Bob Example <bob@example.com> 1700000000 +0000",
40 "committer Bob Example <bob@example.com> 1700000000 +0000",
41 "gpgsig -----BEGIN SSH SIGNATURE-----",
42 " U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgC",
43 " -----END SSH SIGNATURE-----",
44 "",
45 "chore: ssh signed",
46].join("\n");
47
48const UNSIGNED_COMMIT = [
49 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
50 "author Nobody <nobody@example.com> 1700000000 +0000",
51 "committer Nobody <nobody@example.com> 1700000000 +0000",
52 "",
53 "plain commit",
54].join("\n");
55
56describe("signatures — extractSignatureFromCommit", () => {
57 it("returns null for unsigned commits", () => {
58 expect(extractSignatureFromCommit(UNSIGNED_COMMIT)).toBeNull();
59 });
60
61 it("detects a PGP signature + author email", () => {
62 const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
63 expect(sig).not.toBeNull();
64 expect(sig!.type).toBe("gpg");
65 expect(sig!.authorEmail).toBe("alice@example.com");
66 expect(sig!.signature).toContain("BEGIN PGP SIGNATURE");
67 });
68
69 it("detects an SSH signature", () => {
70 const sig = extractSignatureFromCommit(SAMPLE_SSH_COMMIT);
71 expect(sig).not.toBeNull();
72 expect(sig!.type).toBe("ssh");
73 expect(sig!.authorEmail).toBe("bob@example.com");
74 });
75
76 it("preserves continuation-line body", () => {
77 const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
78 expect(sig!.signature.split("\n").length).toBeGreaterThan(2);
79 });
80
81 it("handles gpgsig-sha256 variant", () => {
82 const raw = SAMPLE_GPG_COMMIT.replace("gpgsig ", "gpgsig-sha256 ");
83 const sig = extractSignatureFromCommit(raw);
84 expect(sig).not.toBeNull();
85 expect(sig!.type).toBe("gpg");
86 });
87
88 it("empty input is null", () => {
89 expect(extractSignatureFromCommit("")).toBeNull();
90 });
91});
92
93describe("signatures — unarmorPgp", () => {
94 it("decodes a minimal armored block", () => {
95 const armored = [
96 "-----BEGIN PGP SIGNATURE-----",
97 "",
98 "AAECAwQFBgcICQ==",
99 "=CRC1",
100 "-----END PGP SIGNATURE-----",
101 ].join("\n");
102 const bytes = unarmorPgp(armored);
103 expect(bytes).not.toBeNull();
104 expect(Array.from(bytes!)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
105 });
106
107 it("skips armor headers until blank line", () => {
108 const armored = [
109 "-----BEGIN PGP SIGNATURE-----",
110 "Version: GnuPG v2",
111 "Comment: https://example.com",
112 "",
113 "AAECAwQFBgcICQ==",
114 "-----END PGP SIGNATURE-----",
115 ].join("\n");
116 const bytes = unarmorPgp(armored);
117 expect(bytes).not.toBeNull();
118 expect(bytes!.length).toBe(10);
119 });
120
121 it("returns null when there's no body", () => {
122 expect(
123 unarmorPgp("-----BEGIN PGP SIGNATURE-----\n-----END PGP SIGNATURE-----")
124 ).toBeNull();
125 });
126});
127
128describe("signatures — unarmorSsh", () => {
129 it("decodes SSH armored bytes", () => {
130 const armored = [
131 "-----BEGIN SSH SIGNATURE-----",
132 "U1NIU0lH",
133 "-----END SSH SIGNATURE-----",
134 ].join("\n");
135 const bytes = unarmorSsh(armored);
136 expect(bytes).not.toBeNull();
137 expect(Array.from(bytes!).slice(0, 6)).toEqual([
138 0x53, 0x53, 0x48, 0x53, 0x49, 0x47,
139 ]);
140 });
141
142 it("returns null on garbage", () => {
143 expect(unarmorSsh("")).toBeNull();
144 });
145});
146
147describe("signatures — parsePgpIssuerFingerprint", () => {
148 it("walks an old-format sig packet with subpacket 33", () => {
149 // Build a minimal old-format v4 sig packet:
150 // tagByte = 0x88 (old format, tag=2, lenType=0)
151 // len byte
152 // version=4, sigType=0, pubAlgo=1, hashAlgo=8
153 // hashedLen u16 = 23
154 // subpacket: len=22, type=33, version=4, fp (20 bytes 0xAB)
155 // unhashedLen u16 = 0
156 const fp = new Uint8Array(20).fill(0xab);
157 const hashed: number[] = [];
158 hashed.push(22); // subpacket length (1+type+20)
159 hashed.push(33); // subpacket type
160 hashed.push(4); // fp version
161 for (const b of fp) hashed.push(b);
162 const body: number[] = [];
163 body.push(4, 0, 1, 8);
164 body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
165 for (const b of hashed) body.push(b);
166 body.push(0, 0); // empty unhashed
167 const bytes = new Uint8Array([0x88, body.length, ...body]);
168 const result = parsePgpIssuerFingerprint(bytes);
169 expect(result).toBe("ab".repeat(20));
170 });
171
172 it("falls back to subpacket 16 (Issuer Key ID)", () => {
173 const keyId = new Uint8Array(8).fill(0xcd);
174 const hashed: number[] = [];
175 hashed.push(9); // len
176 hashed.push(16); // type
177 for (const b of keyId) hashed.push(b);
178 const body: number[] = [];
179 body.push(4, 0, 1, 8);
180 body.push(0, 0); // empty hashed
181 body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
182 for (const b of hashed) body.push(b);
183 const bytes = new Uint8Array([0x88, body.length, ...body]);
184 const result = parsePgpIssuerFingerprint(bytes);
185 expect(result).toBe("cd".repeat(8));
186 });
187
188 it("returns null for non-signature packet streams", () => {
189 expect(parsePgpIssuerFingerprint(new Uint8Array(0))).toBeNull();
190 expect(parsePgpIssuerFingerprint(new Uint8Array([0, 0, 0]))).toBeNull();
191 });
192});
193
194describe("signatures — fingerprintForPublicKey", () => {
195 it("SHA256-fingerprints an SSH ed25519 pubkey token", async () => {
196 // Synthesize an SSH wire-format pubkey: lengths in network order.
197 const type = "ssh-ed25519";
198 const data = new Uint8Array(32).fill(0xa0);
199 const header = new Uint8Array(4 + type.length);
200 new DataView(header.buffer).setUint32(0, type.length);
201 for (let i = 0; i < type.length; i++) header[4 + i] = type.charCodeAt(i);
202 const keyHeader = new Uint8Array(4);
203 new DataView(keyHeader.buffer).setUint32(0, data.length);
204 const wire = new Uint8Array(header.length + keyHeader.length + data.length);
205 wire.set(header, 0);
206 wire.set(keyHeader, header.length);
207 wire.set(data, header.length + keyHeader.length);
208 const b64 = btoa(String.fromCharCode(...wire));
209 const authLine = `ssh-ed25519 ${b64} user@host`;
210 const fp = await fingerprintForPublicKey("ssh", authLine);
211 expect(fp).not.toBeNull();
212 expect(fp!.startsWith("SHA256:")).toBe(true);
213 expect(fp!.length).toBeGreaterThan(20);
214 });
215
216 it("GPG extracts first long fingerprint from armored blob", async () => {
217 const pem = [
218 "-----BEGIN PGP PUBLIC KEY BLOCK-----",
219 "",
220 "fingerprint: 1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0B",
221 "-----END PGP PUBLIC KEY BLOCK-----",
222 ].join("\n");
223 const fp = await fingerprintForPublicKey("gpg", pem);
224 expect(fp).toBe("1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b");
225 });
226
227 it("returns null when no fingerprint can be derived", async () => {
228 expect(await fingerprintForPublicKey("ssh", "")).toBeNull();
229 expect(await fingerprintForPublicKey("gpg", "no fingerprint here")).toBeNull();
230 });
231});
232
233describe("signatures — parseSshSigPublicKey", () => {
234 it("extracts the wire-format pubkey from an SSHSIG blob", () => {
235 // Magic + u32 version=1 + string pubkey
236 const pubkey = new Uint8Array([1, 2, 3, 4, 5]);
237 const blob = new Uint8Array(6 + 4 + 4 + pubkey.length);
238 blob.set([0x53, 0x53, 0x48, 0x53, 0x49, 0x47], 0);
239 const dv = new DataView(blob.buffer);
240 dv.setUint32(6, 1); // version
241 dv.setUint32(10, pubkey.length); // pubkey length
242 blob.set(pubkey, 14);
243 const out = parseSshSigPublicKey(blob);
244 expect(out).not.toBeNull();
245 expect(Array.from(out!)).toEqual([1, 2, 3, 4, 5]);
246 });
247
248 it("returns null without SSHSIG magic", () => {
249 expect(parseSshSigPublicKey(new Uint8Array(0))).toBeNull();
250 expect(parseSshSigPublicKey(new Uint8Array(10))).toBeNull();
251 });
252});
253
254describe("signatures — analyzeRawCommit", () => {
255 it("returns nulls for unsigned commit", () => {
256 const r = analyzeRawCommit(UNSIGNED_COMMIT);
257 expect(r.type).toBeNull();
258 expect(r.fingerprint).toBeNull();
259 expect(r.authorEmail).toBeNull();
260 });
261
262 it("tags type=gpg + author email for a PGP-signed commit", () => {
263 const r = analyzeRawCommit(SAMPLE_GPG_COMMIT);
264 expect(r.type).toBe("gpg");
265 expect(r.authorEmail).toBe("alice@example.com");
266 });
267});
268
269describe("signatures — verifyRawCommit (DB-free fast paths)", () => {
270 it("unsigned → unsigned", async () => {
271 const r = await verifyRawCommit(UNSIGNED_COMMIT);
272 expect(r.verified).toBe(false);
273 expect(r.reason).toBe("unsigned");
274 });
275
276 it("null commit → unsigned", async () => {
277 const r = await verifyRawCommit(null);
278 expect(r.verified).toBe(false);
279 expect(r.reason).toBe("unsigned");
280 });
281
282 it("sig present but armor is empty → bad_sig", async () => {
283 const emptySig = [
284 "tree abc",
285 "author X <x@example.com> 1700000000 +0000",
286 "gpgsig -----BEGIN PGP SIGNATURE-----",
287 " ",
288 " -----END PGP SIGNATURE-----",
289 "",
290 "msg",
291 ].join("\n");
292 const r = await verifyRawCommit(emptySig);
293 expect(r.verified).toBe(false);
294 expect(r.reason).toBe("bad_sig");
295 expect(r.signatureType).toBe("gpg");
296 });
297});
298
299describe("signatures — __internal b64decode", () => {
300 it("round-trips", () => {
301 const s = "hello, world";
302 const enc = btoa(s);
303 const bytes = __internal.b64decode(enc);
304 const decoded = String.fromCharCode(...bytes);
305 expect(decoded).toBe(s);
306 });
307
308 it("tolerates whitespace in armor", () => {
309 const bytes = __internal.b64decode(" a\nGVsbG8= ");
310 expect(String.fromCharCode(...bytes)).toBe("hello");
311 });
312});
313
314describe("signatures — route auth", () => {
315 it("GET /settings/signing-keys requires auth", async () => {
316 const res = await app.request("/settings/signing-keys");
317 expect(res.status).toBe(302);
318 expect(res.headers.get("location") || "").toContain("/login");
319 });
320
321 it("POST /settings/signing-keys requires auth", async () => {
322 const res = await app.request("/settings/signing-keys", {
323 method: "POST",
324 });
325 expect(res.status).toBe(302);
326 expect(res.headers.get("location") || "").toContain("/login");
327 });
328
329 it("POST /settings/signing-keys/:id/delete requires auth", async () => {
330 const res = await app.request(
331 "/settings/signing-keys/00000000-0000-0000-0000-000000000000/delete",
332 { method: "POST" }
333 );
334 expect(res.status).toBe(302);
335 expect(res.headers.get("location") || "").toContain("/login");
336 });
337});
Addedsrc/__tests__/sponsors.test.ts+63−0View fileUnifiedSplit
1/**
2 * Block I6 — Sponsors tests.
3 *
4 * Pure tests for formatCents + route auth smoke for the maintainer settings
5 * routes (public sponsor page is intentionally ungated for reading).
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import { __internal } from "../routes/sponsors";
11
12const { formatCents } = __internal;
13
14describe("sponsors — formatCents", () => {
15 it("prints 'Any amount' for 0 cents", () => {
16 expect(formatCents(0)).toBe("Any amount");
17 });
18
19 it("formats 500 cents as $5.00", () => {
20 expect(formatCents(500)).toBe("$5.00");
21 });
22
23 it("formats 1234 cents as $12.34", () => {
24 expect(formatCents(1234)).toBe("$12.34");
25 });
26});
27
28describe("sponsors — route auth", () => {
29 it("GET /settings/sponsors without auth → 302 /login", async () => {
30 const res = await app.request("/settings/sponsors");
31 expect(res.status).toBe(302);
32 expect(res.headers.get("location") || "").toContain("/login");
33 });
34
35 it("POST /settings/sponsors/tiers/new without auth → 302 /login", async () => {
36 const res = await app.request("/settings/sponsors/tiers/new", {
37 method: "POST",
38 body: new URLSearchParams({ name: "Silver", monthly_cents: "500" }),
39 headers: { "content-type": "application/x-www-form-urlencoded" },
40 });
41 expect(res.status).toBe(302);
42 expect(res.headers.get("location") || "").toContain("/login");
43 });
44
45 it("POST /sponsors/:username without auth → 302 /login", async () => {
46 const res = await app.request("/sponsors/alice", {
47 method: "POST",
48 body: new URLSearchParams({ amount_cents: "500" }),
49 headers: { "content-type": "application/x-www-form-urlencoded" },
50 });
51 expect(res.status).toBe(302);
52 expect(res.headers.get("location") || "").toContain("/login");
53 });
54});
55
56describe("sponsors — public page", () => {
57 it("GET /sponsors/:unknown-user is handled (not swallowed)", async () => {
58 // Without a DB connection the handler 500s. With a DB it 404s via the
59 // global notFound handler. Either proves the route was reached.
60 const res = await app.request("/sponsors/__does_not_exist_12345__");
61 expect([404, 500]).toContain(res.status);
62 });
63});
Addedsrc/__tests__/sso.test.ts+226−0View fileUnifiedSplit
1/**
2 * Block I10 — Enterprise SSO (OIDC) tests.
3 *
4 * Covers pure helpers (URL building, domain gating, username normalization)
5 * and route authorization smokes. The full OIDC dance against a real IdP is
6 * exercised by live integration — we don't mock fetch here.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 buildAuthorizeUrl,
13 emailDomainAllowed,
14 randomToken,
15 ssoRedirectUri,
16 __internal,
17} from "../lib/sso";
18
19const { emptyToNull, normalizeUsername } = __internal;
20
21describe("sso — buildAuthorizeUrl", () => {
22 const cfg = {
23 authorizationEndpoint: "https://idp.example.com/authorize",
24 clientId: "abc123",
25 scopes: "openid profile email",
26 };
27
28 it("includes all OIDC required params", () => {
29 const url = buildAuthorizeUrl(
30 cfg,
31 "state-xyz",
32 "nonce-abc",
33 "https://app.example.com/login/sso/callback"
34 );
35 const u = new URL(url);
36 expect(u.origin + u.pathname).toBe("https://idp.example.com/authorize");
37 expect(u.searchParams.get("client_id")).toBe("abc123");
38 expect(u.searchParams.get("response_type")).toBe("code");
39 expect(u.searchParams.get("scope")).toBe("openid profile email");
40 expect(u.searchParams.get("state")).toBe("state-xyz");
41 expect(u.searchParams.get("nonce")).toBe("nonce-abc");
42 expect(u.searchParams.get("redirect_uri")).toBe(
43 "https://app.example.com/login/sso/callback"
44 );
45 });
46
47 it("preserves existing query params on the endpoint", () => {
48 const url = buildAuthorizeUrl(
49 {
50 authorizationEndpoint: "https://idp.example.com/authorize?ext=1",
51 clientId: "abc",
52 scopes: "openid",
53 },
54 "s",
55 "n",
56 "https://app/cb"
57 );
58 const u = new URL(url);
59 expect(u.searchParams.get("ext")).toBe("1");
60 expect(u.searchParams.get("client_id")).toBe("abc");
61 });
62
63 it("throws when endpoint or client_id is missing", () => {
64 expect(() =>
65 buildAuthorizeUrl(
66 { authorizationEndpoint: null, clientId: "x", scopes: "openid" } as any,
67 "s",
68 "n",
69 "https://app/cb"
70 )
71 ).toThrow();
72 expect(() =>
73 buildAuthorizeUrl(
74 {
75 authorizationEndpoint: "https://i/a",
76 clientId: null,
77 scopes: "openid",
78 } as any,
79 "s",
80 "n",
81 "https://app/cb"
82 )
83 ).toThrow();
84 });
85
86 it("falls back to default scopes when empty", () => {
87 const url = buildAuthorizeUrl(
88 {
89 authorizationEndpoint: "https://idp/a",
90 clientId: "c",
91 scopes: "" as any,
92 },
93 "s",
94 "n",
95 "https://app/cb"
96 );
97 expect(new URL(url).searchParams.get("scope")).toBe(
98 "openid profile email"
99 );
100 });
101});
102
103describe("sso — emailDomainAllowed", () => {
104 it("allows any when domains list is null", () => {
105 expect(emailDomainAllowed("a@example.com", null)).toBe(true);
106 });
107
108 it("allows any when list is empty", () => {
109 expect(emailDomainAllowed("a@example.com", "")).toBe(true);
110 expect(emailDomainAllowed("a@example.com", " ")).toBe(true);
111 });
112
113 it("accepts matching domain (case-insensitive)", () => {
114 expect(emailDomainAllowed("a@EXAMPLE.COM", "example.com")).toBe(true);
115 expect(emailDomainAllowed("a@example.com", "acme.io, example.com")).toBe(
116 true
117 );
118 });
119
120 it("rejects unmatched domain", () => {
121 expect(emailDomainAllowed("a@evil.com", "example.com")).toBe(false);
122 });
123
124 it("rejects missing email when list is set", () => {
125 expect(emailDomainAllowed(null, "example.com")).toBe(false);
126 expect(emailDomainAllowed("", "example.com")).toBe(false);
127 });
128
129 it("rejects malformed email", () => {
130 expect(emailDomainAllowed("not-an-email", "example.com")).toBe(false);
131 });
132});
133
134describe("sso — normalizeUsername", () => {
135 it("lowercases and slugifies", () => {
136 expect(normalizeUsername("Alice Smith")).toBe("alice-smith");
137 expect(normalizeUsername("BOB@acme.IO")).toBe("bob-acme-io");
138 });
139
140 it("strips leading/trailing dashes", () => {
141 expect(normalizeUsername("---foo---")).toBe("foo");
142 });
143
144 it("falls back to 'user' for empty input", () => {
145 expect(normalizeUsername("")).toBe("user");
146 expect(normalizeUsername("@@@")).toBe("user");
147 });
148
149 it("caps at 32 chars", () => {
150 const out = normalizeUsername("a".repeat(80));
151 expect(out.length).toBeLessThanOrEqual(32);
152 });
153});
154
155describe("sso — emptyToNull", () => {
156 it("returns null for empty or whitespace-only", () => {
157 expect(emptyToNull("")).toBeNull();
158 expect(emptyToNull(" ")).toBeNull();
159 expect(emptyToNull(null)).toBeNull();
160 expect(emptyToNull(undefined)).toBeNull();
161 });
162
163 it("trims and returns non-empty strings", () => {
164 expect(emptyToNull(" hello ")).toBe("hello");
165 expect(emptyToNull("x")).toBe("x");
166 });
167});
168
169describe("sso — randomToken", () => {
170 it("returns hex of expected length", () => {
171 expect(randomToken(8)).toMatch(/^[0-9a-f]{16}$/);
172 expect(randomToken(16)).toMatch(/^[0-9a-f]{32}$/);
173 });
174
175 it("returns distinct values across calls", () => {
176 expect(randomToken(16)).not.toBe(randomToken(16));
177 });
178});
179
180describe("sso — ssoRedirectUri", () => {
181 it("ends with /login/sso/callback", () => {
182 expect(ssoRedirectUri().endsWith("/login/sso/callback")).toBe(true);
183 });
184});
185
186describe("sso — route auth", () => {
187 it("GET /admin/sso without auth → 302 /login", async () => {
188 const res = await app.request("/admin/sso");
189 expect(res.status).toBe(302);
190 expect(res.headers.get("location") || "").toContain("/login");
191 });
192
193 it("POST /admin/sso without auth → 302 /login", async () => {
194 const res = await app.request("/admin/sso", {
195 method: "POST",
196 body: new URLSearchParams({ provider_name: "x" }),
197 headers: { "content-type": "application/x-www-form-urlencoded" },
198 });
199 expect(res.status).toBe(302);
200 expect(res.headers.get("location") || "").toContain("/login");
201 });
202
203 it("POST /settings/sso/unlink without auth → 302 /login", async () => {
204 const res = await app.request("/settings/sso/unlink", { method: "POST" });
205 expect(res.status).toBe(302);
206 expect(res.headers.get("location") || "").toContain("/login");
207 });
208
209 it("GET /login/sso when SSO not configured → 302 /login with error", async () => {
210 const res = await app.request("/login/sso");
211 expect(res.status).toBe(302);
212 const loc = res.headers.get("location") || "";
213 expect(loc).toContain("/login");
214 // Either "not enabled" or "not fully configured" — either proves the
215 // route is mounted and SSO guard fires.
216 expect(loc).toContain("error=");
217 });
218
219 it("GET /login/sso/callback without state cookie → 302 /login", async () => {
220 const res = await app.request(
221 "/login/sso/callback?code=abc&state=xyz"
222 );
223 expect(res.status).toBe(302);
224 expect(res.headers.get("location") || "").toContain("/login");
225 });
226});
Addedsrc/__tests__/symbols.test.ts+138−0View fileUnifiedSplit
1/**
2 * Block I8 — Symbol / xref navigation tests.
3 *
4 * Pure tests for the regex-based extractor per language + auth smoke on
5 * the reindex endpoint.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import { detectLanguage, extractSymbols } from "../lib/symbols";
11
12describe("symbols — detectLanguage", () => {
13 it("maps common extensions", () => {
14 expect(detectLanguage("src/foo.ts")).toBe("ts");
15 expect(detectLanguage("src/Foo.tsx")).toBe("ts");
16 expect(detectLanguage("script.js")).toBe("ts");
17 expect(detectLanguage("app.py")).toBe("py");
18 expect(detectLanguage("main.rs")).toBe("rs");
19 expect(detectLanguage("main.go")).toBe("go");
20 expect(detectLanguage("App.java")).toBe("java");
21 expect(detectLanguage("Main.kt")).toBe("kt");
22 expect(detectLanguage("App.swift")).toBe("swift");
23 expect(detectLanguage("helper.rb")).toBe("rb");
24 });
25
26 it("returns null for unknown extensions", () => {
27 expect(detectLanguage("README.md")).toBe(null);
28 expect(detectLanguage("styles.css")).toBe(null);
29 expect(detectLanguage("noext")).toBe(null);
30 });
31});
32
33describe("symbols — extractSymbols (ts)", () => {
34 it("finds exported functions", () => {
35 const src = `export function foo() {}\nexport async function bar() {}`;
36 const syms = extractSymbols(src, "ts");
37 expect(syms.find((s) => s.name === "foo")?.kind).toBe("function");
38 expect(syms.find((s) => s.name === "bar")?.kind).toBe("function");
39 });
40
41 it("finds classes + interfaces + types", () => {
42 const src = [
43 "export class Widget {}",
44 "export interface Opts {}",
45 "export type ID = string;",
46 ].join("\n");
47 const syms = extractSymbols(src, "ts");
48 expect(syms.find((s) => s.name === "Widget")?.kind).toBe("class");
49 expect(syms.find((s) => s.name === "Opts")?.kind).toBe("interface");
50 expect(syms.find((s) => s.name === "ID")?.kind).toBe("type");
51 });
52
53 it("finds arrow-function consts as functions", () => {
54 const src = `export const handler = async (req) => {};`;
55 const syms = extractSymbols(src, "ts");
56 expect(syms.find((s) => s.name === "handler")?.kind).toBe("function");
57 });
58
59 it("records 1-based line numbers", () => {
60 const src = `// comment\nexport function foo() {}`;
61 const [sym] = extractSymbols(src, "ts");
62 expect(sym.line).toBe(2);
63 });
64
65 it("skips minified / overly long lines", () => {
66 const long = "x".repeat(600);
67 const src = `${long}\nexport function real() {}`;
68 const syms = extractSymbols(src, "ts");
69 expect(syms.length).toBe(1);
70 expect(syms[0].name).toBe("real");
71 });
72
73 it("truncates signature to 240 chars", () => {
74 // keep the line under 500 chars (extractor skips minified lines)
75 const src = `export function foo(${"x: string, ".repeat(30)}) {}`;
76 const [sym] = extractSymbols(src, "ts");
77 expect(sym.signature.length).toBeLessThanOrEqual(240);
78 });
79});
80
81describe("symbols — extractSymbols (python)", () => {
82 it("finds def and class", () => {
83 const src = `def load():\n pass\n\nclass Widget:\n pass`;
84 const syms = extractSymbols(src, "py");
85 expect(syms.find((s) => s.name === "load")?.kind).toBe("function");
86 expect(syms.find((s) => s.name === "Widget")?.kind).toBe("class");
87 });
88
89 it("finds SCREAMING_CASE constants", () => {
90 const src = `MAX_ITEMS = 100\nfoo = 1`;
91 const syms = extractSymbols(src, "py");
92 expect(syms.find((s) => s.name === "MAX_ITEMS")?.kind).toBe("const");
93 // lowercase should not match const rule
94 expect(syms.find((s) => s.name === "foo")).toBeUndefined();
95 });
96});
97
98describe("symbols — extractSymbols (rust + go)", () => {
99 it("finds rust fn/struct/trait", () => {
100 const src = [
101 "pub fn run() {}",
102 "pub struct Config {}",
103 "pub trait Loader {}",
104 ].join("\n");
105 const syms = extractSymbols(src, "rs");
106 expect(syms.find((s) => s.name === "run")?.kind).toBe("function");
107 expect(syms.find((s) => s.name === "Config")?.kind).toBe("class");
108 expect(syms.find((s) => s.name === "Loader")?.kind).toBe("interface");
109 });
110
111 it("finds go func + type struct + type interface", () => {
112 const src = [
113 "func Handle() {}",
114 "type Config struct {",
115 "type Loader interface {",
116 ].join("\n");
117 const syms = extractSymbols(src, "go");
118 expect(syms.find((s) => s.name === "Handle")?.kind).toBe("function");
119 expect(syms.find((s) => s.name === "Config")?.kind).toBe("class");
120 expect(syms.find((s) => s.name === "Loader")?.kind).toBe("interface");
121 });
122});
123
124describe("symbols — extractSymbols (unknown language)", () => {
125 it("returns empty for unknown language", () => {
126 expect(extractSymbols("blah", "unknown")).toEqual([]);
127 });
128});
129
130describe("symbols — route auth", () => {
131 it("POST /:owner/:repo/symbols/reindex without auth → 302 /login", async () => {
132 const res = await app.request("/alice/repo/symbols/reindex", {
133 method: "POST",
134 });
135 expect(res.status).toBe(302);
136 expect(res.headers.get("location") || "").toContain("/login");
137 });
138});
Addedsrc/__tests__/traffic.test.ts+76−0View fileUnifiedSplit
1/**
2 * Block F1 — Traffic analytics tests.
3 *
4 * Pure bucketDaily tests + route auth smoke.
5 */
6
7import { describe, it, expect } from "bun:test";
8import app from "../app";
9import { bucketDaily } from "../lib/traffic";
10
11describe("traffic — bucketDaily", () => {
12 it("returns empty array for no events", () => {
13 expect(bucketDaily([])).toEqual([]);
14 });
15
16 it("buckets views and clones separately", () => {
17 const buckets = bucketDaily([
18 { createdAt: "2026-04-14T10:00:00Z", kind: "view" },
19 { createdAt: "2026-04-14T12:00:00Z", kind: "view" },
20 { createdAt: "2026-04-14T15:00:00Z", kind: "clone" },
21 { createdAt: "2026-04-15T09:00:00Z", kind: "view" },
22 ]);
23 expect(buckets).toEqual([
24 { day: "2026-04-14", views: 2, clones: 1 },
25 { day: "2026-04-15", views: 1, clones: 0 },
26 ]);
27 });
28
29 it("counts UI kind as views", () => {
30 const buckets = bucketDaily([
31 { createdAt: "2026-04-14T00:00:00Z", kind: "ui" },
32 ]);
33 expect(buckets).toEqual([{ day: "2026-04-14", views: 1, clones: 0 }]);
34 });
35
36 it("ignores api kind in view/clone buckets", () => {
37 const buckets = bucketDaily([
38 { createdAt: "2026-04-14T00:00:00Z", kind: "api" },
39 ]);
40 expect(buckets).toEqual([{ day: "2026-04-14", views: 0, clones: 0 }]);
41 });
42
43 it("sorts buckets by day ascending", () => {
44 const buckets = bucketDaily([
45 { createdAt: "2026-04-15T00:00:00Z", kind: "view" },
46 { createdAt: "2026-04-14T00:00:00Z", kind: "view" },
47 { createdAt: "2026-04-16T00:00:00Z", kind: "view" },
48 ]);
49 expect(buckets.map((b) => b.day)).toEqual([
50 "2026-04-14",
51 "2026-04-15",
52 "2026-04-16",
53 ]);
54 });
55});
56
57describe("traffic — route smoke", () => {
58 it("GET /:owner/:repo/traffic without auth → 302 /login", async () => {
59 const res = await app.request("/any/repo/traffic");
60 expect(res.status).toBe(302);
61 const loc = res.headers.get("location") || "";
62 expect(loc.startsWith("/login")).toBe(true);
63 });
64});
65
66describe("traffic — lib exports", () => {
67 it("exports track + helpers", async () => {
68 const mod = await import("../lib/traffic");
69 expect(typeof mod.track).toBe("function");
70 expect(typeof mod.trackView).toBe("function");
71 expect(typeof mod.trackClone).toBe("function");
72 expect(typeof mod.trackByName).toBe("function");
73 expect(typeof mod.summarise).toBe("function");
74 expect(typeof mod.bucketDaily).toBe("function");
75 });
76});
Addedsrc/__tests__/vscode-extension.test.ts+71−0View fileUnifiedSplit
1/**
2 * Block G4 — VS Code extension pure-helper tests.
3 *
4 * The extension itself depends on the `vscode` module (only available inside
5 * the host), but the URL-building helpers are pure — we re-implement them
6 * locally here to lock the contract. If the contract drifts in extension.ts,
7 * update this file in lockstep.
8 */
9
10import { describe, it, expect } from "bun:test";
11import { readFileSync } from "node:fs";
12import { join } from "node:path";
13
14// Mirror of src/extension.ts — keep in sync.
15function buildWebUrl(
16 host: string,
17 owner: string,
18 repo: string,
19 relPath: string,
20 line?: number
21): string {
22 const base = `${host.replace(/\/+$/, "")}/${owner}/${repo}/blob/main/${relPath}`;
23 return line ? `${base}#L${line + 1}` : base;
24}
25
26describe("vscode-extension — buildWebUrl", () => {
27 it("builds a basic web URL", () => {
28 expect(buildWebUrl("https://gluecron.com", "alice", "proj", "README.md")).toBe(
29 "https://gluecron.com/alice/proj/blob/main/README.md"
30 );
31 });
32
33 it("appends #L<n+1> when a line is supplied", () => {
34 expect(
35 buildWebUrl("https://gluecron.com", "alice", "proj", "src/x.ts", 41)
36 ).toBe("https://gluecron.com/alice/proj/blob/main/src/x.ts#L42");
37 });
38
39 it("strips trailing slashes from the host", () => {
40 expect(buildWebUrl("https://g.com///", "a", "b", "c")).toBe(
41 "https://g.com/a/b/blob/main/c"
42 );
43 });
44});
45
46describe("vscode-extension — package.json contract", () => {
47 const pkg = JSON.parse(
48 readFileSync(
49 join(process.cwd(), "vscode-extension/package.json"),
50 "utf8"
51 )
52 );
53
54 it("declares the expected commands", () => {
55 const names = pkg.contributes.commands.map((c: any) => c.command);
56 expect(names).toContain("gluecron.explainFile");
57 expect(names).toContain("gluecron.openOnWeb");
58 expect(names).toContain("gluecron.searchSemantic");
59 expect(names).toContain("gluecron.generateTests");
60 });
61
62 it("declares configuration keys host + token", () => {
63 const keys = Object.keys(pkg.contributes.configuration.properties);
64 expect(keys).toContain("gluecron.host");
65 expect(keys).toContain("gluecron.token");
66 });
67
68 it("activates onStartupFinished", () => {
69 expect(pkg.activationEvents).toContain("onStartupFinished");
70 });
71});
Addedsrc/__tests__/wikis.test.ts+83−0View fileUnifiedSplit
1/**
2 * Block E3 — Wikis smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import { slugifyTitle } from "../routes/wikis";
8
9describe("wikis — slugifyTitle", () => {
10 it("lowercases and dashes simple titles", () => {
11 expect(slugifyTitle("Home")).toBe("home");
12 expect(slugifyTitle("Getting Started")).toBe("getting-started");
13 });
14
15 it("strips punctuation", () => {
16 expect(slugifyTitle("Hello, World!")).toBe("hello-world");
17 expect(slugifyTitle("What's up?")).toBe("whats-up");
18 });
19
20 it("collapses consecutive spaces/dashes", () => {
21 expect(slugifyTitle("a b")).toBe("a-b");
22 expect(slugifyTitle("a---b")).toBe("a-b");
23 });
24
25 it("trims leading/trailing dashes", () => {
26 expect(slugifyTitle(" hi ")).toBe("hi");
27 expect(slugifyTitle("-hi-")).toBe("hi");
28 });
29
30 it("returns empty string when nothing usable", () => {
31 expect(slugifyTitle("")).toBe("");
32 expect(slugifyTitle("***")).toBe("");
33 });
34});
35
36describe("wikis — route smoke", () => {
37 it("GET /:owner/:repo/wiki on missing repo → 404", async () => {
38 const res = await app.request("/nobody/missing/wiki");
39 expect(res.status).toBe(404);
40 });
41
42 it("GET /:owner/:repo/wiki/new without auth → 302 /login", async () => {
43 const res = await app.request("/any/repo/wiki/new");
44 expect(res.status).toBe(302);
45 expect(res.headers.get("location") || "").toMatch(/^\/login/);
46 });
47
48 it("POST /:owner/:repo/wiki without auth → 302 /login", async () => {
49 const res = await app.request("/any/repo/wiki", {
50 method: "POST",
51 body: new URLSearchParams({ title: "Home", body: "welcome" }),
52 headers: { "content-type": "application/x-www-form-urlencoded" },
53 });
54 expect(res.status).toBe(302);
55 expect(res.headers.get("location") || "").toMatch(/^\/login/);
56 });
57
58 it("POST edit without auth → 302 /login", async () => {
59 const res = await app.request("/any/repo/wiki/home/edit", {
60 method: "POST",
61 body: new URLSearchParams({ title: "x", body: "y" }),
62 headers: { "content-type": "application/x-www-form-urlencoded" },
63 });
64 expect(res.status).toBe(302);
65 expect(res.headers.get("location") || "").toMatch(/^\/login/);
66 });
67
68 it("POST delete without auth → 302 /login", async () => {
69 const res = await app.request("/any/repo/wiki/home/delete", {
70 method: "POST",
71 });
72 expect(res.status).toBe(302);
73 expect(res.headers.get("location") || "").toMatch(/^\/login/);
74 });
75
76 it("POST revert without auth → 302 /login", async () => {
77 const res = await app.request("/any/repo/wiki/home/revert/1", {
78 method: "POST",
79 });
80 expect(res.status).toBe(302);
81 expect(res.headers.get("location") || "").toMatch(/^\/login/);
82 });
83});
Addedsrc/__tests__/workflows.test.ts+186−0View fileUnifiedSplit
1/**
2 * Tests for Block C1 — Actions-equivalent workflow runner.
3 *
4 * Covers the pure-function parser + route-level unauthed guards. The
5 * shell-executor itself is exercised by higher-level integration tests
6 * once a real test DB is wired — for now we only verify that the
7 * exported surface exists and the route shell is correct.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import { parseWorkflow } from "../lib/workflow-parser";
13
14describe("workflow parser (C1)", () => {
15 it("parses a minimal workflow", () => {
16 const result = parseWorkflow(`name: CI
17on: [push]
18jobs:
19 test:
20 runs-on: default
21 steps:
22 - run: echo hello
23`);
24 expect(result.ok).toBe(true);
25 if (!result.ok) return;
26 expect(result.workflow.name).toBe("CI");
27 expect(result.workflow.on).toContain("push");
28 expect(result.workflow.jobs).toHaveLength(1);
29 expect(result.workflow.jobs[0].name).toBe("test");
30 expect(result.workflow.jobs[0].steps).toHaveLength(1);
31 expect(result.workflow.jobs[0].steps[0].run).toBe("echo hello");
32 });
33
34 it("handles scalar 'on' trigger", () => {
35 const result = parseWorkflow(`name: scalar
36on: push
37jobs:
38 test:
39 steps:
40 - run: pwd
41`);
42 expect(result.ok).toBe(true);
43 if (!result.ok) return;
44 expect(result.workflow.on).toEqual(["push"]);
45 });
46
47 it("handles list 'on' triggers", () => {
48 const result = parseWorkflow(`name: multi
49on: [push, pull_request]
50jobs:
51 a:
52 steps:
53 - run: true
54`);
55 expect(result.ok).toBe(true);
56 if (!result.ok) return;
57 expect(result.workflow.on).toContain("push");
58 expect(result.workflow.on).toContain("pull_request");
59 });
60
61 it("auto-names steps that only have a run field", () => {
62 const result = parseWorkflow(`name: n
63on: [push]
64jobs:
65 test:
66 steps:
67 - run: echo x
68`);
69 expect(result.ok).toBe(true);
70 if (!result.ok) return;
71 expect(result.workflow.jobs[0].steps[0].name).toBeTruthy();
72 });
73
74 it("preserves explicit step names", () => {
75 const result = parseWorkflow(`name: n
76on: [push]
77jobs:
78 test:
79 steps:
80 - name: Install
81 run: bun install
82 - name: Test
83 run: bun test
84`);
85 expect(result.ok).toBe(true);
86 if (!result.ok) return;
87 const names = result.workflow.jobs[0].steps.map((s) => s.name);
88 expect(names).toContain("Install");
89 expect(names).toContain("Test");
90 });
91
92 it("defaults runs-on to 'default' when omitted", () => {
93 const result = parseWorkflow(`name: n
94on: [push]
95jobs:
96 test:
97 steps:
98 - run: true
99`);
100 expect(result.ok).toBe(true);
101 if (!result.ok) return;
102 expect(result.workflow.jobs[0].runsOn).toBe("default");
103 });
104
105 it("rejects workflows with no 'on' trigger", () => {
106 const result = parseWorkflow(`name: bad
107jobs:
108 test:
109 steps:
110 - run: true
111`);
112 expect(result.ok).toBe(false);
113 if (result.ok) return;
114 expect(result.error.toLowerCase()).toContain("on");
115 });
116
117 it("rejects workflows with no jobs", () => {
118 const result = parseWorkflow(`name: bad
119on: [push]
120`);
121 expect(result.ok).toBe(false);
122 });
123
124 it("rejects jobs with no steps", () => {
125 const result = parseWorkflow(`name: bad
126on: [push]
127jobs:
128 test:
129 runs-on: default
130`);
131 expect(result.ok).toBe(false);
132 });
133
134 it("rejects steps without a 'run' command", () => {
135 const result = parseWorkflow(`name: bad
136on: [push]
137jobs:
138 test:
139 steps:
140 - name: no-op
141`);
142 expect(result.ok).toBe(false);
143 });
144
145 it("returns a default name when 'name' is missing", () => {
146 const result = parseWorkflow(`on: [push]
147jobs:
148 test:
149 steps:
150 - run: true
151`);
152 expect(result.ok).toBe(true);
153 if (!result.ok) return;
154 expect(typeof result.workflow.name).toBe("string");
155 expect(result.workflow.name.length).toBeGreaterThan(0);
156 });
157
158 it("never throws on malformed input", () => {
159 const inputs = ["", " ", "not:\nyaml\n-\n:", "{]}", "jobs: oh no"];
160 for (const i of inputs) {
161 expect(() => parseWorkflow(i)).not.toThrow();
162 }
163 });
164});
165
166describe("workflow routes (C1) — unauthed behaviour", () => {
167 it("POST /:owner/:repo/actions/:workflowId/run requires auth", async () => {
168 const res = await app.request("/alice/project/actions/abc/run", {
169 method: "POST",
170 headers: { "content-type": "application/x-www-form-urlencoded" },
171 body: "",
172 });
173 // Either a redirect to /login (repo exists, auth required), or 404
174 // (repo doesn't exist in DB-less tests), or 503 on DB failure.
175 expect([301, 302, 303, 307, 404, 503]).toContain(res.status);
176 });
177
178 it("POST /:owner/:repo/actions/runs/:id/cancel requires auth", async () => {
179 const res = await app.request("/alice/project/actions/runs/xyz/cancel", {
180 method: "POST",
181 headers: { "content-type": "application/x-www-form-urlencoded" },
182 body: "",
183 });
184 expect([301, 302, 303, 307, 404, 503]).toContain(res.status);
185 });
186});
Modifiedsrc/app.tsx+131−2View fileUnifiedSplit
11import { Hono } from "hono";
22import { logger } from "hono/logger";
33import { cors } from "hono/cors";
4import { compress } from "hono/compress";
45import { Layout } from "./views/layout";
6import { requestContext } from "./middleware/request-context";
7import { rateLimit } from "./middleware/rate-limit";
58import gitRoutes from "./routes/git";
69import apiRoutes from "./routes/api";
710import apiV2Routes from "./routes/api-v2";
811import apiDocsRoutes from "./routes/api-docs";
912import authRoutes from "./routes/auth";
1013import settingsRoutes from "./routes/settings";
14import settings2faRoutes from "./routes/settings-2fa";
1115import issueRoutes from "./routes/issues";
1216import repoSettings from "./routes/repo-settings";
1317import compareRoutes from "./routes/compare";
2731
2832const app = new Hono();
2933
30// Middleware
31app.use("*", logger());
34// Request context (request ID, start time) runs before everything else
35app.use("*", requestContext);
36// Middleware — compression first (wraps all responses)
37app.use("*", compress());
38// Logger only on non-git routes to avoid overhead on clone/push
39app.use("*", async (c, next) => {
40 if (c.req.path.includes(".git/")) return next();
41 return logger()(c, next);
42});
3243app.use("/api/*", cors());
44// Rate-limit API + auth endpoints (generous default)
45app.use("/api/*", rateLimit({ windowMs: 60_000, max: 120 }));
46app.use("/login", rateLimit({ windowMs: 60_000, max: 20 }));
47app.use("/register", rateLimit({ windowMs: 60_000, max: 10 }));
3348
3449// CSRF protection — set token on all requests, validate on mutations
3550app.use("*", csrfToken);
6479// Settings routes (profile, SSH keys)
6580app.route("/", settingsRoutes);
6681
82// 2FA / TOTP settings (Block B4)
83app.route("/", settings2faRoutes);
84
85// WebAuthn / passkey routes (Block B5)
86app.route("/", passkeyRoutes);
87
88// OAuth 2.0 provider (Block B6)
89app.route("/", oauthRoutes);
90app.route("/", developerAppsRoutes);
91
92// Theme toggle (dark/light cookie)
93app.route("/", themeRoutes);
94
95// Audit log UI
96app.route("/", auditRoutes);
97
98// Reactions API (issues, PRs, comments)
99app.route("/", reactionRoutes);
100
101// Saved replies (per-user canned comment templates)
102app.route("/", savedReplyRoutes);
103
104// Environments + deployment history UI
105app.route("/", deploymentRoutes);
106
107// Organizations + teams (Block B1)
108app.route("/", orgRoutes);
109
67110// API tokens
68111app.route("/", tokenRoutes);
69112
97140// Contributors
98141app.route("/", contributorRoutes);
99142
143// Releases
144app.route("/", releaseRoutes);
145
146// Gates (history + settings + branch protection)
147app.route("/", gateRoutes);
148
149// Actions-equivalent workflow runner (Block C1)
150app.route("/", workflowRoutes);
151
152// Package registry — npm protocol + UI (Block C2)
153app.route("/", packagesApiRoutes);
154app.route("/", packagesUiRoutes);
155
156// Pages / static hosting (Block C3)
157app.route("/", pagesRoutes);
158
159// Environments with protected approvals (Block C4)
160app.route("/", environmentsRoutes);
161
162// AI-native features (Block D)
163app.route("/", aiExplainRoutes); // D6 — /:owner/:repo/explain
164app.route("/", aiChangelogRoutes); // D7 — /:owner/:repo/ai/changelog
165app.route("/", copilotRoutes); // D9 — /api/copilot/completions
166app.route("/", depUpdaterRoutes); // D2 — /:owner/:repo/settings/dep-updater
167app.route("/", semanticSearchRoutes); // D1 — /:owner/:repo/search/semantic
168app.route("/", aiTestsRoutes); // D8 — /:owner/:repo/ai/tests
169app.route("/", discussionRoutes); // E2 — /:owner/:repo/discussions
170app.route("/", gistRoutes); // E4 — /gists, /gists/:slug, /:user/gists
171app.route("/", projectRoutes); // E1 — /:owner/:repo/projects
172app.route("/", wikiRoutes); // E3 — /:owner/:repo/wiki
173app.route("/", mergeQueueRoutes); // E5 — /:owner/:repo/queue
174app.route("/", requiredChecksRoutes); // E6 — /:owner/:repo/gates/protection/:id/checks
175app.route("/", protectedTagsRoutes); // E7 — /:owner/:repo/settings/protected-tags
176app.route("/", trafficRoutes); // F1 — /:owner/:repo/traffic
177app.route("/", orgInsightsRoutes); // F2 — /orgs/:slug/insights
178app.route("/", adminRoutes); // F3 — /admin
179app.route("/", billingRoutes); // F4 — /settings/billing + /admin/billing
180
181// PWA — manifest + service worker + icon (Block G1)
182app.route("/", pwaRoutes);
183
184// GraphQL mirror of REST (Block G2)
185app.route("/", graphqlRoutes);
186
187// Marketplace + app installations + bot identities (Block H1 + H2)
188app.route("/", marketplaceRoutes);
189
190// Template repositories — POST /:owner/:repo/use-template (Block I2)
191app.route("/", templatesRoutes);
192
193// Code scanning UI — /:owner/:repo/security (Block I5)
194app.route("/", codeScanningRoutes);
195
196// Sponsors — /sponsors/:user + /settings/sponsors (Block I6)
197app.route("/", sponsorsRoutes);
198
199// Symbol / xref navigation — /:owner/:repo/symbols (Block I8)
200app.route("/", symbolsRoutes);
201
202// Repository mirroring — /:owner/:repo/settings/mirror (Block I9)
203app.route("/", mirrorsRoutes);
204
205// Enterprise SSO via OIDC — /admin/sso + /login/sso (Block I10)
206app.route("/", ssoRoutes);
207
208// Dependency graph — /:owner/:repo/dependencies (Block J1)
209app.route("/", depsRoutes);
210
211// Security advisories / dependabot alerts — /:owner/:repo/security/advisories (Block J2)
212app.route("/", advisoriesRoutes);
213
214// Commit signature verification / signing keys — /settings/signing-keys (Block J3)
215app.route("/", signingKeysRoutes);
216
217// User following + personalised feed (Block J4)
218app.route("/", followsRoutes);
219
220// Repository rulesets — /:owner/:repo/settings/rulesets (Block J6)
221app.route("/", rulesetsRoutes);
222
223// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
224app.route("/", commitStatusesRoutes);
225
226// Insights + milestones
227app.route("/", insightsRoutes);
228
100229// Explore page
101230app.route("/", exploreRoutes);
102231
Modifiedsrc/db/migrate.ts+83−6View fileUnifiedSplit
1/**
2 * Migration runner — executes every `drizzle/*.sql` file in order.
3 * Tracks applied migrations in a dedicated table so repeat runs are idempotent.
4 */
5
6import { readdir, readFile } from "fs/promises";
7import { join } from "path";
18import { neon } from "@neondatabase/serverless";
2import { drizzle } from "drizzle-orm/neon-http";
3import { migrate } from "drizzle-orm/neon-http/migrator";
49import { config } from "../lib/config";
510
611async function runMigrations() {
12 if (!config.databaseUrl) {
13 throw new Error("DATABASE_URL is not set");
14 }
715 const sql = neon(config.databaseUrl);
8 const db = drizzle(sql);
9 console.log("Running migrations...");
10 await migrate(db, { migrationsFolder: "./drizzle" });
11 console.log("Migrations complete.");
16
17 // Ensure tracking table exists
18 await sql`
19 CREATE TABLE IF NOT EXISTS "_migrations" (
20 "name" text PRIMARY KEY,
21 "applied_at" timestamp DEFAULT now() NOT NULL
22 )
23 `;
24
25 const applied = await sql`SELECT name FROM _migrations`;
26 const appliedSet = new Set(
27 (applied as Array<{ name: string }>).map((r) => r.name)
28 );
29
30 const migrationsDir = join(process.cwd(), "drizzle");
31 const files = (await readdir(migrationsDir))
32 .filter((f) => f.endsWith(".sql"))
33 .sort();
34
35 if (files.length === 0) {
36 console.log("No migration files found.");
37 return;
38 }
39
40 for (const file of files) {
41 if (appliedSet.has(file)) {
42 console.log(`[migrate] ${file} — already applied, skipping`);
43 continue;
44 }
45
46 console.log(`[migrate] applying ${file}...`);
47 const content = await readFile(join(migrationsDir, file), "utf8");
48
49 // Split on the drizzle breakpoint marker. Each chunk is one statement.
50 const statements = content
51 .split(/-->\s*statement-breakpoint/)
52 .map((s) => s.trim())
53 .filter((s) => s.length > 0);
54
55 for (const raw of statements) {
56 const stripped = raw
57 .split("\n")
58 .filter((line) => !line.trim().startsWith("--"))
59 .join("\n")
60 .trim();
61 if (!stripped) continue;
62
63 try {
64 await sql(stripped);
65 } catch (err) {
66 const msg = err instanceof Error ? err.message : String(err);
67 if (
68 msg.includes("already exists") ||
69 msg.includes("duplicate_column") ||
70 msg.includes("duplicate_object")
71 ) {
72 console.warn(
73 `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)`
74 );
75 continue;
76 }
77 console.error(
78 `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}`
79 );
80 throw err;
81 }
82 }
83
84 await sql`INSERT INTO _migrations (name) VALUES (${file})`;
85 console.log(`[migrate] ${file} applied`);
86 }
87
88 console.log("[migrate] all migrations complete");
1289}
1390
1491runMigrations().catch((err) => {
Modifiedsrc/db/schema.ts+2070−1View fileUnifiedSplit
Large file (2,109 lines). Load full file
Modifiedsrc/git/repository.ts+173−51View fileUnifiedSplit
11import { join } from "path";
22import { mkdir } from "fs/promises";
33import { config } from "../lib/config";
4import { gitCache, cached } from "../lib/cache";
45
56export interface GitCommit {
67 sha: string;
8788 owner: string,
8889 name: string
8990): Promise<string[]> {
90 const path = repoPath(owner, name);
91 const { stdout, exitCode } = await exec(
92 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
93 { cwd: path }
94 );
95 if (exitCode !== 0) return [];
96 return stdout.trim().split("\n").filter(Boolean);
91 return cached(gitCache as any, `${owner}/${name}:branches`, async () => {
92 const path = repoPath(owner, name);
93 const { stdout, exitCode } = await exec(
94 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
95 { cwd: path }
96 );
97 if (exitCode !== 0) return [];
98 return stdout.trim().split("\n").filter(Boolean);
99 });
97100}
98101
99102export async function getDefaultBranch(
100103 owner: string,
101104 name: string
102105): Promise<string | null> {
103 const path = repoPath(owner, name);
104 const { stdout, exitCode } = await exec(
105 ["git", "symbolic-ref", "--short", "HEAD"],
106 { cwd: path }
107 );
108 if (exitCode !== 0) return null;
109 return stdout.trim() || null;
106 return cached(gitCache as any, `${owner}/${name}:defaultBranch`, async () => {
107 const path = repoPath(owner, name);
108 const { stdout, exitCode } = await exec(
109 ["git", "symbolic-ref", "--short", "HEAD"],
110 { cwd: path }
111 );
112 if (exitCode !== 0) return null;
113 return stdout.trim() || null;
114 });
110115}
111116
112117export async function resolveRef(
123128 return stdout.trim();
124129}
125130
131/**
132 * List all tags (newest first by commit date).
133 * Returns array of { name, sha, date }.
134 */
135export async function listTags(
136 owner: string,
137 name: string
138): Promise<Array<{ name: string; sha: string; date: string }>> {
139 const path = repoPath(owner, name);
140 const { stdout, exitCode } = await exec(
141 [
142 "git",
143 "for-each-ref",
144 "--sort=-creatordate",
145 "--format=%(refname:short)%00%(objectname)%00%(creatordate:iso-strict)",
146 "refs/tags/",
147 ],
148 { cwd: path }
149 );
150 if (exitCode !== 0) return [];
151 return stdout
152 .trim()
153 .split("\n")
154 .filter(Boolean)
155 .map((line) => {
156 const [tname, sha, date] = line.split("\0");
157 return { name: tname, sha, date };
158 });
159}
160
161/**
162 * Create a lightweight git tag pointing at a commit.
163 */
164export async function createTag(
165 owner: string,
166 name: string,
167 tag: string,
168 sha: string,
169 annotation?: string
170): Promise<boolean> {
171 const path = repoPath(owner, name);
172 const args = annotation
173 ? ["git", "tag", "-a", tag, sha, "-m", annotation]
174 : ["git", "tag", tag, sha];
175 const { exitCode } = await exec(args, { cwd: path });
176 return exitCode === 0;
177}
178
179/**
180 * Delete a tag.
181 */
182export async function deleteTag(
183 owner: string,
184 name: string,
185 tag: string
186): Promise<boolean> {
187 const path = repoPath(owner, name);
188 const { exitCode } = await exec(["git", "tag", "-d", tag], { cwd: path });
189 return exitCode === 0;
190}
191
192/**
193 * List commits between two refs (excluding `from`, including `to`).
194 */
195export async function commitsBetween(
196 owner: string,
197 name: string,
198 from: string | null,
199 to: string
200): Promise<GitCommit[]> {
201 const path = repoPath(owner, name);
202 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
203 const range = from ? `${from}..${to}` : to;
204 const { stdout, exitCode } = await exec(
205 ["git", "log", `--format=${format}`, "-500", range],
206 { cwd: path }
207 );
208 if (exitCode !== 0) return [];
209 return stdout
210 .trim()
211 .split("\n")
212 .filter(Boolean)
213 .map((line) => {
214 const [sha, message, author, authorEmail, date, parents] = line.split("\0");
215 return {
216 sha,
217 message,
218 author,
219 authorEmail,
220 date,
221 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
222 };
223 });
224}
225
126226export async function getCommit(
127227 owner: string,
128228 name: string,
167267 limit = 30,
168268 offset = 0
169269): Promise<GitCommit[]> {
170 const path = repoPath(owner, name);
171 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
172 const { stdout, exitCode } = await exec(
173 [
174 "git",
175 "log",
176 `--format=${format}`,
177 `--skip=${offset}`,
178 `-${limit}`,
179 ref,
180 ],
181 { cwd: path }
182 );
183 if (exitCode !== 0) return [];
184 return stdout
185 .trim()
186 .split("\n")
187 .filter(Boolean)
188 .map((line) => {
189 const [sha, message, author, authorEmail, date, parents] =
190 line.split("\0");
191 return {
192 sha,
193 message,
194 author,
195 authorEmail,
196 date,
197 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
198 };
199 });
270 return cached(gitCache as any, `${owner}/${name}:commits:${ref}:${limit}:${offset}`, async () => {
271 const path = repoPath(owner, name);
272 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
273 const { stdout, exitCode } = await exec(
274 [
275 "git",
276 "log",
277 `--format=${format}`,
278 `--skip=${offset}`,
279 `-${limit}`,
280 ref,
281 ],
282 { cwd: path }
283 );
284 if (exitCode !== 0) return [];
285 return stdout
286 .trim()
287 .split("\n")
288 .filter(Boolean)
289 .map((line) => {
290 const [sha, message, author, authorEmail, date, parents] =
291 line.split("\0");
292 return {
293 sha,
294 message,
295 author,
296 authorEmail,
297 date,
298 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
299 };
300 });
301 });
200302}
201303
202304export async function getTree(
389491 return new Uint8Array(data);
390492}
391493
494/**
495 * Return the raw commit object (`git cat-file commit <sha>`) including any
496 * `gpgsig` / `gpgsig-sha256` headers. Used by Block J3 signature verification.
497 */
498export async function getRawCommitObject(
499 owner: string,
500 name: string,
501 sha: string
502): Promise<string | null> {
503 const path = repoPath(owner, name);
504 const { stdout, exitCode } = await exec(
505 ["git", "cat-file", "commit", sha],
506 { cwd: path }
507 );
508 if (exitCode !== 0) return null;
509 return stdout;
510}
511
392512export async function searchCode(
393513 owner: string,
394514 name: string,
430550 name: string,
431551 ref: string
432552): Promise<string | null> {
433 const tree = await getTree(owner, name, ref);
434 const readme = tree.find((e) =>
435 /^readme(\.(md|txt|rst))?$/i.test(e.name)
436 );
437 if (!readme) return null;
438 const blob = await getBlob(owner, name, ref, readme.name);
439 return blob?.content || null;
553 return cached(gitCache as any, `${owner}/${name}:readme:${ref}`, async () => {
554 const tree = await getTree(owner, name, ref);
555 const readme = tree.find((e) =>
556 /^readme(\.(md|txt|rst))?$/i.test(e.name)
557 );
558 if (!readme) return null;
559 const blob = await getBlob(owner, name, ref, readme.name);
560 return blob?.content || null;
561 });
440562}
Modifiedsrc/hooks/post-receive.ts+429−36View fileUnifiedSplit
11/**
22 * Post-receive hook logic.
3 * Called after a successful git push to trigger GateTest scans
4 * and Crontech deploys.
3 * Runs after a successful git push.
4 *
5 * 1. Update repo.pushedAt and push activity
6 * 2. Sync CODEOWNERS from the default branch
7 * 3. Run gates (GateTest + secret + security) on the new ref
8 * 4. Auto-deploy to Crontech ONLY if gates are green and settings allow it
9 * 5. Fan out webhooks
510 */
611
12import { and, eq } from "drizzle-orm";
713import { config } from "../lib/config";
14import { db } from "../db";
15import {
16 activityFeed,
17 deployments,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import {
23 runGateTestScan,
24 runSecretAndSecurityScan,
25} from "../lib/gate";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getBlob, getDefaultBranch, getTree } from "../git/repository";
28import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify, audit } from "../lib/notify";
30import { workflows, pagesSettings } from "../db/schema";
31import { parseWorkflow } from "../lib/workflow-parser";
32import { enqueueRun } from "../lib/workflow-runner";
33import { onPagesPush } from "../lib/pages";
34import { requiresApprovalFor } from "../lib/environments";
35import { onDeployFailure } from "../lib/ai-incident";
36import { matchProtectedTag } from "../lib/protected-tags";
837
938interface PushRef {
1039 oldSha: string;
1746 repo: string,
1847 refs: PushRef[]
1948): Promise<void> {
20 const promises: Promise<void>[] = [];
49 const [ownerRow] = await db
50 .select()
51 .from(users)
52 .where(eq(users.username, owner))
53 .limit(1);
54 const repoRow = ownerRow
55 ? (
56 await db
57 .select()
58 .from(repositories)
59 .where(
60 and(
61 eq(repositories.ownerId, ownerRow.id),
62 eq(repositories.name, repo)
63 )
64 )
65 .limit(1)
66 )[0]
67 : null;
68
69 const defaultBranch =
70 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
2171
22 // GateTest scan on every push
23 promises.push(triggerGateTest(owner, repo, refs));
72 // --- 1. pushedAt + activity ---
73 if (repoRow) {
74 try {
75 await db
76 .update(repositories)
77 .set({ pushedAt: new Date(), updatedAt: new Date() })
78 .where(eq(repositories.id, repoRow.id));
79 for (const ref of refs) {
80 if (!ref.newSha.startsWith("0000")) {
81 await db.insert(activityFeed).values({
82 repositoryId: repoRow.id,
83 userId: ownerRow?.id || null,
84 action: "push",
85 targetType: "commit",
86 targetId: ref.newSha,
87 metadata: JSON.stringify({ ref: ref.refName }),
88 });
89 }
90 }
91 } catch (err) {
92 console.error("[post-receive] activity/pushedAt:", err);
93 }
94 }
2495
25 // Crontech deploy on push to main
26 const mainPush = refs.find(
27 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
96 // --- 1b. Protected-tag advisory logging (Block E7) ---
97 // v1 is non-blocking: we log violations to the audit log and fan a notify
98 // event out to the repo owner. Actual pre-receive blocking is future work.
99 if (repoRow) {
100 for (const ref of refs) {
101 if (!ref.refName.startsWith("refs/tags/")) continue;
102 const rule = await matchProtectedTag(repoRow.id, ref.refName);
103 if (!rule) continue;
104 const isDelete = ref.newSha.startsWith("0000");
105 const isCreate = ref.oldSha.startsWith("0000");
106 const action = isDelete
107 ? "delete"
108 : isCreate
109 ? "create"
110 : "update";
111 try {
112 await audit({
113 userId: ownerRow?.id || null,
114 repositoryId: repoRow.id,
115 action: `protected_tags.${action}_violation_candidate`,
116 targetType: "ref",
117 targetId: ref.refName,
118 metadata: {
119 pattern: rule.pattern,
120 oldSha: ref.oldSha,
121 newSha: ref.newSha,
122 },
123 });
124 } catch {}
125 }
126 }
127
128 // --- 2. CODEOWNERS sync (only when default branch changed) ---
129 const mainRef = refs.find(
130 (r) =>
131 r.refName === `refs/heads/${defaultBranch}` &&
132 !r.newSha.startsWith("0000")
28133 );
29 if (mainPush) {
30 promises.push(triggerCrontechDeploy(owner, repo, mainPush.newSha));
134 if (mainRef && repoRow) {
135 try {
136 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
137 for (const p of paths) {
138 const blob = await getBlob(owner, repo, defaultBranch, p);
139 if (blob && !blob.isBinary) {
140 const rules = parseCodeowners(blob.content);
141 await syncCodeowners(repoRow.id, rules);
142 break;
143 }
144 }
145 } catch (err) {
146 console.error("[post-receive] codeowners sync:", err);
147 }
148 }
149
150 // --- 2b. Workflow sync + trigger (Block C1) ---
151 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
152 // upsert them in the workflows table, and enqueue a run for each workflow
153 // whose `on` triggers include `push`.
154 if (mainRef && repoRow) {
155 try {
156 const entries = await getTree(
157 owner,
158 repo,
159 defaultBranch,
160 ".gluecron/workflows"
161 );
162 const existing = await db
163 .select({ id: workflows.id, path: workflows.path })
164 .from(workflows)
165 .where(eq(workflows.repositoryId, repoRow.id));
166 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
167 const seenPaths = new Set<string>();
168
169 for (const entry of entries) {
170 if (entry.type !== "blob") continue;
171 if (!/\.ya?ml$/i.test(entry.name)) continue;
172 const path = `.gluecron/workflows/${entry.name}`;
173 seenPaths.add(path);
174 const blob = await getBlob(owner, repo, defaultBranch, path);
175 if (!blob || blob.isBinary) continue;
176 const parsed = parseWorkflow(blob.content);
177 if (!parsed.ok) {
178 console.error(
179 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
180 );
181 continue;
182 }
183 const onEvents = JSON.stringify(parsed.workflow.on);
184 const parsedJson = JSON.stringify(parsed.workflow);
185 const existingId = existingByPath.get(path);
186 let workflowId: string;
187 if (existingId) {
188 await db
189 .update(workflows)
190 .set({
191 name: parsed.workflow.name,
192 yaml: blob.content,
193 parsed: parsedJson,
194 onEvents,
195 updatedAt: new Date(),
196 })
197 .where(eq(workflows.id, existingId));
198 workflowId = existingId;
199 } else {
200 const [row] = await db
201 .insert(workflows)
202 .values({
203 repositoryId: repoRow.id,
204 name: parsed.workflow.name,
205 path,
206 yaml: blob.content,
207 parsed: parsedJson,
208 onEvents,
209 })
210 .returning({ id: workflows.id });
211 workflowId = row.id;
212 }
213
214 // Enqueue a run if this workflow subscribes to the push event.
215 if (parsed.workflow.on.includes("push")) {
216 try {
217 await enqueueRun({
218 workflowId,
219 repositoryId: repoRow.id,
220 event: "push",
221 ref: mainRef.refName,
222 commitSha: mainRef.newSha,
223 triggeredBy: ownerRow?.id || null,
224 });
225 } catch (err) {
226 console.error(
227 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
228 err
229 );
230 }
231 }
232 }
233
234 // Mark workflows whose files have been removed as disabled (soft-delete).
235 for (const [p, id] of existingByPath) {
236 if (!seenPaths.has(p)) {
237 await db
238 .update(workflows)
239 .set({ disabled: true, updatedAt: new Date() })
240 .where(eq(workflows.id, id));
241 }
242 }
243 } catch (err) {
244 console.error("[post-receive] workflow sync:", err);
245 }
246 }
247
248 // --- 2c. Pages (Block C3) ---
249 // On any push, if the ref matches the configured pages source branch,
250 // record a pages_deployments row. Fire-and-forget; onPagesPush never throws.
251 if (repoRow) {
252 let pagesBranch = "gh-pages";
253 try {
254 const [pSettings] = await db
255 .select()
256 .from(pagesSettings)
257 .where(eq(pagesSettings.repositoryId, repoRow.id))
258 .limit(1);
259 if (pSettings) {
260 if (pSettings.enabled === false) pagesBranch = "";
261 else pagesBranch = pSettings.sourceBranch || "gh-pages";
262 }
263 } catch {
264 /* fall back to default */
265 }
266
267 if (pagesBranch) {
268 for (const ref of refs) {
269 if (ref.newSha.startsWith("0000")) continue;
270 if (ref.refName === `refs/heads/${pagesBranch}`) {
271 void onPagesPush({
272 ownerLogin: owner,
273 repoName: repo,
274 repositoryId: repoRow.id,
275 ref: ref.refName,
276 newSha: ref.newSha,
277 triggeredByUserId: ownerRow?.id || null,
278 });
279 }
280 }
281 }
282 }
283
284 // --- 3. Gates ---
285 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
286
287 const promises: Promise<void>[] = [];
288 for (const ref of refs) {
289 if (ref.newSha.startsWith("0000")) continue;
290
291 if (settings?.gateTestEnabled !== false) {
292 promises.push(
293 runGateTestScan(owner, repo, ref.refName, ref.newSha)
294 .then((result) => {
295 console.log(
296 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"}${result.details}`
297 );
298 })
299 .catch((err) => {
300 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
301 })
302 );
303 }
304
305 if (
306 settings?.secretScanEnabled !== false ||
307 settings?.securityScanEnabled !== false
308 ) {
309 promises.push(
310 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
311 scanSecrets: settings?.secretScanEnabled !== false,
312 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
313 })
314 .then((result) => {
315 if (
316 !result.secretResult.passed &&
317 ownerRow &&
318 repoRow &&
319 result.secrets.length > 0
320 ) {
321 void notify(ownerRow.id, {
322 kind: "security_alert",
323 title: `Secret detected in ${owner}/${repo}`,
324 body: result.secretResult.details,
325 url: `/${owner}/${repo}/gates`,
326 repositoryId: repoRow.id,
327 });
328 }
329 })
330 .catch((err) => {
331 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
332 })
333 );
334 }
335 }
336
337 // --- 4. Auto-deploy (only on default branch + green settings) ---
338 // Block C4: if a "production" environment is configured with approval
339 // required, insert a pending_approval deployment row instead of firing.
340 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
341 const gate = await requiresApprovalFor(
342 repoRow.id,
343 "production",
344 mainRef.refName
345 ).catch(() => ({ required: false, env: null as null }));
346
347 if (gate.required && gate.env) {
348 try {
349 await db.insert(deployments).values({
350 repositoryId: repoRow.id,
351 environment: "production",
352 commitSha: mainRef.newSha,
353 ref: mainRef.refName,
354 status: "pending_approval",
355 target: "crontech",
356 blockedReason: `awaiting approval for environment '${gate.env.name}'`,
357 });
358 } catch (err) {
359 console.error("[post-receive] pending_approval insert:", err);
360 }
361 } else {
362 promises.push(
363 triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id)
364 );
365 }
366 }
367
368 // --- 5. Webhook fan-out ---
369 if (repoRow) {
370 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
31371 }
32372
33373 await Promise.allSettled(promises);
34374}
35375
36async function triggerGateTest(
376async function triggerCrontechDeploy(
37377 owner: string,
38378 repo: string,
39 refs: PushRef[]
379 sha: string,
380 repositoryId: string
40381): Promise<void> {
382 let deployId = "";
41383 try {
42 const response = await fetch(config.gatetestUrl, {
43 method: "POST",
44 headers: { "Content-Type": "application/json" },
45 body: JSON.stringify({
46 repository: `${owner}/${repo}`,
47 refs: refs.map((r) => ({
48 ref: r.refName,
49 before: r.oldSha,
50 after: r.newSha,
51 })),
52 source: "gluecron",
53 }),
54 });
55 console.log(
56 `[gatetest] scan triggered for ${owner}/${repo}: ${response.status}`
57 );
58 } catch (err) {
59 console.error(`[gatetest] failed to trigger scan:`, err);
384 const [row] = await db
385 .insert(deployments)
386 .values({
387 repositoryId,
388 environment: "production",
389 commitSha: sha,
390 ref: "refs/heads/main",
391 status: "pending",
392 target: "crontech",
393 })
394 .returning();
395 deployId = row?.id || "";
396 } catch {
397 /* ignore */
60398 }
61}
62399
63async function triggerCrontechDeploy(
64 owner: string,
65 repo: string,
66 sha: string
67): Promise<void> {
68400 try {
69401 const response = await fetch(config.crontechDeployUrl, {
70402 method: "POST",
79411 console.log(
80412 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
81413 );
414 if (deployId) {
415 await db
416 .update(deployments)
417 .set({
418 status: response.ok ? "success" : "failed",
419 completedAt: new Date(),
420 })
421 .where(eq(deployments.id, deployId));
422 }
423 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
424 // incident responder AFTER the deployment row is flipped to "failed".
425 if (!response.ok && deployId) {
426 void onDeployFailure({
427 repositoryId,
428 deploymentId: deployId,
429 ref: "refs/heads/main",
430 commitSha: sha,
431 target: "crontech",
432 errorMessage: `HTTP ${response.status}`,
433 }).catch((e) => console.error("[ai-incident]", e));
434 }
82435 } catch (err) {
83436 console.error(`[crontech] failed to trigger deploy:`, err);
437 if (deployId) {
438 await db
439 .update(deployments)
440 .set({
441 status: "failed",
442 blockedReason: (err as Error).message,
443 completedAt: new Date(),
444 })
445 .where(eq(deployments.id, deployId));
446 // D4: fire-and-forget incident analysis AFTER marking the row failed.
447 void onDeployFailure({
448 repositoryId,
449 deploymentId: deployId,
450 ref: "refs/heads/main",
451 commitSha: sha,
452 target: "crontech",
453 errorMessage: (err as Error).message,
454 }).catch((e) => console.error("[ai-incident]", e));
455 }
456 }
457}
458
459async function fanoutWebhooks(
460 repositoryId: string,
461 owner: string,
462 repo: string,
463 refs: PushRef[]
464): Promise<void> {
465 try {
466 const { fireWebhooks } = await import("../routes/webhooks");
467 await fireWebhooks(repositoryId, "push", {
468 repository: `${owner}/${repo}`,
469 refs: refs.map((r) => ({
470 ref: r.refName,
471 before: r.oldSha,
472 after: r.newSha,
473 })),
474 });
475 } catch {
476 // best-effort
84477 }
85478}
Modifiedsrc/index.ts+5−0View fileUnifiedSplit
11import { mkdir } from "fs/promises";
22import app from "./app";
33import { config } from "./lib/config";
4import { startWorker } from "./lib/workflow-runner";
45
56// Ensure repos directory exists
67await mkdir(config.gitReposPath, { recursive: true });
78
9// Start the Actions-equivalent workflow worker (Block C1). Polls
10// workflow_runs for queued rows and executes them sequentially.
11startWorker();
12
813console.log(`
914 gluecron v0.1.0
1015 ──────────────────────
Addedsrc/lib/admin.ts+139−0View fileUnifiedSplit
1/**
2 * Block F3 — Site admin helpers.
3 *
4 * A site admin is a user with a row in `site_admins`. If the table is empty,
5 * the very first registered user is the bootstrap admin. This mirrors how
6 * many self-hosted apps handle the first-install case without requiring
7 * `env`-based provisioning.
8 *
9 * Writes to system flags go through `setFlag` which records the writer.
10 */
11
12import { asc, eq } from "drizzle-orm";
13import { db } from "../db";
14import { siteAdmins, systemFlags, users } from "../db/schema";
15
16/**
17 * Is this user a site admin? Returns true if any of:
18 * - they have a row in `site_admins`, OR
19 * - no rows exist in `site_admins` and they are the oldest-created user
20 * (bootstrap rule).
21 */
22export async function isSiteAdmin(
23 userId: string | null | undefined
24): Promise<boolean> {
25 if (!userId) return false;
26 try {
27 const [row] = await db
28 .select({ userId: siteAdmins.userId })
29 .from(siteAdmins)
30 .where(eq(siteAdmins.userId, userId))
31 .limit(1);
32 if (row) return true;
33 // Bootstrap: empty site_admins → oldest user is admin.
34 const [anyAdmin] = await db.select().from(siteAdmins).limit(1);
35 if (anyAdmin) return false;
36 const [first] = await db
37 .select({ id: users.id })
38 .from(users)
39 .orderBy(asc(users.createdAt))
40 .limit(1);
41 return !!first && first.id === userId;
42 } catch {
43 return false;
44 }
45}
46
47export async function listSiteAdmins() {
48 try {
49 return await db
50 .select({
51 userId: siteAdmins.userId,
52 username: users.username,
53 grantedAt: siteAdmins.grantedAt,
54 grantedBy: siteAdmins.grantedBy,
55 })
56 .from(siteAdmins)
57 .innerJoin(users, eq(siteAdmins.userId, users.id));
58 } catch {
59 return [];
60 }
61}
62
63export async function grantSiteAdmin(
64 userId: string,
65 grantedBy: string | null
66): Promise<boolean> {
67 try {
68 await db
69 .insert(siteAdmins)
70 .values({ userId, grantedBy: grantedBy || null })
71 .onConflictDoNothing();
72 return true;
73 } catch {
74 return false;
75 }
76}
77
78export async function revokeSiteAdmin(userId: string): Promise<boolean> {
79 try {
80 const res = await db
81 .delete(siteAdmins)
82 .where(eq(siteAdmins.userId, userId))
83 .returning({ userId: siteAdmins.userId });
84 return res.length > 0;
85 } catch {
86 return false;
87 }
88}
89
90export async function getFlag(key: string): Promise<string | null> {
91 try {
92 const [row] = await db
93 .select({ value: systemFlags.value })
94 .from(systemFlags)
95 .where(eq(systemFlags.key, key))
96 .limit(1);
97 return row?.value ?? null;
98 } catch {
99 return null;
100 }
101}
102
103export async function setFlag(
104 key: string,
105 value: string,
106 updatedBy: string | null
107): Promise<boolean> {
108 try {
109 await db
110 .insert(systemFlags)
111 .values({ key, value, updatedBy: updatedBy || null })
112 .onConflictDoUpdate({
113 target: systemFlags.key,
114 set: { value, updatedBy: updatedBy || null, updatedAt: new Date() },
115 });
116 return true;
117 } catch (err) {
118 console.error("[admin] setFlag:", err);
119 return false;
120 }
121}
122
123export async function listFlags() {
124 try {
125 return await db.select().from(systemFlags);
126 } catch {
127 return [];
128 }
129}
130
131/** Known flag keys with defaults (used by callers + UI rendering). */
132export const KNOWN_FLAGS = {
133 registration_locked: "0", // "1" to block new sign-ups
134 site_banner_text: "", // non-empty → show a banner at the top
135 site_banner_level: "info", // info | warn | error
136 read_only_mode: "0",
137} as const;
138
139export type FlagKey = keyof typeof KNOWN_FLAGS;
Addedsrc/lib/advisories.ts+546−0View fileUnifiedSplit
1/**
2 * Block J2 — Security advisories + per-repo alerts.
3 *
4 * This module does three things:
5 * 1. Provides a seeded set of well-known public advisories (log4j, lodash,
6 * minimist, etc.) that get inserted into `security_advisories` on first
7 * run.
8 * 2. Exposes a minimal version-range matcher (`rangeMatches`) that can tell
9 * whether a declared manifest spec like `"^1.2.0"` or `">=0.5"` falls
10 * inside an advisory's `affected_range` such as `"<1.2.3"`. This is a
11 * heuristic — we never claim to replace npm's full semver resolver, but
12 * for unpinned or tightly-pinned dev manifests (the common case on
13 * GitHub), it's accurate in the common cases.
14 * 3. `scanRepositoryForAlerts(repositoryId)` iterates the repo's
15 * `repo_dependencies` rows, finds matching advisories, and upserts
16 * `repo_advisory_alerts` rows. Existing alerts whose underlying dep
17 * went away are auto-closed (status=fixed).
18 */
19
20import { and, desc, eq, inArray } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repoAdvisoryAlerts,
24 repoDependencies,
25 securityAdvisories,
26 type RepoAdvisoryAlert,
27 type SecurityAdvisory,
28} from "../db/schema";
29
30// ----------------------------------------------------------------------------
31// Seed: a small but real set of widely-cited advisories
32// ----------------------------------------------------------------------------
33
34export interface SeedAdvisory {
35 ghsaId: string;
36 cveId?: string;
37 summary: string;
38 severity: "low" | "moderate" | "high" | "critical";
39 ecosystem: string;
40 packageName: string;
41 affectedRange: string;
42 fixedVersion?: string;
43 referenceUrl?: string;
44}
45
46export const SEED_ADVISORIES: SeedAdvisory[] = [
47 {
48 ghsaId: "GHSA-jfh8-c2jp-5v3q",
49 cveId: "CVE-2021-44228",
50 summary:
51 "Log4Shell: Apache Log4j2 JNDI features do not protect against attacker-controlled LDAP and other JNDI related endpoints",
52 severity: "critical",
53 ecosystem: "composer",
54 packageName: "apache/log4j",
55 affectedRange: ">=2.0 <2.15.0",
56 fixedVersion: "2.15.0",
57 referenceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2021-44228",
58 },
59 {
60 ghsaId: "GHSA-p6mc-m468-83gw",
61 cveId: "CVE-2019-10744",
62 summary: "Prototype pollution in lodash",
63 severity: "high",
64 ecosystem: "npm",
65 packageName: "lodash",
66 affectedRange: "<4.17.12",
67 fixedVersion: "4.17.12",
68 referenceUrl: "https://github.com/advisories/GHSA-jf85-cpcp-j695",
69 },
70 {
71 ghsaId: "GHSA-vh95-rmgr-6w4m",
72 cveId: "CVE-2020-7598",
73 summary: "Prototype pollution in minimist",
74 severity: "moderate",
75 ecosystem: "npm",
76 packageName: "minimist",
77 affectedRange: "<1.2.2",
78 fixedVersion: "1.2.2",
79 referenceUrl: "https://github.com/advisories/GHSA-vh95-rmgr-6w4m",
80 },
81 {
82 ghsaId: "GHSA-hpx4-r86g-5jrg",
83 cveId: "CVE-2020-28469",
84 summary: "Regex DoS in glob-parent",
85 severity: "high",
86 ecosystem: "npm",
87 packageName: "glob-parent",
88 affectedRange: "<5.1.2",
89 fixedVersion: "5.1.2",
90 referenceUrl: "https://github.com/advisories/GHSA-ww39-953v-wcq6",
91 },
92 {
93 ghsaId: "GHSA-rxrx-xcr5-2f33",
94 cveId: "CVE-2022-25883",
95 summary: "Regex DoS in node-semver",
96 severity: "moderate",
97 ecosystem: "npm",
98 packageName: "semver",
99 affectedRange: "<5.7.2",
100 fixedVersion: "5.7.2",
101 referenceUrl: "https://github.com/advisories/GHSA-c2qf-rxjj-qqgw",
102 },
103 {
104 ghsaId: "GHSA-j8xg-fqg3-53r7",
105 cveId: "CVE-2022-24999",
106 summary: "Express qs prototype pollution",
107 severity: "high",
108 ecosystem: "npm",
109 packageName: "qs",
110 affectedRange: "<6.9.7",
111 fixedVersion: "6.9.7",
112 referenceUrl: "https://github.com/advisories/GHSA-hrpp-h998-j3pp",
113 },
114 {
115 ghsaId: "GHSA-6v2p-pv7g-wrx2",
116 cveId: "CVE-2022-36067",
117 summary: "vm2 sandbox escape",
118 severity: "critical",
119 ecosystem: "npm",
120 packageName: "vm2",
121 affectedRange: "<3.9.11",
122 fixedVersion: "3.9.11",
123 referenceUrl: "https://github.com/advisories/GHSA-6v2p-pv7g-wrx2",
124 },
125 {
126 ghsaId: "GHSA-mh63-6h87-95cp",
127 cveId: "CVE-2022-21222",
128 summary: "Prototype pollution in jquery.extend",
129 severity: "moderate",
130 ecosystem: "npm",
131 packageName: "jquery",
132 affectedRange: "<3.5.0",
133 fixedVersion: "3.5.0",
134 referenceUrl: "https://github.com/advisories/GHSA-gxr4-xjj5-5px2",
135 },
136 {
137 ghsaId: "GHSA-h52j-qf5x-j8ch",
138 cveId: "CVE-2021-33503",
139 summary: "urllib3 catastrophic backtracking regex",
140 severity: "high",
141 ecosystem: "pypi",
142 packageName: "urllib3",
143 affectedRange: "<1.26.5",
144 fixedVersion: "1.26.5",
145 referenceUrl: "https://github.com/advisories/GHSA-q2q7-5pp4-w6pg",
146 },
147 {
148 ghsaId: "GHSA-56pw-mpj4-fxww",
149 cveId: "CVE-2019-11236",
150 summary: "CRLF injection in urllib3",
151 severity: "moderate",
152 ecosystem: "pypi",
153 packageName: "urllib3",
154 affectedRange: "<1.24.2",
155 fixedVersion: "1.24.2",
156 referenceUrl: "https://github.com/advisories/GHSA-wqvq-5m8c-6g24",
157 },
158 {
159 ghsaId: "GHSA-w596-4wvx-j9j6",
160 cveId: "CVE-2021-33026",
161 summary: "Flask-Caching pickle deserialisation RCE",
162 severity: "high",
163 ecosystem: "pypi",
164 packageName: "flask-caching",
165 affectedRange: "<1.10.1",
166 fixedVersion: "1.10.1",
167 referenceUrl: "https://github.com/advisories/GHSA-xx7p-3c2j-8c7w",
168 },
169 {
170 ghsaId: "GHSA-5545-jx63-4mgx",
171 cveId: "CVE-2020-26160",
172 summary: "JWT-go incorrect type assertion allows auth bypass",
173 severity: "high",
174 ecosystem: "go",
175 packageName: "github.com/dgrijalva/jwt-go",
176 affectedRange: "<4.0.0",
177 fixedVersion: "4.0.0",
178 referenceUrl: "https://github.com/advisories/GHSA-w73w-5m7g-f7qc",
179 },
180];
181
182// ----------------------------------------------------------------------------
183// Version range matcher
184// ----------------------------------------------------------------------------
185
186/**
187 * Parse a dotted version string → numeric components. Non-numeric segments
188 * become 0 so "1.2.3-beta" < "1.2.3". Good enough for advisory comparisons.
189 */
190export function parseVersion(v: string): number[] {
191 const cleaned = (v || "").trim().replace(/^[\^~=v\s]+/, "");
192 if (!cleaned) return [0, 0, 0];
193 const stem = cleaned.split(/[+\-\s]/, 1)[0]; // strip prerelease / build
194 return stem
195 .split(".")
196 .map((p) => parseInt(p, 10))
197 .map((n) => (Number.isFinite(n) ? n : 0));
198}
199
200export function compareVersions(a: string, b: string): number {
201 const av = parseVersion(a);
202 const bv = parseVersion(b);
203 const len = Math.max(av.length, bv.length);
204 for (let i = 0; i < len; i++) {
205 const x = av[i] ?? 0;
206 const y = bv[i] ?? 0;
207 if (x !== y) return x - y;
208 }
209 return 0;
210}
211
212/** Satisfies "<1.2.3", ">=1.0.0", ">=1.0 <2.0", "=1.2.3", or bare "1.2.3". */
213export function satisfiesRange(version: string, range: string): boolean {
214 const clauses = range
215 .split(/\s+/)
216 .map((c) => c.trim())
217 .filter(Boolean);
218 if (clauses.length === 0) return false;
219 for (const clause of clauses) {
220 const m = clause.match(/^(<=|>=|<|>|=)?\s*(\S+)$/);
221 if (!m) return false;
222 const op = m[1] || "=";
223 const target = m[2];
224 const cmp = compareVersions(version, target);
225 let ok = false;
226 switch (op) {
227 case "<":
228 ok = cmp < 0;
229 break;
230 case "<=":
231 ok = cmp <= 0;
232 break;
233 case ">":
234 ok = cmp > 0;
235 break;
236 case ">=":
237 ok = cmp >= 0;
238 break;
239 case "=":
240 ok = cmp === 0;
241 break;
242 }
243 if (!ok) return false;
244 }
245 return true;
246}
247
248/**
249 * Extract a concrete version from a manifest spec:
250 * "^1.2.3" → "1.2.3"
251 * "~2.0.0" → "2.0.0"
252 * "v1.8.0" → "1.8.0"
253 * ">=1.0 <2.0" → "1.0" (lower bound — conservative)
254 * "1.2.3" → "1.2.3"
255 *
256 * Returns null if the spec has no concrete version (e.g. "*" or git URL).
257 */
258export function normalizeManifestVersion(spec: string | null): string | null {
259 if (!spec) return null;
260 const trimmed = spec.trim();
261 if (!trimmed || trimmed === "*" || trimmed === "latest") return null;
262 // Grab the first number-like token
263 const m = trimmed.match(/(\d+(?:\.\d+)*(?:\.\d+)?)/);
264 if (!m) return null;
265 return m[1];
266}
267
268/**
269 * True when a declared manifest spec overlaps with an advisory range.
270 * When the spec can't be pinned to a version, we still return true to
271 * surface the potential risk (false positives are safer than false
272 * negatives in a dependabot-style feature).
273 */
274export function rangeMatches(
275 manifestSpec: string | null,
276 affectedRange: string
277): boolean {
278 const normalized = normalizeManifestVersion(manifestSpec);
279 if (!normalized) {
280 // Can't resolve spec → conservatively match (no concrete version means
281 // the floating spec could resolve to anything).
282 return true;
283 }
284 return satisfiesRange(normalized, affectedRange);
285}
286
287// ----------------------------------------------------------------------------
288// Seeding
289// ----------------------------------------------------------------------------
290
291/**
292 * Inserts the hardcoded seed list if not already present, matched by
293 * `ghsa_id`. Safe to call on every boot — idempotent.
294 */
295export async function seedAdvisories(): Promise<{ inserted: number }> {
296 let inserted = 0;
297 for (const a of SEED_ADVISORIES) {
298 try {
299 const [existing] = await db
300 .select({ id: securityAdvisories.id })
301 .from(securityAdvisories)
302 .where(eq(securityAdvisories.ghsaId, a.ghsaId))
303 .limit(1);
304 if (existing) continue;
305 await db.insert(securityAdvisories).values({
306 ghsaId: a.ghsaId,
307 cveId: a.cveId ?? null,
308 summary: a.summary,
309 severity: a.severity,
310 ecosystem: a.ecosystem,
311 packageName: a.packageName,
312 affectedRange: a.affectedRange,
313 fixedVersion: a.fixedVersion ?? null,
314 referenceUrl: a.referenceUrl ?? null,
315 });
316 inserted++;
317 } catch (err) {
318 console.error("[advisories] seed:", err);
319 }
320 }
321 return { inserted };
322}
323
324// ----------------------------------------------------------------------------
325// Scan + alert upsert
326// ----------------------------------------------------------------------------
327
328export async function scanRepositoryForAlerts(
329 repositoryId: string
330): Promise<{ opened: number; matched: number; closed: number } | null> {
331 try {
332 const deps = await db
333 .select()
334 .from(repoDependencies)
335 .where(eq(repoDependencies.repositoryId, repositoryId));
336
337 if (deps.length === 0) {
338 // All prior alerts become fixed
339 const closed = await closeAllAlerts(repositoryId);
340 return { opened: 0, matched: 0, closed };
341 }
342
343 const ecosystems = Array.from(new Set(deps.map((d) => d.ecosystem)));
344 const advisories = await db
345 .select()
346 .from(securityAdvisories)
347 .where(inArray(securityAdvisories.ecosystem, ecosystems));
348
349 const existing = await db
350 .select()
351 .from(repoAdvisoryAlerts)
352 .where(eq(repoAdvisoryAlerts.repositoryId, repositoryId));
353 const existingByKey = new Map<string, RepoAdvisoryAlert>();
354 for (const e of existing) {
355 existingByKey.set(`${e.advisoryId}::${e.manifestPath}`, e);
356 }
357
358 let opened = 0;
359 let matched = 0;
360 const keepKeys = new Set<string>();
361
362 for (const dep of deps) {
363 for (const adv of advisories) {
364 if (adv.ecosystem !== dep.ecosystem) continue;
365 if (adv.packageName !== dep.name) continue;
366 if (!rangeMatches(dep.versionSpec, adv.affectedRange)) continue;
367 matched++;
368 const key = `${adv.id}::${dep.manifestPath}`;
369 keepKeys.add(key);
370 const prior = existingByKey.get(key);
371 if (prior) {
372 // Reopen if previously fixed; keep dismissed as dismissed.
373 if (prior.status === "fixed") {
374 await db
375 .update(repoAdvisoryAlerts)
376 .set({
377 status: "open",
378 dependencyVersion: dep.versionSpec ?? null,
379 updatedAt: new Date(),
380 })
381 .where(eq(repoAdvisoryAlerts.id, prior.id));
382 } else {
383 await db
384 .update(repoAdvisoryAlerts)
385 .set({
386 dependencyVersion: dep.versionSpec ?? null,
387 updatedAt: new Date(),
388 })
389 .where(eq(repoAdvisoryAlerts.id, prior.id));
390 }
391 } else {
392 await db.insert(repoAdvisoryAlerts).values({
393 repositoryId,
394 advisoryId: adv.id,
395 dependencyName: dep.name,
396 dependencyVersion: dep.versionSpec ?? null,
397 manifestPath: dep.manifestPath,
398 });
399 opened++;
400 }
401 }
402 }
403
404 // Close alerts whose dep + advisory combo is no longer present
405 let closed = 0;
406 for (const [key, prior] of existingByKey) {
407 if (keepKeys.has(key)) continue;
408 if (prior.status === "dismissed") continue;
409 if (prior.status === "fixed") continue;
410 await db
411 .update(repoAdvisoryAlerts)
412 .set({ status: "fixed", updatedAt: new Date() })
413 .where(eq(repoAdvisoryAlerts.id, prior.id));
414 closed++;
415 }
416
417 return { opened, matched, closed };
418 } catch (err) {
419 console.error("[advisories] scanRepositoryForAlerts:", err);
420 return null;
421 }
422}
423
424async function closeAllAlerts(repositoryId: string): Promise<number> {
425 try {
426 const existing = await db
427 .select()
428 .from(repoAdvisoryAlerts)
429 .where(
430 and(
431 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
432 eq(repoAdvisoryAlerts.status, "open")
433 )
434 );
435 if (existing.length === 0) return 0;
436 await db
437 .update(repoAdvisoryAlerts)
438 .set({ status: "fixed", updatedAt: new Date() })
439 .where(
440 and(
441 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
442 eq(repoAdvisoryAlerts.status, "open")
443 )
444 );
445 return existing.length;
446 } catch {
447 return 0;
448 }
449}
450
451export interface AlertWithAdvisory extends RepoAdvisoryAlert {
452 advisory: SecurityAdvisory;
453}
454
455export async function listAlertsForRepo(
456 repositoryId: string,
457 status: "open" | "dismissed" | "fixed" | "all" = "open"
458): Promise<AlertWithAdvisory[]> {
459 try {
460 const whereClauses =
461 status === "all"
462 ? eq(repoAdvisoryAlerts.repositoryId, repositoryId)
463 : and(
464 eq(repoAdvisoryAlerts.repositoryId, repositoryId),
465 eq(repoAdvisoryAlerts.status, status)
466 )!;
467 const rows = await db
468 .select({
469 alert: repoAdvisoryAlerts,
470 advisory: securityAdvisories,
471 })
472 .from(repoAdvisoryAlerts)
473 .innerJoin(
474 securityAdvisories,
475 eq(securityAdvisories.id, repoAdvisoryAlerts.advisoryId)
476 )
477 .where(whereClauses)
478 .orderBy(desc(repoAdvisoryAlerts.createdAt));
479 return rows.map((r) => ({ ...r.alert, advisory: r.advisory }));
480 } catch {
481 return [];
482 }
483}
484
485export async function dismissAlert(
486 alertId: string,
487 repositoryId: string,
488 reason: string
489): Promise<boolean> {
490 try {
491 const result = await db
492 .update(repoAdvisoryAlerts)
493 .set({
494 status: "dismissed",
495 dismissedReason: reason.slice(0, 280),
496 updatedAt: new Date(),
497 })
498 .where(
499 and(
500 eq(repoAdvisoryAlerts.id, alertId),
501 eq(repoAdvisoryAlerts.repositoryId, repositoryId)
502 )
503 )
504 .returning({ id: repoAdvisoryAlerts.id });
505 return result.length > 0;
506 } catch {
507 return false;
508 }
509}
510
511export async function reopenAlert(
512 alertId: string,
513 repositoryId: string
514): Promise<boolean> {
515 try {
516 const result = await db
517 .update(repoAdvisoryAlerts)
518 .set({
519 status: "open",
520 dismissedReason: null,
521 updatedAt: new Date(),
522 })
523 .where(
524 and(
525 eq(repoAdvisoryAlerts.id, alertId),
526 eq(repoAdvisoryAlerts.repositoryId, repositoryId)
527 )
528 )
529 .returning({ id: repoAdvisoryAlerts.id });
530 return result.length > 0;
531 } catch {
532 return false;
533 }
534}
535
536// ----------------------------------------------------------------------------
537// Test-only exports
538// ----------------------------------------------------------------------------
539
540export const __internal = {
541 parseVersion,
542 compareVersions,
543 satisfiesRange,
544 normalizeManifestVersion,
545 rangeMatches,
546};
Addedsrc/lib/ai-chat.ts+193−0View fileUnifiedSplit
1/**
2 * AI chat assistant — conversational interface grounded in repo context.
3 *
4 * The assistant can:
5 * - Answer questions about the codebase ("where is auth handled?")
6 * - Explain files / functions ("explain src/lib/gate.ts")
7 * - Draft code (answered verbally, user applies manually)
8 * - Summarise recent activity ("what changed this week?")
9 *
10 * Grounds its answers in a curated context window built from:
11 * - Repo README
12 * - Recent commits
13 * - Tree listing
14 * - Files the user explicitly @-mentions in the message
15 */
16
17import {
18 getAnthropic,
19 MODEL_SONNET,
20 extractText,
21 isAiAvailable,
22} from "./ai-client";
23import {
24 getReadme,
25 getTree,
26 getBlob,
27 listCommits,
28 getDefaultBranch,
29} from "../git/repository";
30
31export interface ChatMessage {
32 role: "user" | "assistant";
33 content: string;
34}
35
36export interface ChatResponse {
37 reply: string;
38 citedFiles: string[];
39}
40
41/**
42 * Build a concise repo context block.
43 * Keeps under ~60k chars to leave room for conversation.
44 */
45async function buildRepoContext(
46 owner: string,
47 repo: string,
48 mentionedFiles: string[]
49): Promise<{ context: string; files: string[] }> {
50 const branch = (await getDefaultBranch(owner, repo)) || "main";
51 const citedFiles: string[] = [];
52 const parts: string[] = [];
53
54 parts.push(`# Repository: ${owner}/${repo}\nDefault branch: ${branch}\n`);
55
56 // README
57 const readme = await getReadme(owner, repo, branch);
58 if (readme) {
59 parts.push(`## README\n${readme.slice(0, 8000)}\n`);
60 }
61
62 // Top-level tree
63 const tree = await getTree(owner, repo, branch);
64 if (tree.length > 0) {
65 parts.push(
66 `## Top-level files\n${tree
67 .slice(0, 60)
68 .map((e) => `- ${e.type === "tree" ? e.name + "/" : e.name}`)
69 .join("\n")}\n`
70 );
71 }
72
73 // Recent commits
74 const commits = await listCommits(owner, repo, branch, 15);
75 if (commits.length > 0) {
76 parts.push(
77 `## Recent commits\n${commits
78 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]}${c.author}`)
79 .join("\n")}\n`
80 );
81 }
82
83 // Mentioned files
84 for (const file of mentionedFiles.slice(0, 8)) {
85 try {
86 const blob = await getBlob(owner, repo, branch, file);
87 if (blob && !blob.isBinary) {
88 citedFiles.push(file);
89 parts.push(`## File: ${file}\n\`\`\`\n${blob.content.slice(0, 12000)}\n\`\`\`\n`);
90 }
91 } catch {
92 // ignore
93 }
94 }
95
96 return { context: parts.join("\n"), files: citedFiles };
97}
98
99/**
100 * Extract @-mentions of files from a user's message.
101 * Supports @filename.ext and @path/to/file.ext.
102 */
103function extractFileMentions(text: string): string[] {
104 const matches = text.match(/@([A-Za-z0-9._/-]+\.[A-Za-z0-9]+)/g);
105 if (!matches) return [];
106 return Array.from(new Set(matches.map((m) => m.slice(1))));
107}
108
109export async function chat(
110 owner: string,
111 repo: string | null,
112 history: ChatMessage[],
113 userMessage: string
114): Promise<ChatResponse> {
115 if (!isAiAvailable()) {
116 return {
117 reply:
118 "AI chat is not available — the server needs an ANTHROPIC_API_KEY to be configured.",
119 citedFiles: [],
120 };
121 }
122 const client = getAnthropic();
123
124 const mentioned = extractFileMentions(userMessage);
125 const { context: repoContext, files } = repo
126 ? await buildRepoContext(owner, repo, mentioned)
127 : { context: "", files: [] };
128
129 const system = repo
130 ? `You are GlueCron's AI assistant. You help developers understand and work with the repository ${owner}/${repo}. Be concise, accurate, and reference specific files and line numbers when relevant. If the user asks about something not in your context, say so.`
131 : `You are GlueCron's AI assistant. You help developers navigate the GlueCron platform — a git host with green-gate enforcement, AI code review, and auto-repair. Keep answers concise.`;
132
133 const messages: ChatMessage[] = [
134 ...history,
135 {
136 role: "user",
137 content: repoContext
138 ? `${repoContext}\n\n---\n\nUser question: ${userMessage}`
139 : userMessage,
140 },
141 ];
142
143 const response = await client.messages.create({
144 model: MODEL_SONNET,
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
148 });
149
150 return {
151 reply: extractText(response).trim(),
152 citedFiles: files,
153 };
154}
155
156/**
157 * Explain a single file — used for the "Explain this file" button on blob views.
158 */
159export async function explainFile(
160 owner: string,
161 repo: string,
162 filePath: string,
163 content: string
164): Promise<string> {
165 if (!isAiAvailable()) {
166 return "AI explanations are not available — server needs ANTHROPIC_API_KEY.";
167 }
168 const client = getAnthropic();
169 const message = await client.messages.create({
170 model: MODEL_SONNET,
171 max_tokens: 1024,
172 messages: [
173 {
174 role: "user",
175 content: `Explain this file from ${owner}/${repo} in plain English.
176
177Structure:
1781. **Purpose** — one sentence
1792. **Key exports / APIs** — bulleted list
1803. **How it works** — 2-4 sentences
1814. **Gotchas / caveats** — only if any
182
183Be concise.
184
185File: ${filePath}
186\`\`\`
187${content.slice(0, 40000)}
188\`\`\``,
189 },
190 ],
191 });
192 return extractText(message).trim();
193}
Addedsrc/lib/ai-client.ts+75−0View fileUnifiedSplit
1/**
2 * Shared Anthropic client + helpers for all AI-powered features.
3 * Centralised here so model choice, caching, and key handling live in one place.
4 */
5
6import Anthropic from "@anthropic-ai/sdk";
7import { config } from "./config";
8
9let _client: Anthropic | null = null;
10
11export function getAnthropic(): Anthropic {
12 if (!_client) {
13 if (!config.anthropicApiKey) {
14 throw new Error("ANTHROPIC_API_KEY is not set");
15 }
16 _client = new Anthropic({ apiKey: config.anthropicApiKey });
17 }
18 return _client;
19}
20
21export function isAiAvailable(): boolean {
22 return !!config.anthropicApiKey;
23}
24
25/** Default model for code understanding + review */
26export const MODEL_SONNET = "claude-sonnet-4-20250514";
27/** Fast model for lightweight tasks (commit messages, titles) */
28export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
29
30/**
31 * Extract text content from an Anthropic message response.
32 */
33export function extractText(
34 message: Anthropic.Messages.Message
35): string {
36 for (const block of message.content) {
37 if (block.type === "text") return block.text;
38 }
39 return "";
40}
41
42/**
43 * Safely parse JSON from a Claude response that may include surrounding prose
44 * or a ```json code block.
45 */
46export function parseJsonResponse<T = unknown>(text: string): T | null {
47 // Prefer ```json blocks
48 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/);
49 const candidate = fenced ? fenced[1] : null;
50 if (candidate) {
51 try {
52 return JSON.parse(candidate) as T;
53 } catch {
54 // fall through
55 }
56 }
57 // Fall back to first top-level {...} or [...]
58 const braceMatch = text.match(/\{[\s\S]*\}/);
59 if (braceMatch) {
60 try {
61 return JSON.parse(braceMatch[0]) as T;
62 } catch {
63 // fall through
64 }
65 }
66 const bracketMatch = text.match(/\[[\s\S]*\]/);
67 if (bracketMatch) {
68 try {
69 return JSON.parse(bracketMatch[0]) as T;
70 } catch {
71 return null;
72 }
73 }
74 return null;
75}
Addedsrc/lib/ai-completion.ts+178−0View fileUnifiedSplit
1/**
2 * Block D9 — Copilot-style code completion engine.
3 *
4 * Exposes a single async function `completeCode` that turns a prefix (+ optional
5 * suffix) into the characters that should be inserted at the cursor. Used by
6 * the `/api/copilot/completions` endpoint, which IDE plugins (VS Code, Neovim,
7 * JetBrains) call on every keystroke.
8 *
9 * Design notes:
10 * - Uses Haiku because latency matters more than depth for inline suggestions.
11 * - Input is clipped aggressively (8k chars before, 2k chars after) so a huge
12 * file doesn't blow the token budget.
13 * - Never throws. On any error (bad key, timeout, rate limit) we return an
14 * empty completion so the editor just stays silent rather than popping a
15 * scary modal at the user.
16 * - Inline LRU keeps identical requests (the editor firing the same
17 * completion twice in rapid succession) from each burning an API call.
18 * - We log prefix.length only — never the content itself, which may contain
19 * API keys, private tokens, or proprietary source.
20 */
21
22import {
23 getAnthropic,
24 MODEL_HAIKU,
25 extractText,
26 isAiAvailable,
27} from "./ai-client";
28import { createHash } from "crypto";
29
30export interface CompleteArgs {
31 prefix: string;
32 suffix?: string;
33 language?: string;
34 maxTokens?: number;
35 repoHint?: string;
36}
37
38export interface CompleteResult {
39 completion: string;
40 model: string;
41 cached: boolean;
42}
43
44// ---------- Inline LRU (size cap 200, TTL ~5 min) ----------
45// Intentionally standalone rather than reusing src/lib/cache.ts: completion
46// cache entries have a very different shape + access pattern (write-heavy,
47// short-lived, never invalidated by repo events) and keeping the logic local
48// makes the file easier to reason about and test.
49
50interface CacheEntry {
51 value: string;
52 expiresAt: number;
53}
54
55const CACHE_MAX = 200;
56const CACHE_TTL_MS = 5 * 60 * 1000;
57const cacheStore = new Map<string, CacheEntry>();
58
59function cacheKey(prefix: string, suffix: string, language: string): string {
60 return createHash("sha256")
61 .update(prefix)
62 .update("\0")
63 .update(suffix)
64 .update("\0")
65 .update(language)
66 .digest("hex");
67}
68
69function cacheGet(key: string): string | undefined {
70 const entry = cacheStore.get(key);
71 if (!entry) return undefined;
72 if (Date.now() > entry.expiresAt) {
73 cacheStore.delete(key);
74 return undefined;
75 }
76 // Move to end (MRU).
77 cacheStore.delete(key);
78 cacheStore.set(key, entry);
79 return entry.value;
80}
81
82function cacheSet(key: string, value: string): void {
83 cacheStore.delete(key);
84 if (cacheStore.size >= CACHE_MAX) {
85 const oldest = cacheStore.keys().next().value;
86 if (oldest !== undefined) cacheStore.delete(oldest);
87 }
88 cacheStore.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
89}
90
91/**
92 * Strip leading/trailing markdown code fences that Claude sometimes emits
93 * despite the system prompt forbidding them. Handles:
94 * ```lang\n...\n```
95 * ```\n...\n```
96 * Leaves un-fenced content untouched.
97 */
98function stripCodeFences(text: string): string {
99 let out = text;
100 // Leading fence (optionally with language label)
101 out = out.replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "");
102 // Trailing fence
103 out = out.replace(/\n?\s*```\s*$/, "");
104 return out;
105}
106
107export async function completeCode(
108 args: CompleteArgs
109): Promise<CompleteResult> {
110 const prefix = (args.prefix || "").slice(-8000);
111 const suffix = (args.suffix || "").slice(0, 2000);
112 const language = args.language || "unknown";
113 const repoHint = args.repoHint || "unknown";
114 const maxTokens = args.maxTokens ?? 256;
115
116 if (!isAiAvailable()) {
117 return { completion: "", model: "fallback", cached: false };
118 }
119
120 const key = cacheKey(prefix, suffix, language);
121 const hit = cacheGet(key);
122 if (hit !== undefined) {
123 return { completion: hit, model: MODEL_HAIKU, cached: true };
124 }
125
126 try {
127 const client = getAnthropic();
128 const response = await client.messages.create({
129 model: MODEL_HAIKU,
130 max_tokens: maxTokens,
131 system:
132 "You are a code completion engine. Given a prefix and optional suffix, output ONLY the characters that should be inserted at the cursor. No explanations. No markdown fences. No commentary.",
133 messages: [
134 {
135 role: "user",
136 content:
137 `Language: ${language}\n` +
138 `Repo: ${repoHint}\n\n` +
139 `PREFIX:\n${prefix}\n\n` +
140 `SUFFIX:\n${suffix}`,
141 },
142 ],
143 });
144
145 const raw = extractText(response);
146 const completion = stripCodeFences(raw);
147 cacheSet(key, completion);
148 return { completion, model: MODEL_HAIKU, cached: false };
149 } catch (err) {
150 // Never throw — the editor should degrade silently. Log length only, not
151 // the prefix itself, which can contain secrets.
152 console.error(
153 "[ai-completion] completeCode failed (prefix.length=" +
154 prefix.length +
155 "):",
156 (err as Error)?.message || err
157 );
158 return { completion: "", model: "error", cached: false };
159 }
160}
161
162/**
163 * Test-only helpers. Not part of the public API — tests use these to seed
164 * entries so they can exercise the cache without hitting the real Anthropic
165 * endpoint, and to reset state between runs.
166 */
167export const __test = {
168 cacheKey,
169 cacheGet,
170 cacheSet,
171 clear() {
172 cacheStore.clear();
173 },
174 size() {
175 return cacheStore.size;
176 },
177 stripCodeFences,
178};
Addedsrc/lib/ai-explain.ts+520−0View fileUnifiedSplit
1/**
2 * Block D6 — AI-generated "Explain this codebase" feature.
3 *
4 * Given a repo + commit sha, gathers a representative sample of files
5 * (README, manifest, top-level and main sources) and asks Claude to
6 * produce a concise Markdown overview. Results are cached per-sha in
7 * the `codebase_explanations` table so repeat views are free.
8 *
9 * Exposed functions never throw — any failure returns a safe fallback
10 * shape so the route handler can render something sensible.
11 */
12
13import { and, eq } from "drizzle-orm";
14import { db } from "../db";
15import { codebaseExplanations } from "../db/schema";
16import { getBlob, getTree } from "../git/repository";
17import type { GitTreeEntry } from "../git/repository";
18import {
19 MODEL_SONNET,
20 extractText,
21 getAnthropic,
22 isAiAvailable,
23} from "./ai-client";
24
25export interface ExplainArgs {
26 owner: string;
27 repo: string;
28 repositoryId: string;
29 commitSha: string;
30 force?: boolean;
31}
32
33export interface ExplainResult {
34 summary: string;
35 markdown: string;
36 model: string;
37 cached: boolean;
38}
39
40/** Rough budget for total characters collected from the tree. */
41const MAX_TOTAL_CHARS = 60_000;
42/** Cap on number of files sampled. */
43const MAX_FILES = 25;
44/** Skip files bigger than this before even reading them. */
45const MAX_FILE_SIZE = 32_000;
46
47const FALLBACK: ExplainResult = {
48 summary: "",
49 markdown: "_Unable to generate explanation._",
50 model: "fallback",
51 cached: false,
52};
53
54/**
55 * Read the cached explanation row for a repo + commit, if present.
56 * Returns `null` on cache miss or any DB failure.
57 */
58export async function getCachedExplanation(
59 repositoryId: string,
60 commitSha: string
61): Promise<ExplainResult | null> {
62 try {
63 const [row] = await db
64 .select()
65 .from(codebaseExplanations)
66 .where(
67 and(
68 eq(codebaseExplanations.repositoryId, repositoryId),
69 eq(codebaseExplanations.commitSha, commitSha)
70 )
71 )
72 .limit(1);
73 if (!row) return null;
74 return {
75 summary: row.summary,
76 markdown: row.markdown,
77 model: row.model,
78 cached: true,
79 };
80 } catch {
81 return null;
82 }
83}
84
85/**
86 * Produce (or return a cached) explanation for a codebase at a commit.
87 * Never throws.
88 */
89export async function explainCodebase(
90 args: ExplainArgs
91): Promise<ExplainResult> {
92 try {
93 if (!args.force) {
94 const cached = await getCachedExplanation(
95 args.repositoryId,
96 args.commitSha
97 );
98 if (cached) return cached;
99 }
100
101 // Gather a representative slice of the tree.
102 const samples = await gatherRepresentativeFiles(
103 args.owner,
104 args.repo,
105 args.commitSha
106 );
107
108 // If we couldn't read any files AND can't call Claude, there's nothing
109 // useful to say — return the canonical unable-to-generate marker so the
110 // UI can render a friendly message.
111 if (samples.files.length === 0 && !isAiAvailable()) {
112 return { ...FALLBACK };
113 }
114
115 let result: { summary: string; markdown: string; model: string };
116 if (isAiAvailable() && samples.files.length > 0) {
117 try {
118 result = await callClaude(args.owner, args.repo, samples);
119 } catch {
120 result = buildFallbackMarkdown(args.owner, args.repo, samples);
121 }
122 } else {
123 result = buildFallbackMarkdown(args.owner, args.repo, samples);
124 }
125
126 // Best-effort upsert; never let cache writes break the response.
127 try {
128 await upsertExplanation(
129 args.repositoryId,
130 args.commitSha,
131 result.summary,
132 result.markdown,
133 result.model
134 );
135 } catch {
136 /* swallow — we still return the fresh result */
137 }
138
139 return { ...result, cached: false };
140 } catch {
141 return { ...FALLBACK };
142 }
143}
144
145async function upsertExplanation(
146 repositoryId: string,
147 commitSha: string,
148 summary: string,
149 markdown: string,
150 model: string
151): Promise<void> {
152 // Try update first; if no rows, insert. Avoids needing onConflict helpers.
153 const existing = await db
154 .select({ id: codebaseExplanations.id })
155 .from(codebaseExplanations)
156 .where(
157 and(
158 eq(codebaseExplanations.repositoryId, repositoryId),
159 eq(codebaseExplanations.commitSha, commitSha)
160 )
161 )
162 .limit(1);
163 if (existing.length > 0) {
164 await db
165 .update(codebaseExplanations)
166 .set({ summary, markdown, model, generatedAt: new Date() })
167 .where(eq(codebaseExplanations.id, existing[0].id));
168 } else {
169 await db.insert(codebaseExplanations).values({
170 repositoryId,
171 commitSha,
172 summary,
173 markdown,
174 model,
175 });
176 }
177}
178
179// ---------------------------------------------------------------------------
180// Tree sampling
181// ---------------------------------------------------------------------------
182
183interface SampledFile {
184 path: string;
185 content: string;
186}
187
188interface Samples {
189 files: SampledFile[];
190 topLevelTree: GitTreeEntry[];
191 packageJson: Record<string, unknown> | null;
192}
193
194const PRIORITY_ROOT_FILES = [
195 "README.md",
196 "README",
197 "readme.md",
198 "Readme.md",
199 "README.rst",
200 "README.txt",
201 "package.json",
202 "pyproject.toml",
203 "Cargo.toml",
204 "go.mod",
205 "Gemfile",
206 "pom.xml",
207 "build.gradle",
208 "Dockerfile",
209 "tsconfig.json",
210];
211
212const CANDIDATE_SRC_DIRS = ["src", "lib", "app", "server", "backend", "pkg"];
213
214async function gatherRepresentativeFiles(
215 owner: string,
216 repo: string,
217 sha: string
218): Promise<Samples> {
219 const out: SampledFile[] = [];
220 let totalChars = 0;
221 const seen = new Set<string>();
222
223 let root: GitTreeEntry[] = [];
224 try {
225 root = await getTree(owner, repo, sha, "");
226 } catch {
227 root = [];
228 }
229
230 async function tryAdd(path: string): Promise<void> {
231 if (out.length >= MAX_FILES) return;
232 if (totalChars >= MAX_TOTAL_CHARS) return;
233 if (seen.has(path)) return;
234 seen.add(path);
235 try {
236 const blob = await getBlob(owner, repo, sha, path);
237 if (!blob || blob.isBinary) return;
238 if (!blob.content) return;
239 if (blob.size && blob.size > MAX_FILE_SIZE) {
240 const snippet = blob.content.slice(0, MAX_FILE_SIZE);
241 out.push({ path, content: snippet });
242 totalChars += snippet.length;
243 return;
244 }
245 const content = blob.content.slice(
246 0,
247 Math.max(0, MAX_TOTAL_CHARS - totalChars)
248 );
249 if (!content) return;
250 out.push({ path, content });
251 totalChars += content.length;
252 } catch {
253 /* skip file */
254 }
255 }
256
257 // 1. Root-level priority files.
258 for (const name of PRIORITY_ROOT_FILES) {
259 if (root.find((e) => e.type === "blob" && e.name === name)) {
260 await tryAdd(name);
261 }
262 }
263
264 // 2. Any other top-level config-ish blob the priority list missed.
265 for (const entry of root) {
266 if (out.length >= MAX_FILES) break;
267 if (entry.type !== "blob") continue;
268 if (seen.has(entry.name)) continue;
269 if (!looksLikeManifest(entry.name)) continue;
270 await tryAdd(entry.name);
271 }
272
273 // 3. Index/main entry files within common source directories.
274 for (const dir of CANDIDATE_SRC_DIRS) {
275 if (out.length >= MAX_FILES) break;
276 if (totalChars >= MAX_TOTAL_CHARS) break;
277 const dirEntry = root.find((e) => e.type === "tree" && e.name === dir);
278 if (!dirEntry) continue;
279 let children: GitTreeEntry[] = [];
280 try {
281 children = await getTree(owner, repo, sha, dir);
282 } catch {
283 children = [];
284 }
285 // Prefer entry-point names at top of that dir.
286 const entryNames = [
287 "index.ts",
288 "index.tsx",
289 "index.js",
290 "main.ts",
291 "main.tsx",
292 "main.js",
293 "app.ts",
294 "app.tsx",
295 "mod.rs",
296 "lib.rs",
297 "__init__.py",
298 "main.py",
299 "server.ts",
300 "server.js",
301 ];
302 for (const name of entryNames) {
303 const hit = children.find((e) => e.type === "blob" && e.name === name);
304 if (hit) await tryAdd(`${dir}/${name}`);
305 }
306 // Pull a few more source files from this directory to give context.
307 for (const child of children) {
308 if (out.length >= MAX_FILES) break;
309 if (totalChars >= MAX_TOTAL_CHARS) break;
310 if (child.type !== "blob") continue;
311 if (!isLikelySource(child.name)) continue;
312 await tryAdd(`${dir}/${child.name}`);
313 }
314 }
315
316 // 4. As a last resort, pull any remaining top-level source blobs.
317 for (const entry of root) {
318 if (out.length >= MAX_FILES) break;
319 if (totalChars >= MAX_TOTAL_CHARS) break;
320 if (entry.type !== "blob") continue;
321 if (!isLikelySource(entry.name)) continue;
322 await tryAdd(entry.name);
323 }
324
325 let pkg: Record<string, unknown> | null = null;
326 const packageFile = out.find((f) => f.path === "package.json");
327 if (packageFile) {
328 try {
329 pkg = JSON.parse(packageFile.content) as Record<string, unknown>;
330 } catch {
331 pkg = null;
332 }
333 }
334
335 return { files: out, topLevelTree: root, packageJson: pkg };
336}
337
338function looksLikeManifest(name: string): boolean {
339 const lower = name.toLowerCase();
340 return (
341 lower.endsWith(".toml") ||
342 lower.endsWith(".yaml") ||
343 lower.endsWith(".yml") ||
344 lower.endsWith(".json") ||
345 lower === "makefile" ||
346 lower === "dockerfile" ||
347 lower === "procfile"
348 );
349}
350
351function isLikelySource(name: string): boolean {
352 const lower = name.toLowerCase();
353 return (
354 lower.endsWith(".ts") ||
355 lower.endsWith(".tsx") ||
356 lower.endsWith(".js") ||
357 lower.endsWith(".jsx") ||
358 lower.endsWith(".mjs") ||
359 lower.endsWith(".cjs") ||
360 lower.endsWith(".py") ||
361 lower.endsWith(".rs") ||
362 lower.endsWith(".go") ||
363 lower.endsWith(".rb") ||
364 lower.endsWith(".java") ||
365 lower.endsWith(".kt") ||
366 lower.endsWith(".swift") ||
367 lower.endsWith(".c") ||
368 lower.endsWith(".cc") ||
369 lower.endsWith(".cpp") ||
370 lower.endsWith(".h") ||
371 lower.endsWith(".hpp")
372 );
373}
374
375// ---------------------------------------------------------------------------
376// Claude prompt + fallback
377// ---------------------------------------------------------------------------
378
379async function callClaude(
380 owner: string,
381 repo: string,
382 samples: Samples
383): Promise<{ summary: string; markdown: string; model: string }> {
384 const client = getAnthropic();
385 const treeListing = samples.topLevelTree
386 .slice(0, 80)
387 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
388 .join("\n");
389
390 const fileBlob = samples.files
391 .map(
392 (f) =>
393 `----- FILE: ${f.path} -----\n${f.content.slice(0, 10_000)}`
394 )
395 .join("\n\n");
396
397 const prompt = `You are documenting an open-source repository named "${owner}/${repo}".
398
399Based on the top-level tree and the files below, write a concise, helpful Markdown overview for a new contributor. Use GitHub-flavoured Markdown.
400
401Start with exactly one line: a single-sentence summary of what this project is, on its own (no heading, no bold).
402
403Then the following sections, in order, each as an H2 header:
404
405## What this project does
406A short paragraph expanding on the summary. Focus on the user-visible value.
407
408## Architecture
409A short paragraph or bullet list describing how the code is organised and which pieces talk to which. Mention the language/runtime.
410
411## Key modules
412A bulleted list of the most important files or directories with a one-sentence explanation each. Use backticks for paths.
413
414## Build + run
415A short list of commands a developer would run to build and start the project, inferred from the manifest or README. If unsure, say so.
416
417Keep the whole response under ~800 words. Do not invent features that are not visible. Do not include a top-level H1.
418
419Top-level tree:
420\`\`\`
421${treeListing || "(empty)"}
422\`\`\`
423
424Representative files:
425${fileBlob}`;
426
427 const message = await client.messages.create({
428 model: MODEL_SONNET,
429 max_tokens: 2048,
430 messages: [{ role: "user", content: prompt }],
431 });
432
433 const markdown = extractText(message).trim();
434 const summary = extractSummary(markdown);
435 return { summary, markdown, model: MODEL_SONNET };
436}
437
438function extractSummary(markdown: string): string {
439 if (!markdown) return "";
440 for (const raw of markdown.split("\n")) {
441 const line = raw.trim();
442 if (!line) continue;
443 if (line.startsWith("#")) continue;
444 if (line.startsWith("```")) continue;
445 return line.replace(/^[*_>]+|[*_>]+$/g, "").slice(0, 280);
446 }
447 return "";
448}
449
450function buildFallbackMarkdown(
451 owner: string,
452 repo: string,
453 samples: Samples
454): { summary: string; markdown: string; model: string } {
455 const pkg = samples.packageJson;
456 const nameRaw = pkg && typeof pkg.name === "string" ? pkg.name : `${owner}/${repo}`;
457 const description =
458 pkg && typeof pkg.description === "string" && pkg.description.trim()
459 ? pkg.description.trim()
460 : `The ${owner}/${repo} repository.`;
461
462 const scripts =
463 pkg && pkg.scripts && typeof pkg.scripts === "object"
464 ? Object.keys(pkg.scripts as Record<string, unknown>)
465 : [];
466
467 const treeLines = samples.topLevelTree
468 .slice(0, 40)
469 .map((e) => `- \`${e.name}${e.type === "tree" ? "/" : ""}\``);
470
471 const lines: string[] = [];
472 lines.push(description);
473 lines.push("");
474 lines.push("## What this project does");
475 lines.push("");
476 lines.push(
477 pkg
478 ? `\`${nameRaw}\` — ${description}`
479 : `${description} An automatically-generated overview is shown here because no AI backend is configured.`
480 );
481 lines.push("");
482 lines.push("## Architecture");
483 lines.push("");
484 lines.push(
485 samples.topLevelTree.length > 0
486 ? "Top-level layout of the repository:"
487 : "(No files found at the default commit.)"
488 );
489 if (treeLines.length > 0) {
490 lines.push("");
491 lines.push(...treeLines);
492 }
493 lines.push("");
494 lines.push("## Key modules");
495 lines.push("");
496 if (samples.files.length === 0) {
497 lines.push("- _No representative files could be sampled._");
498 } else {
499 for (const f of samples.files.slice(0, 10)) {
500 lines.push(`- \`${f.path}\``);
501 }
502 }
503 lines.push("");
504 lines.push("## Build + run");
505 lines.push("");
506 if (scripts.length > 0) {
507 lines.push("Detected package scripts:");
508 lines.push("");
509 for (const s of scripts.slice(0, 12)) {
510 lines.push(`- \`npm run ${s}\``);
511 }
512 } else {
513 lines.push(
514 "No build scripts were detected automatically. See the README for build instructions."
515 );
516 }
517
518 const markdown = lines.join("\n");
519 return { summary: description, markdown, model: "fallback" };
520}
Addedsrc/lib/ai-generators.ts+257−0View fileUnifiedSplit
1/**
2 * AI generators — commit messages, PR descriptions, changelogs, issue triage.
3 * All exposed via POST endpoints for CLI hooks + web UI convenience.
4 */
5
6import {
7 getAnthropic,
8 MODEL_HAIKU,
9 MODEL_SONNET,
10 extractText,
11 parseJsonResponse,
12 isAiAvailable,
13} from "./ai-client";
14
15export async function generateCommitMessage(diff: string): Promise<string> {
16 if (!isAiAvailable() || !diff.trim()) {
17 return "chore: update files";
18 }
19 const client = getAnthropic();
20 const message = await client.messages.create({
21 model: MODEL_HAIKU,
22 max_tokens: 512,
23 messages: [
24 {
25 role: "user",
26 content: `Write a single conventional-commit message for this diff. One line subject under 72 chars, lowercase type (feat/fix/refactor/chore/docs/test/perf/style), then optional body wrapped at 72 chars. No backticks or markdown.
27
28Diff:
29\`\`\`
30${diff.slice(0, 40000)}
31\`\`\``,
32 },
33 ],
34 });
35 return extractText(message).trim();
36}
37
38export async function generatePrSummary(
39 title: string,
40 diff: string
41): Promise<string> {
42 if (!isAiAvailable() || !diff.trim()) return "";
43 const client = getAnthropic();
44 const message = await client.messages.create({
45 model: MODEL_SONNET,
46 max_tokens: 1024,
47 messages: [
48 {
49 role: "user",
50 content: `Generate a Markdown PR description for the following changes. Include: Summary (1-3 sentences focused on why), Key changes (bullets), Test plan (bullets), Risks (bullets — omit if minor).
51
52Title: ${title}
53
54Diff:
55\`\`\`
56${diff.slice(0, 60000)}
57\`\`\``,
58 },
59 ],
60 });
61 return extractText(message).trim();
62}
63
64export async function generateChangelog(
65 repoFullName: string,
66 fromRef: string | null,
67 toRef: string,
68 commits: Array<{ sha: string; message: string; author: string }>
69): Promise<string> {
70 if (!isAiAvailable() || commits.length === 0) {
71 const header = `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n`;
72 return (
73 header +
74 commits
75 .map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`)
76 .join("\n")
77 );
78 }
79 const client = getAnthropic();
80 const commitBlob = commits
81 .slice(0, 200)
82 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]}${c.author}`)
83 .join("\n");
84 const message = await client.messages.create({
85 model: MODEL_SONNET,
86 max_tokens: 2048,
87 messages: [
88 {
89 role: "user",
90 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
91
92Release: ${toRef}
93Previous: ${fromRef || "(initial)"}
94
95Group commits by category (Features, Fixes, Performance, Refactoring, Docs, Other). Omit empty categories. Use bullet points. Keep it concise — no marketing fluff, just the facts a user of the project needs. Reference SHAs in parentheses.
96
97Commits:
98${commitBlob}`,
99 },
100 ],
101 });
102 return extractText(message).trim();
103}
104
105interface IssueTriage {
106 suggestedLabels: string[];
107 duplicateOfIssueNumber: number | null;
108 priority: "critical" | "high" | "medium" | "low";
109 summary: string;
110}
111
112export async function triageIssue(
113 title: string,
114 body: string,
115 existingLabels: string[],
116 recentIssues: Array<{ number: number; title: string }>
117): Promise<IssueTriage> {
118 const fallback: IssueTriage = {
119 suggestedLabels: [],
120 duplicateOfIssueNumber: null,
121 priority: "medium",
122 summary: "",
123 };
124 if (!isAiAvailable()) return fallback;
125 const client = getAnthropic();
126 const message = await client.messages.create({
127 model: MODEL_HAIKU,
128 max_tokens: 512,
129 messages: [
130 {
131 role: "user",
132 content: `Triage this new GitHub-style issue.
133
134Title: ${title}
135Body:
136${body.slice(0, 4000)}
137
138Available labels: ${existingLabels.join(", ") || "(none)"}
139Recent issues (to check for duplicates):
140${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")}
141
142Respond ONLY with JSON:
143{
144 "suggestedLabels": ["label1", "label2"],
145 "duplicateOfIssueNumber": null,
146 "priority": "medium",
147 "summary": "one sentence"
148}
149Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
150 },
151 ],
152 });
153 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
154 if (!parsed) return fallback;
155 return {
156 suggestedLabels: Array.isArray(parsed.suggestedLabels)
157 ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l))
158 : [],
159 duplicateOfIssueNumber:
160 typeof parsed.duplicateOfIssueNumber === "number"
161 ? parsed.duplicateOfIssueNumber
162 : null,
163 priority: (["critical", "high", "medium", "low"] as const).includes(
164 parsed.priority as never
165 )
166 ? parsed.priority
167 : "medium",
168 summary: typeof parsed.summary === "string" ? parsed.summary : "",
169 };
170}
171
172/**
173 * D3 — AI PR triage. Reads the PR title/body + optional diff summary and
174 * suggests labels, reviewers, and a priority. Never throws; degrades to
175 * empty suggestions when the Anthropic key is absent.
176 */
177export interface PrTriage {
178 suggestedLabels: string[];
179 suggestedReviewerUsernames: string[];
180 priority: "critical" | "high" | "medium" | "low";
181 riskArea: "frontend" | "backend" | "infra" | "docs" | "tests" | "mixed";
182 summary: string;
183}
184
185export async function triagePullRequest(
186 title: string,
187 body: string,
188 diffSummary: string,
189 availableLabels: string[],
190 candidateReviewers: string[]
191): Promise<PrTriage> {
192 const fallback: PrTriage = {
193 suggestedLabels: [],
194 suggestedReviewerUsernames: [],
195 priority: "medium",
196 riskArea: "mixed",
197 summary: "",
198 };
199 if (!isAiAvailable()) return fallback;
200 try {
201 const client = getAnthropic();
202 const message = await client.messages.create({
203 model: MODEL_HAIKU,
204 max_tokens: 512,
205 messages: [
206 {
207 role: "user",
208 content: `Triage this new pull request.
209
210Title: ${title}
211Body:
212${body.slice(0, 4000)}
213
214Diff summary (paths + line counts):
215${diffSummary.slice(0, 4000)}
216
217Available labels: ${availableLabels.join(", ") || "(none)"}
218Candidate reviewers (usernames): ${candidateReviewers.join(", ") || "(none)"}
219
220Respond ONLY with JSON:
221{
222 "suggestedLabels": ["label1"],
223 "suggestedReviewerUsernames": ["alice"],
224 "priority": "medium",
225 "riskArea": "backend",
226 "summary": "one-sentence description of the change"
227}
228Only pick labels from the available list. Only pick reviewers from the candidate list. Priority must be one of critical|high|medium|low. riskArea must be one of frontend|backend|infra|docs|tests|mixed.`,
229 },
230 ],
231 });
232 const parsed = parseJsonResponse<PrTriage>(extractText(message));
233 if (!parsed) return fallback;
234 const allowedRisk = ["frontend", "backend", "infra", "docs", "tests", "mixed"] as const;
235 const allowedPriority = ["critical", "high", "medium", "low"] as const;
236 return {
237 suggestedLabels: Array.isArray(parsed.suggestedLabels)
238 ? parsed.suggestedLabels.filter((l) => availableLabels.includes(l))
239 : [],
240 suggestedReviewerUsernames: Array.isArray(parsed.suggestedReviewerUsernames)
241 ? parsed.suggestedReviewerUsernames.filter((u) =>
242 candidateReviewers.includes(u)
243 )
244 : [],
245 priority: allowedPriority.includes(parsed.priority as never)
246 ? parsed.priority
247 : "medium",
248 riskArea: allowedRisk.includes(parsed.riskArea as never)
249 ? parsed.riskArea
250 : "mixed",
251 summary: typeof parsed.summary === "string" ? parsed.summary : "",
252 };
253 } catch (err) {
254 console.error("[triagePullRequest]", err);
255 return fallback;
256 }
257}
Addedsrc/lib/ai-incident.ts+393−0View fileUnifiedSplit
1/**
2 * Block D4 — AI Incident Responder.
3 *
4 * When a deployment fails, this module automatically opens an issue with an
5 * AI-generated root-cause analysis. Invoked by the post-receive hook (from
6 * `triggerCrontechDeploy`) whenever the Crontech deploy call returns a non-2xx
7 * response or throws. Also retriggerable via the deployments route.
8 *
9 * Everything here degrades gracefully:
10 * - Without `ANTHROPIC_API_KEY` we still open an issue, just with a
11 * deterministic fallback body saying "AI analysis unavailable".
12 * - Any DB/git/network failure is caught; the function never throws and
13 * returns `{ issueNumber: null, reason: <err.message> }` instead.
14 */
15
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 deployments,
20 issueLabels,
21 issues,
22 labels,
23 repositories,
24 users,
25} from "../db/schema";
26import { getDefaultBranch, listCommits } from "../git/repository";
27import {
28 MODEL_SONNET,
29 extractText,
30 getAnthropic,
31 isAiAvailable,
32 parseJsonResponse,
33} from "./ai-client";
34
35export interface IncidentAnalysis {
36 title: string;
37 likelyCause: string;
38 suspectedCommit: string | null;
39 remediation: string;
40}
41
42export interface OnDeployFailureArgs {
43 repositoryId: string;
44 deploymentId: string;
45 ref?: string | null;
46 commitSha?: string | null;
47 target?: string | null;
48 errorMessage?: string | null;
49}
50
51export interface OnDeployFailureResult {
52 issueNumber: number | null;
53 reason: string;
54}
55
56/**
57 * Format a list of commits for inclusion in the incident prompt / issue body.
58 * Pure helper — kept separate so it can be unit-tested without any I/O.
59 */
60export function summariseCommitsForIncident(
61 commits: { sha: string; message: string; author: string }[]
62): string {
63 return commits
64 .map((c) => {
65 const sha7 = (c.sha || "").slice(0, 7);
66 const subject = (c.message || "").split("\n")[0] || "";
67 const author = c.author || "unknown";
68 return `- ${sha7} ${subject}${author}`;
69 })
70 .join("\n");
71}
72
73function truncate(s: string | null | undefined, max: number): string {
74 if (!s) return "";
75 if (s.length <= max) return s;
76 return s.slice(0, max) + "\n…(truncated)";
77}
78
79function renderIssueBody(args: {
80 deploymentId: string;
81 ref: string;
82 shortSha: string;
83 target: string;
84 errorMessage: string;
85 likelyCause: string;
86 suspectedCommit: string | null;
87 remediation: string;
88}): string {
89 const suspected = args.suspectedCommit || "none";
90 const safeError = args.errorMessage || "(no error message captured)";
91 return [
92 `Automated by GlueCron incident responder after deployment ${args.deploymentId} failed.`,
93 "",
94 `**Ref:** \`${args.ref}\` (sha \`${args.shortSha}\`)`,
95 `**Target:** ${args.target}`,
96 "",
97 "## Error",
98 "```",
99 safeError,
100 "```",
101 "",
102 "## Likely cause",
103 args.likelyCause,
104 "",
105 "## Suspected commit",
106 suspected,
107 "",
108 "## Suggested remediation",
109 args.remediation,
110 "",
111 "---",
112 "_This issue was auto-generated. Edit or close if the analysis is off._",
113 ].join("\n");
114}
115
116async function askClaudeForAnalysis(
117 repoFullName: string,
118 ref: string,
119 shortSha: string,
120 errorMessage: string,
121 commitSummary: string
122): Promise<IncidentAnalysis | null> {
123 try {
124 const client = getAnthropic();
125 const message = await client.messages.create({
126 model: MODEL_SONNET,
127 max_tokens: 1024,
128 messages: [
129 {
130 role: "user",
131 content: `You are GlueCron's incident responder. A deployment just failed. Respond ONLY with JSON of the form:
132
133{"title": "...", "likelyCause": "...", "suspectedCommit": "<sha or null>", "remediation": "..."}
134
135Repository: ${repoFullName}
136Failing ref: ${ref} (sha ${shortSha})
137
138Error message:
139\`\`\`
140${truncate(errorMessage, 4000)}
141\`\`\`
142
143Recent commits (most recent first):
144${commitSummary || "(no commits available)"}
145
146Write a crisp issue title (prefixed with "Deploy failed:"), a plausible likelyCause (2-4 sentences), a suspectedCommit sha (or null if unclear), and concrete remediation steps (bullet list as a single string with \\n separators).`,
147 },
148 ],
149 });
150 const parsed = parseJsonResponse<IncidentAnalysis>(extractText(message));
151 if (!parsed) return null;
152 const suspected =
153 typeof parsed.suspectedCommit === "string" && parsed.suspectedCommit
154 ? parsed.suspectedCommit
155 : null;
156 return {
157 title:
158 typeof parsed.title === "string" && parsed.title.trim()
159 ? parsed.title.trim().slice(0, 200)
160 : `Deploy failed: ${repoFullName} @ ${shortSha}`,
161 likelyCause:
162 typeof parsed.likelyCause === "string" && parsed.likelyCause.trim()
163 ? parsed.likelyCause.trim()
164 : "Unknown — see raw error above.",
165 suspectedCommit: suspected,
166 remediation:
167 typeof parsed.remediation === "string" && parsed.remediation.trim()
168 ? parsed.remediation.trim()
169 : "Inspect logs manually and re-run the deployment.",
170 };
171 } catch (err) {
172 console.error("[ai-incident] analysis request failed:", err);
173 return null;
174 }
175}
176
177/**
178 * Entry point called by the post-receive hook and the retry-incident route.
179 * Never throws.
180 */
181export async function onDeployFailure(
182 args: OnDeployFailureArgs
183): Promise<OnDeployFailureResult> {
184 const ref = args.ref || "refs/heads/main";
185 const commitSha = args.commitSha || "";
186 const shortSha = commitSha ? commitSha.slice(0, 7) : "unknown";
187 const target = args.target || "unknown";
188 const errorMessage = args.errorMessage || "";
189
190 try {
191 // 1. Load repository + owner username for nice issue attribution.
192 const [repoRow] = await db
193 .select()
194 .from(repositories)
195 .where(eq(repositories.id, args.repositoryId))
196 .limit(1);
197 if (!repoRow) {
198 return { issueNumber: null, reason: "repository not found" };
199 }
200 const [ownerRow] = await db
201 .select()
202 .from(users)
203 .where(eq(users.id, repoRow.ownerId))
204 .limit(1);
205 if (!ownerRow) {
206 return { issueNumber: null, reason: "repository owner not found" };
207 }
208 const repoFullName = `${ownerRow.username}/${repoRow.name}`;
209
210 // 2. Load up to ~10 recent commits leading to commitSha, with a fallback
211 // to the default branch tip if the sha is missing / unresolvable.
212 let commits: Array<{ sha: string; message: string; author: string }> = [];
213 try {
214 const refForLog =
215 commitSha ||
216 (await getDefaultBranch(ownerRow.username, repoRow.name)) ||
217 repoRow.defaultBranch ||
218 "main";
219 const raw = await listCommits(
220 ownerRow.username,
221 repoRow.name,
222 refForLog,
223 10,
224 0
225 ).catch(() => [] as Awaited<ReturnType<typeof listCommits>>);
226 commits = raw.map((c) => ({
227 sha: c.sha,
228 message: c.message,
229 author: c.author,
230 }));
231 } catch {
232 commits = [];
233 }
234 const commitSummary = summariseCommitsForIncident(commits);
235
236 // 3. Ask Claude for an analysis, or fall back to a deterministic body.
237 let analysis: IncidentAnalysis;
238 if (isAiAvailable()) {
239 const ai = await askClaudeForAnalysis(
240 repoFullName,
241 ref,
242 shortSha,
243 errorMessage,
244 commitSummary
245 );
246 analysis = ai || {
247 title: `Deploy failed: ${repoFullName} @ ${shortSha}`,
248 likelyCause:
249 "AI analysis unavailable — inspect logs manually.\n\nRecent commits:\n" +
250 commitSummary,
251 suspectedCommit: commits[0]?.sha || null,
252 remediation:
253 "Inspect deployment logs and recent commits. Re-run the deploy once fixed.",
254 };
255 } else {
256 analysis = {
257 title: `Deploy failed: ${repoFullName} @ ${shortSha}`,
258 likelyCause:
259 "AI analysis unavailable — inspect logs manually.\n\nRecent commits:\n" +
260 (commitSummary || "(none)"),
261 suspectedCommit: commits[0]?.sha || null,
262 remediation:
263 "Inspect deployment logs and recent commits. Re-run the deploy once fixed.",
264 };
265 }
266
267 // 4. Render the Markdown issue body.
268 const body = renderIssueBody({
269 deploymentId: args.deploymentId,
270 ref,
271 shortSha,
272 target,
273 errorMessage,
274 likelyCause: analysis.likelyCause,
275 suspectedCommit: analysis.suspectedCommit,
276 remediation: analysis.remediation,
277 });
278
279 // 5. Insert the issue row. `issues.number` is a `serial()` — Postgres
280 // assigns the next number automatically, matching the pattern used
281 // in src/routes/issues.tsx.
282 let issueNumber: number | null = null;
283 try {
284 const [inserted] = await db
285 .insert(issues)
286 .values({
287 repositoryId: repoRow.id,
288 authorId: repoRow.ownerId,
289 title: analysis.title,
290 body,
291 state: "open",
292 })
293 .returning();
294 issueNumber = inserted?.number ?? null;
295
296 // Best-effort: attach the "incident" label if one exists for the repo.
297 if (inserted?.id) {
298 try {
299 const [incidentLabel] = await db
300 .select()
301 .from(labels)
302 .where(
303 and(
304 eq(labels.repositoryId, repoRow.id),
305 eq(labels.name, "incident")
306 )
307 )
308 .limit(1);
309 if (incidentLabel) {
310 await db
311 .insert(issueLabels)
312 .values({ issueId: inserted.id, labelId: incidentLabel.id })
313 .catch(() => {
314 /* ignore — the unique constraint may reject duplicates */
315 });
316 }
317 } catch {
318 /* best-effort */
319 }
320 }
321
322 // Bump the repo's issue count so the UI stays in sync.
323 try {
324 await db
325 .update(repositories)
326 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
327 .where(eq(repositories.id, repoRow.id));
328 } catch {
329 /* best-effort */
330 }
331 } catch (err) {
332 return {
333 issueNumber: null,
334 reason: (err as Error).message || "issue insert failed",
335 };
336 }
337
338 // 6. Update the deployment's blockedReason to link the auto-issue, but
339 // only if the field is currently empty or looks like a raw error
340 // (i.e. NOT an admin-edited note).
341 if (issueNumber !== null) {
342 try {
343 const [depRow] = await db
344 .select()
345 .from(deployments)
346 .where(eq(deployments.id, args.deploymentId))
347 .limit(1);
348 const current = depRow?.blockedReason || "";
349 const looksAutoEditable =
350 !current ||
351 current === errorMessage ||
352 /^HTTP \d+/.test(current) ||
353 /^auto-issue #/.test(current);
354 if (depRow && looksAutoEditable) {
355 await db
356 .update(deployments)
357 .set({ blockedReason: `auto-issue #${issueNumber}` })
358 .where(eq(deployments.id, args.deploymentId));
359 }
360 } catch {
361 /* best-effort */
362 }
363 }
364
365 return {
366 issueNumber,
367 reason: issueNumber !== null ? "ok" : "issue number unavailable",
368 };
369 } catch (err) {
370 return {
371 issueNumber: null,
372 reason: (err as Error).message || "unknown failure",
373 };
374 }
375}
376
377// Re-exported for tests that want to inspect the most recent incident issue
378// for a repository without hitting the HTTP layer.
379export async function getLatestIncidentIssueForRepo(
380 repositoryId: string
381): Promise<{ number: number; title: string } | null> {
382 try {
383 const [row] = await db
384 .select({ number: issues.number, title: issues.title })
385 .from(issues)
386 .where(eq(issues.repositoryId, repositoryId))
387 .orderBy(desc(issues.createdAt))
388 .limit(1);
389 return row || null;
390 } catch {
391 return null;
392 }
393}
Addedsrc/lib/ai-review.ts+125−0View fileUnifiedSplit
1/**
2 * AI-powered code review using Claude.
3 *
4 * Generates inline review comments on pull request diffs.
5 * Reviews are posted as PR comments with isAiReview=true.
6 */
7
8import Anthropic from "@anthropic-ai/sdk";
9import { config } from "./config";
10
11interface ReviewComment {
12 filePath: string;
13 lineNumber: number | null;
14 body: string;
15}
16
17interface ReviewResult {
18 summary: string;
19 comments: ReviewComment[];
20 approved: boolean;
21}
22
23let _client: Anthropic | null = null;
24
25function getClient(): Anthropic {
26 if (!_client) {
27 if (!config.anthropicApiKey) {
28 throw new Error("ANTHROPIC_API_KEY is not set");
29 }
30 _client = new Anthropic({ apiKey: config.anthropicApiKey });
31 }
32 return _client;
33}
34
35/**
36 * Run AI code review on a PR diff.
37 */
38export async function reviewDiff(
39 repoFullName: string,
40 prTitle: string,
41 prBody: string | null,
42 baseBranch: string,
43 headBranch: string,
44 diffText: string
45): Promise<ReviewResult> {
46 const client = getClient();
47
48 const message = await client.messages.create({
49 model: "claude-sonnet-4-20250514",
50 max_tokens: 4096,
51 messages: [
52 {
53 role: "user",
54 content: `You are reviewing a pull request on the repository "${repoFullName}".
55
56**PR Title:** ${prTitle}
57**PR Description:** ${prBody || "(none)"}
58**Base branch:** ${baseBranch}
59**Head branch:** ${headBranch}
60
61Review the following diff. Look for:
62- Bugs, logic errors, or potential runtime failures
63- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
64- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
65- Missing error handling at system boundaries
66- Breaking changes or API contract violations
67
68Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
69
70Respond in JSON format:
71{
72 "summary": "1-3 sentence overall assessment",
73 "approved": true/false,
74 "comments": [
75 {
76 "filePath": "path/to/file.ts",
77 "lineNumber": 42,
78 "body": "Explain the issue and suggest a fix"
79 }
80 ]
81}
82
83If the diff looks clean, return approved: true with an empty comments array.
84
85\`\`\`diff
86${diffText.slice(0, 100000)}
87\`\`\``,
88 },
89 ],
90 });
91
92 const text =
93 message.content[0].type === "text" ? message.content[0].text : "";
94
95 try {
96 // Extract JSON from response (may be wrapped in markdown code block)
97 const jsonMatch = text.match(/\{[\s\S]*\}/);
98 if (!jsonMatch) {
99 return {
100 summary: "AI review completed but could not parse structured output.",
101 comments: [],
102 approved: true,
103 };
104 }
105 const parsed = JSON.parse(jsonMatch[0]);
106 return {
107 summary: parsed.summary || "Review complete.",
108 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
109 approved: parsed.approved !== false,
110 };
111 } catch {
112 return {
113 summary: text.slice(0, 500),
114 comments: [],
115 approved: true,
116 };
117 }
118}
119
120/**
121 * Check if AI review is available (API key configured).
122 */
123export function isAiReviewEnabled(): boolean {
124 return !!config.anthropicApiKey;
125}
Addedsrc/lib/ai-tests.ts+354−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite helper.
3 *
4 * Given a source file from a repo, produces a *failing* test stub that
5 * exercises the public surface of the file using whatever test framework
6 * the repository appears to be using (bun:test, vitest, jest, pytest,
7 * go test, etc.).
8 *
9 * The HTTP glue lives in `routes/ai-tests.tsx`; this module only exposes
10 * pure helpers and an AI wrapper that NEVER throws. When Claude isn't
11 * available (no API key, transport error, etc.) `generateTestStub` returns
12 * an empty body and `framework: "fallback"` so the route can render a
13 * "couldn't generate" message without crashing.
14 */
15
16import {
17 MODEL_SONNET,
18 extractText,
19 getAnthropic,
20 isAiAvailable,
21} from "./ai-client";
22
23// ---------------------------------------------------------------------------
24// Language detection
25// ---------------------------------------------------------------------------
26
27export type TestLanguage =
28 | "typescript"
29 | "javascript"
30 | "python"
31 | "go"
32 | "rust"
33 | "java"
34 | "ruby"
35 | "other";
36
37/**
38 * Detect a coarse language bucket from a file path's extension.
39 * Unknown / non-code paths return "other".
40 */
41export function detectLanguage(path: string): TestLanguage {
42 const lower = path.toLowerCase();
43 const dot = lower.lastIndexOf(".");
44 if (dot < 0) return "other";
45 const ext = lower.slice(dot + 1);
46 switch (ext) {
47 case "ts":
48 case "tsx":
49 case "mts":
50 case "cts":
51 return "typescript";
52 case "js":
53 case "jsx":
54 case "mjs":
55 case "cjs":
56 return "javascript";
57 case "py":
58 return "python";
59 case "go":
60 return "go";
61 case "rs":
62 return "rust";
63 case "java":
64 case "kt":
65 return "java";
66 case "rb":
67 return "ruby";
68 default:
69 return "other";
70 }
71}
72
73// ---------------------------------------------------------------------------
74// Framework detection
75// ---------------------------------------------------------------------------
76
77/**
78 * Given a language bucket and a flat list of repository files (paths, not
79 * contents), return a short framework identifier that matches the
80 * conventions the repo already appears to be using.
81 *
82 * The returned string is what gets plumbed into Claude prompts and is used
83 * to compute the suggested test file path.
84 */
85export function detectTestFramework(
86 language: TestLanguage,
87 repoFiles: string[]
88): string {
89 const files = repoFiles.map((f) => f.toLowerCase());
90 const has = (needle: string | RegExp): boolean => {
91 if (typeof needle === "string") return files.some((f) => f === needle);
92 return files.some((f) => needle.test(f));
93 };
94
95 // Python first — it has the clearest signals.
96 if (language === "python") {
97 if (has("pytest.ini") || has("pyproject.toml") || has(/(^|\/)tests?\/.+test.*\.py$/))
98 return "pytest";
99 if (has(/(^|\/)test_.+\.py$/) || has(/_test\.py$/)) return "pytest";
100 return "pytest";
101 }
102
103 if (language === "go") return "go test";
104 if (language === "rust") return "cargo test";
105 if (language === "java") return "junit";
106 if (language === "ruby") return has("gemfile") ? "rspec" : "minitest";
107
108 // JavaScript / TypeScript — multiple competing frameworks.
109 if (language === "typescript" || language === "javascript" || language === "other") {
110 if (has(/vitest\.config\.(ts|js|mjs|cjs)$/) || has("vitest.config.ts"))
111 return "vitest";
112 if (has(/jest\.config(\..+)?$/)) return "jest";
113 if (has(/\.mocharc(\..+)?$/) || has("mocha.opts")) return "mocha";
114 if (has("playwright.config.ts") || has("playwright.config.js"))
115 return "playwright";
116
117 const usesBun =
118 has("bun.lockb") ||
119 has("bunfig.toml") ||
120 files.some((f) => f.endsWith("package.json"));
121 if (usesBun) return "bun:test";
122 }
123
124 return "bun:test";
125}
126
127// ---------------------------------------------------------------------------
128// Suggested test path
129// ---------------------------------------------------------------------------
130
131/**
132 * Compute a deterministic path for the generated test file. The rules are
133 * conventional rather than exhaustive — reviewers can always move the file
134 * after generation.
135 */
136export function suggestedTestPath(
137 sourcePath: string,
138 language: TestLanguage,
139 framework: string
140): string {
141 const parts = sourcePath.split("/");
142 const file = parts[parts.length - 1] || sourcePath;
143 const dir = parts.slice(0, -1).join("/");
144 const dotIdx = file.lastIndexOf(".");
145 const base = dotIdx > 0 ? file.slice(0, dotIdx) : file;
146 const ext = dotIdx > 0 ? file.slice(dotIdx) : "";
147
148 // Python: siblings `test_foo.py` is near-universal.
149 if (language === "python" || framework === "pytest") {
150 return dir ? `${dir}/test_${base}.py` : `test_${base}.py`;
151 }
152
153 // Go: `foo_test.go` adjacent to source.
154 if (language === "go" || framework === "go test") {
155 return dir ? `${dir}/${base}_test.go` : `${base}_test.go`;
156 }
157
158 // Rust: convention is `#[cfg(test)] mod tests` inline, but for a standalone
159 // stub we drop a file into `tests/`.
160 if (language === "rust" || framework === "cargo test") {
161 return `tests/${base}_test.rs`;
162 }
163
164 // Java / Kotlin
165 if (language === "java" || framework === "junit") {
166 const klass = base.charAt(0).toUpperCase() + base.slice(1);
167 return dir
168 ? `${dir.replace(/\/main\//, "/test/")}/${klass}Test${ext || ".java"}`
169 : `${klass}Test${ext || ".java"}`;
170 }
171
172 // Ruby
173 if (language === "ruby") {
174 if (framework === "rspec") {
175 return dir ? `spec/${dir}/${base}_spec.rb` : `spec/${base}_spec.rb`;
176 }
177 return dir ? `test/${dir}/${base}_test.rb` : `test/${base}_test.rb`;
178 }
179
180 // JS/TS with bun / jest / vitest — prefer `__tests__/<name>.test.<ext>`.
181 const testExt = ext === ".tsx" ? ".test.ts" : ext.replace(/^\./, ".test.");
182 const safeExt = testExt || ".test.ts";
183
184 if (framework === "bun:test") {
185 // If the source is already under src/, put tests under src/__tests__/.
186 if (dir.startsWith("src/")) {
187 return `src/__tests__/${base}${safeExt}`;
188 }
189 if (dir === "src") {
190 return `src/__tests__/${base}${safeExt}`;
191 }
192 if (dir) return `${dir}/__tests__/${base}${safeExt}`;
193 return `__tests__/${base}${safeExt}`;
194 }
195
196 // vitest / jest default to sibling `.test.` file.
197 if (dir) return `${dir}/${base}${safeExt}`;
198 return `${base}${safeExt}`;
199}
200
201// ---------------------------------------------------------------------------
202// Prompt
203// ---------------------------------------------------------------------------
204
205export interface TestGenOpts {
206 path: string;
207 language: string;
208 framework: string;
209 sourceCode: string;
210 apiHints?: string;
211}
212
213/**
214 * Build the user prompt that instructs Claude to emit a failing test stub.
215 * Returning the prompt as a pure function keeps it easy to test.
216 */
217export function buildTestsPrompt(opts: TestGenOpts): string {
218 const { path, language, framework, sourceCode, apiHints } = opts;
219 const trimmed = sourceCode.length > 40_000
220 ? sourceCode.slice(0, 40_000) + "\n// ... (truncated)"
221 : sourceCode;
222
223 return `You are writing an initial failing test suite for an open-source project.
224
225Source file path: \`${path}\`
226Detected language: ${language}
227Detected test framework: ${framework}
228
229Write a *failing* test stub — the tests should compile / import cleanly where possible, but assertions MUST fail (or use explicit \`fail()\` / \`todo\` / \`skip\` markers where the framework supports them) so a developer is forced to review, fill in expected values, and confirm the intended behaviour. Prefer realistic \`expect(...)\` calls with placeholder expected values that are obviously wrong (like \`TODO\`) rather than empty bodies.
230
231Rules:
232- Exercise every exported / public symbol you can see in the source.
233- Use only idioms native to "${framework}".
234- No explanations, no Markdown, no surrounding prose — return ONLY the test file body.
235- If imports are needed, compute paths relative to the source file at \`${path}\`.
236- Leave a top-of-file comment that this stub was generated by gluecron's AI test helper and MUST be reviewed before being committed.
237${apiHints ? `\nAdditional hints about the public API:\n${apiHints}\n` : ""}
238Source file contents:
239\`\`\`
240${trimmed}
241\`\`\`
242`;
243}
244
245// ---------------------------------------------------------------------------
246// Claude wrapper
247// ---------------------------------------------------------------------------
248
249export interface TestStubResult {
250 code: string;
251 suggestedPath: string;
252 framework: string;
253 language: string;
254}
255
256/**
257 * Strip a leading/trailing Markdown fence (```lang ... ```) that Claude will
258 * sometimes add around the returned body, even when told not to.
259 */
260function stripCodeFences(raw: string): string {
261 let text = raw.trim();
262 const fence = text.match(/^```[a-zA-Z0-9_+-]*\n?([\s\S]*?)\n?```\s*$/);
263 if (fence) return fence[1].trim();
264 // Partial fences (just the opener) — drop them.
265 if (text.startsWith("```")) {
266 const nl = text.indexOf("\n");
267 if (nl > -1) text = text.slice(nl + 1);
268 }
269 if (text.endsWith("```")) text = text.slice(0, -3);
270 return text.trim();
271}
272
273/**
274 * Ask Claude Sonnet to produce a failing test stub for the given source.
275 * Never throws. On any error (AI unavailable, network failure, empty
276 * response) returns `{ code: "", framework: "fallback", ... }`.
277 */
278export async function generateTestStub(
279 opts: TestGenOpts
280): Promise<TestStubResult> {
281 const lang = (opts.language as TestLanguage) || "other";
282 const suggestedPath = suggestedTestPath(opts.path, lang, opts.framework);
283
284 if (!isAiAvailable()) {
285 return {
286 code: "",
287 suggestedPath,
288 framework: "fallback",
289 language: opts.language,
290 };
291 }
292
293 try {
294 const client = getAnthropic();
295 const prompt = buildTestsPrompt(opts);
296 const message = await client.messages.create({
297 model: MODEL_SONNET,
298 max_tokens: 2048,
299 messages: [{ role: "user", content: prompt }],
300 });
301 const raw = extractText(message);
302 const code = stripCodeFences(raw);
303 if (!code) {
304 return {
305 code: "",
306 suggestedPath,
307 framework: "fallback",
308 language: opts.language,
309 };
310 }
311 return {
312 code,
313 suggestedPath,
314 framework: opts.framework,
315 language: opts.language,
316 };
317 } catch {
318 return {
319 code: "",
320 suggestedPath,
321 framework: "fallback",
322 language: opts.language,
323 };
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Content types for ?format=raw
329// ---------------------------------------------------------------------------
330
331/**
332 * Return a suitable `Content-Type` header for a generated test file, based
333 * on the language bucket. Defaults to `text/plain; charset=utf-8`.
334 */
335export function contentTypeFor(language: string): string {
336 switch (language) {
337 case "typescript":
338 return "application/typescript; charset=utf-8";
339 case "javascript":
340 return "application/javascript; charset=utf-8";
341 case "python":
342 return "text/x-python; charset=utf-8";
343 case "go":
344 return "text/x-go; charset=utf-8";
345 case "rust":
346 return "text/x-rust; charset=utf-8";
347 case "java":
348 return "text/x-java-source; charset=utf-8";
349 case "ruby":
350 return "text/x-ruby; charset=utf-8";
351 default:
352 return "text/plain; charset=utf-8";
353 }
354}
Addedsrc/lib/auto-repair.ts+472−0View fileUnifiedSplit
1/**
2 * AI-powered auto-repair engine.
3 *
4 * When a gate fails, this engine attempts to automatically fix the problem
5 * and push the fix back to the branch. Covers:
6 * - Failing tests → analyse + patch source
7 * - Type errors → fix type signatures
8 * - Lint errors → apply fixes or reformat
9 * - Secret leaks → redact secret, add to .gitignore, force-push fix
10 * - Security issues → patch vulnerable code
11 *
12 * Works in a temporary worktree so the bare repo is never corrupted.
13 * All repair commits are authored by "GlueCron AI" and recorded in gate_runs.
14 */
15
16import { spawn } from "bun";
17import { mkdir, rm, readFile, writeFile } from "fs/promises";
18import { join } from "path";
19import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
20import { getRepoPath } from "../git/repository";
21import type { SecurityFinding, SecretFinding } from "./security-scan";
22
23export interface RepairResult {
24 attempted: boolean;
25 success: boolean;
26 commitSha?: string;
27 filesChanged: string[];
28 summary: string;
29 error?: string;
30}
31
32async function exec(
33 cmd: string[],
34 opts?: { cwd?: string; env?: Record<string, string> }
35): Promise<{ stdout: string; stderr: string; exitCode: number }> {
36 const proc = spawn(cmd, {
37 cwd: opts?.cwd,
38 env: { ...process.env, ...opts?.env },
39 stdout: "pipe",
40 stderr: "pipe",
41 });
42 const [stdout, stderr] = await Promise.all([
43 new Response(proc.stdout).text(),
44 new Response(proc.stderr).text(),
45 ]);
46 const exitCode = await proc.exited;
47 return { stdout, stderr, exitCode };
48}
49
50const AUTHOR_ENV = {
51 GIT_AUTHOR_NAME: "GlueCron AI",
52 GIT_AUTHOR_EMAIL: "ai@gluecron.com",
53 GIT_COMMITTER_NAME: "GlueCron AI",
54 GIT_COMMITTER_EMAIL: "ai@gluecron.com",
55};
56
57interface Patch {
58 path: string;
59 /** Full replacement content. Preferred for simplicity + correctness. */
60 content: string;
61 /** Short rationale for the change — included in the commit message. */
62 reason: string;
63}
64
65/**
66 * Create a disposable worktree at the given branch head.
67 * Returns the worktree path; caller MUST call cleanupWorktree when done.
68 */
69async function createWorktree(
70 repoDir: string,
71 branch: string
72): Promise<{ path: string; ok: boolean; error?: string }> {
73 const path = join(repoDir, `_repair_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
74 const res = await exec(["git", "worktree", "add", path, branch], { cwd: repoDir });
75 if (res.exitCode !== 0) {
76 return { path, ok: false, error: res.stderr };
77 }
78 return { path, ok: true };
79}
80
81async function cleanupWorktree(repoDir: string, worktree: string): Promise<void> {
82 await exec(["git", "worktree", "remove", "--force", worktree], {
83 cwd: repoDir,
84 }).catch(() => {});
85 await rm(worktree, { recursive: true, force: true }).catch(() => {});
86}
87
88/**
89 * Apply patches to a worktree, commit, and update the branch ref in the bare repo.
90 */
91async function applyAndCommit(
92 repoDir: string,
93 worktree: string,
94 branch: string,
95 patches: Patch[],
96 commitMessage: string
97): Promise<{ ok: boolean; sha?: string; error?: string; filesChanged: string[] }> {
98 const filesChanged: string[] = [];
99 for (const patch of patches) {
100 const fullPath = join(worktree, patch.path);
101 try {
102 await mkdir(join(fullPath, "..").replace(/[^/]+\/\.\.$/, ""), { recursive: true }).catch(() => {});
103 await writeFile(fullPath, patch.content, "utf8");
104 filesChanged.push(patch.path);
105 } catch (err) {
106 console.error(`[auto-repair] Failed to write ${patch.path}:`, err);
107 }
108 }
109 if (filesChanged.length === 0) {
110 return { ok: false, error: "No patches applied", filesChanged: [] };
111 }
112
113 const add = await exec(["git", "add", "-A"], { cwd: worktree });
114 if (add.exitCode !== 0) {
115 return { ok: false, error: `git add: ${add.stderr}`, filesChanged };
116 }
117
118 const commit = await exec(
119 ["git", "commit", "-m", commitMessage],
120 { cwd: worktree, env: AUTHOR_ENV }
121 );
122 if (commit.exitCode !== 0) {
123 return { ok: false, error: `git commit: ${commit.stderr}`, filesChanged };
124 }
125
126 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
127
128 // Push the new commit to the branch ref in the bare repo
129 const push = await exec(
130 ["git", "push", "origin", `HEAD:refs/heads/${branch}`],
131 { cwd: worktree }
132 );
133 if (push.exitCode !== 0) {
134 // Fall back to update-ref on bare repo if "origin" isn't the bare repo
135 const upd = await exec(
136 ["git", "update-ref", `refs/heads/${branch}`, sha.trim()],
137 { cwd: repoDir }
138 );
139 if (upd.exitCode !== 0) {
140 return { ok: false, error: `update-ref: ${upd.stderr}`, filesChanged };
141 }
142 }
143
144 return { ok: true, sha: sha.trim(), filesChanged };
145}
146
147/**
148 * Repair secret leaks by redacting the matching lines.
149 * This is a defensive baseline — the secret itself must be rotated manually
150 * because git history already contains it, but removing it from HEAD prevents
151 * further exposure.
152 */
153export async function repairSecrets(
154 owner: string,
155 repo: string,
156 branch: string,
157 findings: SecretFinding[]
158): Promise<RepairResult> {
159 if (findings.length === 0) {
160 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
161 }
162 const repoDir = getRepoPath(owner, repo);
163 const wt = await createWorktree(repoDir, branch);
164 if (!wt.ok) {
165 return {
166 attempted: true,
167 success: false,
168 filesChanged: [],
169 summary: "could not create worktree",
170 error: wt.error,
171 };
172 }
173
174 try {
175 // Group findings by file
176 const byFile = new Map<string, SecretFinding[]>();
177 for (const f of findings) {
178 if (!byFile.has(f.file)) byFile.set(f.file, []);
179 byFile.get(f.file)!.push(f);
180 }
181
182 const patches: Patch[] = [];
183 for (const [file, fileFindings] of byFile) {
184 try {
185 const content = await readFile(join(wt.path, file), "utf8");
186 const lines = content.split("\n");
187 const badLines = new Set(fileFindings.map((f) => f.line - 1));
188 for (const idx of badLines) {
189 if (idx >= 0 && idx < lines.length) {
190 // Redact everything that looks like a value after = or :
191 lines[idx] = lines[idx].replace(
192 /(['"])[A-Za-z0-9_\-/+=\.]{20,}(['"])/g,
193 '$1REDACTED_BY_GLUECRON$2'
194 );
195 // If the whole line IS the secret (PEM), comment it out
196 if (lines[idx].includes("BEGIN") && lines[idx].includes("PRIVATE KEY")) {
197 lines[idx] = `// ${lines[idx]} // REDACTED_BY_GLUECRON`;
198 }
199 }
200 }
201 patches.push({
202 path: file,
203 content: lines.join("\n"),
204 reason: `Redact ${fileFindings.length} secret${fileFindings.length === 1 ? "" : "s"}`,
205 });
206 } catch (err) {
207 console.error(`[auto-repair] Could not read ${file}:`, err);
208 }
209 }
210
211 if (patches.length === 0) {
212 return {
213 attempted: true,
214 success: false,
215 filesChanged: [],
216 summary: "no files to patch",
217 };
218 }
219
220 const msg = `fix(security): auto-redact leaked secrets
221
222Redacted ${findings.length} secret finding${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}.
223
224ACTION REQUIRED: these credentials must be rotated — they remain visible in git history.
225
226[auto-repair by GlueCron AI]`;
227
228 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
229 if (!result.ok) {
230 return {
231 attempted: true,
232 success: false,
233 filesChanged: result.filesChanged,
234 summary: "commit failed",
235 error: result.error,
236 };
237 }
238 return {
239 attempted: true,
240 success: true,
241 commitSha: result.sha,
242 filesChanged: result.filesChanged,
243 summary: `Redacted ${findings.length} secret${findings.length === 1 ? "" : "s"} across ${patches.length} file${patches.length === 1 ? "" : "s"}`,
244 };
245 } finally {
246 await cleanupWorktree(repoDir, wt.path);
247 }
248}
249
250/**
251 * Use Claude to repair a set of security findings by rewriting affected files.
252 */
253export async function repairSecurityIssues(
254 owner: string,
255 repo: string,
256 branch: string,
257 findings: SecurityFinding[]
258): Promise<RepairResult> {
259 if (findings.length === 0) {
260 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
261 }
262 if (!isAiAvailable()) {
263 return {
264 attempted: false,
265 success: false,
266 filesChanged: [],
267 summary: "AI not configured",
268 };
269 }
270
271 const repoDir = getRepoPath(owner, repo);
272 const wt = await createWorktree(repoDir, branch);
273 if (!wt.ok) {
274 return {
275 attempted: true,
276 success: false,
277 filesChanged: [],
278 summary: "could not create worktree",
279 error: wt.error,
280 };
281 }
282
283 try {
284 // Group by file, read + patch each
285 const byFile = new Map<string, SecurityFinding[]>();
286 for (const f of findings) {
287 if (!byFile.has(f.file)) byFile.set(f.file, []);
288 byFile.get(f.file)!.push(f);
289 }
290
291 const client = getAnthropic();
292 const patches: Patch[] = [];
293
294 for (const [file, fileFindings] of byFile) {
295 let original: string;
296 try {
297 original = await readFile(join(wt.path, file), "utf8");
298 } catch {
299 continue;
300 }
301 const findingsText = fileFindings
302 .map(
303 (f, i) =>
304 `${i + 1}. [${f.severity}] ${f.type}${f.line ? ` on line ${f.line}` : ""}: ${f.description}${f.suggestion ? `\n Suggestion: ${f.suggestion}` : ""}`
305 )
306 .join("\n");
307
308 const message = await client.messages.create({
309 model: MODEL_SONNET,
310 max_tokens: 8192,
311 messages: [
312 {
313 role: "user",
314 content: `You are a secure-coding assistant. A security scan flagged the following issues in "${file}":
315
316${findingsText}
317
318Rewrite the file to fix ALL flagged issues while preserving existing behaviour. Rules:
319- Output ONLY the full corrected file content. No prose, no code fences.
320- Do not add feature changes unrelated to the findings.
321- Keep imports / exports intact.
322- If an issue genuinely can't be fixed without breaking behaviour, return the file unchanged.
323
324Current file:
325${original}`,
326 },
327 ],
328 });
329 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
330 if (fixed && fixed !== original && fixed.length > 10) {
331 patches.push({
332 path: file,
333 content: fixed,
334 reason: `Fix ${fileFindings.length} security finding${fileFindings.length === 1 ? "" : "s"}`,
335 });
336 }
337 }
338
339 if (patches.length === 0) {
340 return {
341 attempted: true,
342 success: false,
343 filesChanged: [],
344 summary: "AI produced no patches",
345 };
346 }
347
348 const msg = `fix(security): auto-repair flagged issues
349
350${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
351
352[auto-repair by GlueCron AI]`;
353
354 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
355 if (!result.ok) {
356 return {
357 attempted: true,
358 success: false,
359 filesChanged: result.filesChanged,
360 summary: "commit failed",
361 error: result.error,
362 };
363 }
364 return {
365 attempted: true,
366 success: true,
367 commitSha: result.sha,
368 filesChanged: result.filesChanged,
369 summary: `Repaired ${findings.length} security issue${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}`,
370 };
371 } finally {
372 await cleanupWorktree(repoDir, wt.path);
373 }
374}
375
376/**
377 * Given a GateTest failure summary, ask Claude to produce a patch set
378 * that should make the failing check pass.
379 */
380export async function repairGateFailure(
381 owner: string,
382 repo: string,
383 branch: string,
384 gateName: string,
385 failureDetails: string,
386 context: { file: string; content: string }[]
387): Promise<RepairResult> {
388 if (!isAiAvailable()) {
389 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
390 }
391 if (context.length === 0) {
392 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
393 }
394
395 const repoDir = getRepoPath(owner, repo);
396 const wt = await createWorktree(repoDir, branch);
397 if (!wt.ok) {
398 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
399 }
400
401 try {
402 const client = getAnthropic();
403 const contextBlob = context
404 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
405 .join("\n");
406
407 const message = await client.messages.create({
408 model: MODEL_SONNET,
409 max_tokens: 8192,
410 messages: [
411 {
412 role: "user",
413 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
414
415Failure details:
416${failureDetails}
417
418Relevant files:
419${contextBlob}
420
421Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
422{
423 "patches": [
424 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
425 ],
426 "summary": "One sentence describing the fix"
427}
428
429If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
430 },
431 ],
432 });
433 const text = extractText(message);
434 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
435 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
436 return {
437 attempted: true,
438 success: false,
439 filesChanged: [],
440 summary: parsed?.summary || "AI produced no patches",
441 };
442 }
443
444 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
445
446${parsed.summary}
447
448${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
449
450[auto-repair by GlueCron AI]`;
451
452 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
453 if (!result.ok) {
454 return {
455 attempted: true,
456 success: false,
457 filesChanged: result.filesChanged,
458 summary: "commit failed",
459 error: result.error,
460 };
461 }
462 return {
463 attempted: true,
464 success: true,
465 commitSha: result.sha,
466 filesChanged: result.filesChanged,
467 summary: parsed.summary,
468 };
469 } finally {
470 await cleanupWorktree(repoDir, wt.path);
471 }
472}
Addedsrc/lib/billing.ts+308−0View fileUnifiedSplit
1/**
2 * Block F4 — Billing + quotas.
3 *
4 * Plans live in `billing_plans` (seeded with free/pro/team/enterprise by
5 * migration 0020). Each user has a row in `user_quotas` keyed by
6 * `plan_slug` + running usage counters (storage, AI tokens, bandwidth).
7 *
8 * getPlan(slug) — load a plan by slug
9 * getUserQuota(userId) — row + plan join, initialises on first read
10 * listPlans() — admin UI
11 * setUserPlan(userId, slug, byId) — admin override (audit-logged outside)
12 * bumpUsage(userId, field, delta) — fire-and-forget counter increment
13 * checkQuota(userId, field, amount) — boolean "allowed?" for pre-write gating
14 * repoCountForUser(userId) — counts owned repos (enforced at create)
15 * resetIfCycleExpired(userId) — flips cycleStart each month
16 *
17 * All helpers swallow DB errors (plan goes to "free" on failure) so billing is
18 * never a hard dependency for the primary request path.
19 */
20
21import { and, eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 billingPlans,
25 userQuotas,
26 repositories,
27 type BillingPlan,
28 type UserQuota,
29} from "../db/schema";
30
31export const DEFAULT_PLAN_SLUG = "free";
32
33/** Mirrors the seed rows in migration 0020 so billing works even pre-migration. */
34export const FALLBACK_PLANS: Record<string, Omit<BillingPlan, "id" | "createdAt">> = {
35 free: {
36 slug: "free",
37 name: "Free",
38 priceCents: 0,
39 repoLimit: 5,
40 storageMbLimit: 500,
41 aiTokensMonthly: 50_000,
42 bandwidthGbMonthly: 5,
43 privateRepos: false,
44 },
45 pro: {
46 slug: "pro",
47 name: "Pro",
48 priceCents: 900,
49 repoLimit: 50,
50 storageMbLimit: 5_000,
51 aiTokensMonthly: 500_000,
52 bandwidthGbMonthly: 50,
53 privateRepos: true,
54 },
55 team: {
56 slug: "team",
57 name: "Team",
58 priceCents: 2900,
59 repoLimit: 200,
60 storageMbLimit: 20_000,
61 aiTokensMonthly: 2_000_000,
62 bandwidthGbMonthly: 200,
63 privateRepos: true,
64 },
65 enterprise: {
66 slug: "enterprise",
67 name: "Enterprise",
68 priceCents: 9900,
69 repoLimit: 10_000,
70 storageMbLimit: 500_000,
71 aiTokensMonthly: 50_000_000,
72 bandwidthGbMonthly: 5_000,
73 privateRepos: true,
74 },
75};
76
77export async function listPlans(): Promise<
78 Array<Omit<BillingPlan, "id" | "createdAt">>
79> {
80 try {
81 const rows = await db.select().from(billingPlans).orderBy(billingPlans.priceCents);
82 if (rows.length > 0) return rows;
83 } catch {
84 // fall through
85 }
86 return Object.values(FALLBACK_PLANS);
87}
88
89export async function getPlan(
90 slug: string
91): Promise<Omit<BillingPlan, "id" | "createdAt">> {
92 try {
93 const [row] = await db
94 .select()
95 .from(billingPlans)
96 .where(eq(billingPlans.slug, slug))
97 .limit(1);
98 if (row) return row;
99 } catch {
100 // fall through
101 }
102 return FALLBACK_PLANS[slug] || FALLBACK_PLANS.free;
103}
104
105export type QuotaField =
106 | "storageMbUsed"
107 | "aiTokensUsedThisMonth"
108 | "bandwidthGbUsedThisMonth";
109
110export interface QuotaView {
111 planSlug: string;
112 plan: Omit<BillingPlan, "id" | "createdAt">;
113 usage: {
114 storageMbUsed: number;
115 aiTokensUsedThisMonth: number;
116 bandwidthGbUsedThisMonth: number;
117 };
118 cycleStart: Date | null;
119 percent: {
120 storage: number;
121 aiTokens: number;
122 bandwidth: number;
123 };
124}
125
126/** Loads the quota row, inserting a free-plan row on first read. */
127export async function getUserQuota(userId: string): Promise<QuotaView> {
128 let row: UserQuota | undefined;
129 try {
130 const [r] = await db
131 .select()
132 .from(userQuotas)
133 .where(eq(userQuotas.userId, userId))
134 .limit(1);
135 row = r;
136 if (!row) {
137 await db
138 .insert(userQuotas)
139 .values({ userId, planSlug: DEFAULT_PLAN_SLUG })
140 .onConflictDoNothing();
141 const [r2] = await db
142 .select()
143 .from(userQuotas)
144 .where(eq(userQuotas.userId, userId))
145 .limit(1);
146 row = r2;
147 }
148 } catch {
149 // fall through
150 }
151
152 const planSlug = row?.planSlug || DEFAULT_PLAN_SLUG;
153 const plan = await getPlan(planSlug);
154 const usage = {
155 storageMbUsed: row?.storageMbUsed || 0,
156 aiTokensUsedThisMonth: row?.aiTokensUsedThisMonth || 0,
157 bandwidthGbUsedThisMonth: row?.bandwidthGbUsedThisMonth || 0,
158 };
159 const pct = (used: number, limit: number) =>
160 limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
161
162 return {
163 planSlug,
164 plan,
165 usage,
166 cycleStart: (row?.cycleStart as Date | null) || null,
167 percent: {
168 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
169 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
170 bandwidth: pct(usage.bandwidthGbUsedThisMonth, plan.bandwidthGbMonthly),
171 },
172 };
173}
174
175export async function setUserPlan(
176 userId: string,
177 planSlug: string
178): Promise<boolean> {
179 try {
180 await db
181 .insert(userQuotas)
182 .values({ userId, planSlug })
183 .onConflictDoUpdate({
184 target: userQuotas.userId,
185 set: { planSlug, updatedAt: new Date() },
186 });
187 return true;
188 } catch (err) {
189 console.error("[billing] setUserPlan:", err);
190 return false;
191 }
192}
193
194/** Fire-and-forget counter bump. Returns the new value on success. */
195export async function bumpUsage(
196 userId: string,
197 field: QuotaField,
198 delta: number
199): Promise<number | null> {
200 if (!userId || delta === 0) return null;
201 const column =
202 field === "storageMbUsed"
203 ? userQuotas.storageMbUsed
204 : field === "aiTokensUsedThisMonth"
205 ? userQuotas.aiTokensUsedThisMonth
206 : userQuotas.bandwidthGbUsedThisMonth;
207 try {
208 await db
209 .insert(userQuotas)
210 .values({
211 userId,
212 planSlug: DEFAULT_PLAN_SLUG,
213 [field]: delta,
214 } as any)
215 .onConflictDoUpdate({
216 target: userQuotas.userId,
217 set: {
218 [field]: sql`${column} + ${delta}`,
219 updatedAt: new Date(),
220 } as any,
221 });
222 const [r] = await db
223 .select({ n: column })
224 .from(userQuotas)
225 .where(eq(userQuotas.userId, userId))
226 .limit(1);
227 return Number(r?.n || 0);
228 } catch (err) {
229 console.error("[billing] bumpUsage:", err);
230 return null;
231 }
232}
233
234/** True if the user has budget left for an action costing `amount` against `field`. */
235export async function checkQuota(
236 userId: string,
237 field: QuotaField,
238 amount: number = 1
239): Promise<boolean> {
240 try {
241 const { plan, usage } = await getUserQuota(userId);
242 if (field === "storageMbUsed")
243 return usage.storageMbUsed + amount <= plan.storageMbLimit;
244 if (field === "aiTokensUsedThisMonth")
245 return usage.aiTokensUsedThisMonth + amount <= plan.aiTokensMonthly;
246 if (field === "bandwidthGbUsedThisMonth")
247 return usage.bandwidthGbUsedThisMonth + amount <= plan.bandwidthGbMonthly;
248 return true;
249 } catch {
250 return true; // fail-open on billing errors
251 }
252}
253
254export async function repoCountForUser(userId: string): Promise<number> {
255 try {
256 const [r] = await db
257 .select({ n: sql<number>`count(*)::int` })
258 .from(repositories)
259 .where(eq(repositories.ownerId, userId));
260 return Number(r?.n || 0);
261 } catch {
262 return 0;
263 }
264}
265
266/** True if creating another repo would exceed the plan's repoLimit. */
267export async function wouldExceedRepoLimit(userId: string): Promise<boolean> {
268 try {
269 const [quota, count] = await Promise.all([
270 getUserQuota(userId),
271 repoCountForUser(userId),
272 ]);
273 return count >= quota.plan.repoLimit;
274 } catch {
275 return false;
276 }
277}
278
279/** Resets monthly counters if >30 days have passed since cycleStart. */
280export async function resetIfCycleExpired(userId: string): Promise<boolean> {
281 try {
282 const [row] = await db
283 .select({ cycleStart: userQuotas.cycleStart })
284 .from(userQuotas)
285 .where(eq(userQuotas.userId, userId))
286 .limit(1);
287 if (!row?.cycleStart) return false;
288 const age = Date.now() - new Date(row.cycleStart).getTime();
289 if (age < 30 * 24 * 60 * 60 * 1000) return false;
290 await db
291 .update(userQuotas)
292 .set({
293 aiTokensUsedThisMonth: 0,
294 bandwidthGbUsedThisMonth: 0,
295 cycleStart: new Date(),
296 updatedAt: new Date(),
297 })
298 .where(eq(userQuotas.userId, userId));
299 return true;
300 } catch {
301 return false;
302 }
303}
304
305export function formatPrice(cents: number): string {
306 if (cents === 0) return "Free";
307 return `$${(cents / 100).toFixed(2)}/mo`;
308}
Addedsrc/lib/branch-protection.ts+256−0View fileUnifiedSplit
1/**
2 * Block D5 — Branch-protection enforcement helpers.
3 *
4 * The `branch_protection` table lets owners configure per-pattern rules. Until
5 * now those rules were mostly advisory — `runAllGateChecks` read the repo-
6 * global `repoSettings` for enable flags, and the merge handler only rejected
7 * on gate-level hard failures. This module:
8 *
9 * 1. Matches a branch name against the list of protection rules for a repo
10 * (supports `*` / `**` globs via shared matcher).
11 * 2. Evaluates the matched rule against merge-time context (AI approval,
12 * human approvals, gate result) and returns a pass/fail decision with
13 * human-readable reasons.
14 *
15 * Kept minimal: no throwing, no side effects.
16 */
17
18import { and, desc, eq } from "drizzle-orm";
19import { db } from "../db";
20import {
21 branchProtection,
22 branchRequiredChecks,
23 gateRuns,
24 prComments,
25 workflowRuns,
26 workflows,
27} from "../db/schema";
28import type { BranchProtection, BranchRequiredCheck } from "../db/schema";
29import { matchGlob } from "./environments";
30
31export interface ProtectionEvalContext {
32 aiApproved: boolean;
33 humanApprovalCount: number;
34 gateResultGreen: boolean;
35 hasFailedGates: boolean;
36 /** Names of checks whose latest run passed. Used by E6 required-checks. */
37 passingCheckNames?: string[];
38}
39
40export interface ProtectionDecision {
41 allowed: boolean;
42 rule: BranchProtection | null;
43 reasons: string[];
44 missingChecks?: string[];
45}
46
47/**
48 * Find the most specific branch-protection rule that matches `branch`.
49 * Rules with exact string matches win over glob rules; among globs the first
50 * alphabetical pattern wins (deterministic). Returns null if nothing matches.
51 */
52export async function matchProtection(
53 repositoryId: string,
54 branch: string
55): Promise<BranchProtection | null> {
56 let rules: BranchProtection[];
57 try {
58 rules = await db
59 .select()
60 .from(branchProtection)
61 .where(eq(branchProtection.repositoryId, repositoryId));
62 } catch {
63 return null;
64 }
65 if (!rules || rules.length === 0) return null;
66
67 // Exact match wins.
68 const exact = rules.find((r) => r.pattern === branch);
69 if (exact) return exact;
70
71 // Otherwise first glob match (deterministic order).
72 const globs = rules
73 .filter((r) => r.pattern.includes("*"))
74 .sort((a, b) => a.pattern.localeCompare(b.pattern));
75 for (const rule of globs) {
76 if (matchGlob(branch, rule.pattern)) return rule;
77 }
78 return null;
79}
80
81/**
82 * Evaluate a protection rule against merge-time context. Does not block on
83 * a missing rule — callers can treat that as "no protection configured".
84 */
85export function evaluateProtection(
86 rule: BranchProtection | null,
87 ctx: ProtectionEvalContext,
88 requiredChecks: string[] = []
89): ProtectionDecision {
90 if (!rule) {
91 return { allowed: true, rule: null, reasons: [] };
92 }
93 const reasons: string[] = [];
94
95 if (rule.requireAiApproval && !ctx.aiApproved) {
96 reasons.push(
97 `Branch protection '${rule.pattern}' requires AI approval, but no AI review comment is approving this PR.`
98 );
99 }
100 if (rule.requireGreenGates && ctx.hasFailedGates) {
101 reasons.push(
102 `Branch protection '${rule.pattern}' requires green gates, but at least one gate is failing.`
103 );
104 }
105 if (rule.requireHumanReview && ctx.humanApprovalCount < 1) {
106 reasons.push(
107 `Branch protection '${rule.pattern}' requires at least one human review approval.`
108 );
109 }
110 if (
111 rule.requiredApprovals > 0 &&
112 ctx.humanApprovalCount < rule.requiredApprovals
113 ) {
114 reasons.push(
115 `Branch protection '${rule.pattern}' requires ${rule.requiredApprovals} approvals (have ${ctx.humanApprovalCount}).`
116 );
117 }
118
119 // E6 — required status checks matrix
120 let missingChecks: string[] | undefined;
121 if (requiredChecks.length > 0) {
122 const passing = new Set(ctx.passingCheckNames || []);
123 const missing = requiredChecks.filter((n) => !passing.has(n));
124 if (missing.length > 0) {
125 missingChecks = missing;
126 reasons.push(
127 `Branch protection '${rule.pattern}' requires these checks to pass: ${missing.join(", ")}.`
128 );
129 }
130 }
131
132 return {
133 allowed: reasons.length === 0,
134 rule,
135 reasons,
136 ...(missingChecks ? { missingChecks } : {}),
137 };
138}
139
140/**
141 * Count human (non-AI) approving PR comments. "Approval" is defined as a
142 * comment containing LGTM / ":+1:" / "approved" tokens. Best-effort; callers
143 * should treat a zero here as "unknown", not "rejected".
144 */
145export async function countHumanApprovals(pullRequestId: string): Promise<number> {
146 try {
147 const comments = await db
148 .select({ body: prComments.body, isAi: prComments.isAiReview })
149 .from(prComments)
150 .where(
151 and(
152 eq(prComments.pullRequestId, pullRequestId),
153 eq(prComments.isAiReview, false)
154 )
155 );
156 return comments.filter((c) => {
157 const b = (c.body || "").toLowerCase();
158 return (
159 b.includes("lgtm") ||
160 b.includes(":+1:") ||
161 b.includes("approved") ||
162 b.includes("👍")
163 );
164 }).length;
165 } catch {
166 return 0;
167 }
168}
169
170// ---------------------------------------------------------------------------
171// E6 — Required status checks matrix
172// ---------------------------------------------------------------------------
173
174/**
175 * List required check names for a branch protection rule. Empty array when
176 * nothing is required (the default; same semantics as "no matrix configured").
177 */
178export async function listRequiredChecks(
179 branchProtectionId: string
180): Promise<BranchRequiredCheck[]> {
181 try {
182 return await db
183 .select()
184 .from(branchRequiredChecks)
185 .where(eq(branchRequiredChecks.branchProtectionId, branchProtectionId));
186 } catch {
187 return [];
188 }
189}
190
191/**
192 * Compute the set of check names that have a passing latest result for this
193 * repo + commit. A "check" is either:
194 * - a `gate_runs` row where `status IN ('passed','repaired')` (matched by
195 * gateName), or
196 * - a `workflow_runs` row where `status = 'success'` (matched by workflow
197 * name, joined through the workflows table).
198 *
199 * Passing names are aggregated across the last N rows to survive re-runs.
200 */
201export async function passingCheckNames(
202 repositoryId: string,
203 commitSha: string | null
204): Promise<string[]> {
205 const names = new Set<string>();
206
207 try {
208 const whereClause = commitSha
209 ? and(
210 eq(gateRuns.repositoryId, repositoryId),
211 eq(gateRuns.commitSha, commitSha)
212 )
213 : eq(gateRuns.repositoryId, repositoryId);
214 const gRows = await db
215 .select({ name: gateRuns.gateName, status: gateRuns.status })
216 .from(gateRuns)
217 .where(whereClause)
218 .orderBy(desc(gateRuns.createdAt))
219 .limit(200);
220 for (const r of gRows) {
221 if (r.status === "passed" || r.status === "repaired") {
222 names.add(r.name);
223 }
224 }
225 } catch {
226 // ignore
227 }
228
229 try {
230 const whereWf = commitSha
231 ? and(
232 eq(workflowRuns.repositoryId, repositoryId),
233 eq(workflowRuns.commitSha, commitSha)
234 )
235 : eq(workflowRuns.repositoryId, repositoryId);
236 const wRows = await db
237 .select({
238 name: workflows.name,
239 status: workflowRuns.status,
240 })
241 .from(workflowRuns)
242 .innerJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
243 .where(whereWf)
244 .orderBy(desc(workflowRuns.createdAt))
245 .limit(200);
246 for (const r of wRows) {
247 if (r.status === "success") {
248 names.add(r.name);
249 }
250 }
251 } catch {
252 // ignore
253 }
254
255 return Array.from(names);
256}
Addedsrc/lib/cache.ts+123−0View fileUnifiedSplit
1/**
2 * In-memory LRU cache with TTL expiration.
3 *
4 * Used for caching git operations, session lookups,
5 * and other hot-path data to avoid redundant subprocess
6 * spawns and database roundtrips.
7 */
8
9interface CacheEntry<T> {
10 value: T;
11 expiresAt: number;
12}
13
14export class LRUCache<T> {
15 private cache = new Map<string, CacheEntry<T>>();
16 private readonly maxSize: number;
17 private readonly ttlMs: number;
18
19 constructor(maxSize: number, ttlMs: number) {
20 this.maxSize = maxSize;
21 this.ttlMs = ttlMs;
22 }
23
24 get(key: string): T | undefined {
25 const entry = this.cache.get(key);
26 if (!entry) return undefined;
27
28 if (Date.now() > entry.expiresAt) {
29 this.cache.delete(key);
30 return undefined;
31 }
32
33 // Move to end (most recently used)
34 this.cache.delete(key);
35 this.cache.set(key, entry);
36 return entry.value;
37 }
38
39 set(key: string, value: T): void {
40 // Delete first to update position
41 this.cache.delete(key);
42
43 // Evict oldest if at capacity
44 if (this.cache.size >= this.maxSize) {
45 const firstKey = this.cache.keys().next().value;
46 if (firstKey !== undefined) this.cache.delete(firstKey);
47 }
48
49 this.cache.set(key, {
50 value,
51 expiresAt: Date.now() + this.ttlMs,
52 });
53 }
54
55 invalidate(key: string): void {
56 this.cache.delete(key);
57 }
58
59 /**
60 * Invalidate all keys matching a prefix.
61 * Useful for clearing all cached data for a repo after a push.
62 */
63 invalidatePrefix(prefix: string): void {
64 for (const key of this.cache.keys()) {
65 if (key.startsWith(prefix)) {
66 this.cache.delete(key);
67 }
68 }
69 }
70
71 clear(): void {
72 this.cache.clear();
73 }
74
75 get size(): number {
76 return this.cache.size;
77 }
78}
79
80// --- Shared cache instances ---
81
82/** Git operation cache — trees, branches, commits, blobs (5 min TTL, 2000 entries) */
83export const gitCache = new LRUCache<unknown>(2000, 5 * 60 * 1000);
84
85/** Session cache — maps session tokens to user objects (2 min TTL, 500 entries) */
86export const sessionCache = new LRUCache<unknown>(500, 2 * 60 * 1000);
87
88/**
89 * Cache-through helper — returns cached value or runs the factory,
90 * caches the result, and returns it.
91 *
92 * Does NOT cache empty arrays or null — avoids stale empty results
93 * when a repo is freshly created or still receiving its first push.
94 */
95export async function cached<T>(
96 cache: LRUCache<T>,
97 key: string,
98 factory: () => Promise<T>
99): Promise<T> {
100 const existing = cache.get(key);
101 if (existing !== undefined) return existing;
102
103 const value = await factory();
104
105 // Only cache non-empty results
106 if (value !== null && value !== undefined) {
107 if (Array.isArray(value) && value.length === 0) {
108 // Don't cache empty arrays — repo may just be initializing
109 } else {
110 cache.set(key, value);
111 }
112 }
113
114 return value;
115}
116
117/**
118 * Invalidate all cached data for a repository.
119 * Call this after pushes, merges, or any repo-mutating operation.
120 */
121export function invalidateRepoCache(owner: string, repo: string): void {
122 gitCache.invalidatePrefix(`${owner}/${repo}:`);
123}
Addedsrc/lib/close-keywords.ts+54−0View fileUnifiedSplit
1/**
2 * Block J7 — Closing keywords.
3 *
4 * Parses PR body + commit messages for GitHub-style "closes #N" phrases and
5 * returns the de-duplicated set of issue numbers that should auto-close when
6 * the PR merges. Pure; zero IO.
7 *
8 * Accepted verbs (case-insensitive):
9 * close(s|d), fix(es|ed), resolve(s|d)
10 *
11 * Optional punctuation between the verb and the issue ref ("Fixes: #12",
12 * "Closes #12.") is tolerated. Refs must be bare `#<number>` — cross-repo
13 * refs like `owner/repo#12` are intentionally ignored for v1 because
14 * cross-repo auto-close requires authorisation we don't track yet.
15 */
16
17const VERB = "close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved";
18
19// Word boundary on the verb, optional colon / hyphen / whitespace, then a
20// bare `#<number>`. Ignore matches preceded by `/` (owner/repo#N cross-repo).
21const CLOSE_RE = new RegExp(
22 `(^|[^a-z0-9/])(${VERB})\\s*[:\\-]?\\s*#(\\d+)`,
23 "gi"
24);
25
26/**
27 * Extract the sorted de-duplicated list of issue numbers referenced with a
28 * closing verb in the supplied text. Returns [] on empty / no match.
29 */
30export function extractClosingRefs(text: string | null | undefined): number[] {
31 if (!text) return [];
32 const out = new Set<number>();
33 for (const m of text.matchAll(CLOSE_RE)) {
34 const n = parseInt(m[3], 10);
35 if (Number.isFinite(n) && n > 0) out.add(n);
36 }
37 return [...out].sort((a, b) => a - b);
38}
39
40/**
41 * Extract closing refs from N source strings (e.g. PR body + commit messages)
42 * and return the merged de-duped list.
43 */
44export function extractClosingRefsMulti(
45 sources: Array<string | null | undefined>
46): number[] {
47 const all = new Set<number>();
48 for (const s of sources) {
49 for (const n of extractClosingRefs(s)) all.add(n);
50 }
51 return [...all].sort((a, b) => a - b);
52}
53
54export const __internal = { CLOSE_RE };
Addedsrc/lib/codeowners.ts+193−0View fileUnifiedSplit
1/**
2 * CODEOWNERS parser + sync.
3 *
4 * Parses a CODEOWNERS file (GitHub-compatible syntax):
5 * # comments allowed
6 * * @alice
7 * src/api/** @bob @carol
8 * /docs @alice
9 * api/** @acme/backend # Block B3: team reference
10 *
11 * Ownership is resolved by last-matching rule (GitHub parity).
12 *
13 * Tokens containing a `/` are treated as team references of the form
14 * `@orgSlug/teamSlug`. They are stored as-is and expanded to the team's
15 * current membership at review-request time.
16 */
17
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import {
21 codeOwners,
22 organizations,
23 teams,
24 teamMembers,
25 users,
26} from "../db/schema";
27
28export interface OwnerRule {
29 pattern: string;
30 /**
31 * Owner tokens. Usernames are stored without the leading `@`;
32 * team references are stored as `org/team` (also no `@`).
33 * Use `isTeamToken(tok)` to distinguish.
34 */
35 owners: string[];
36}
37
38export function isTeamToken(token: string): boolean {
39 return token.includes("/");
40}
41
42export function parseCodeowners(content: string): OwnerRule[] {
43 const rules: OwnerRule[] = [];
44 for (const rawLine of content.split("\n")) {
45 const line = rawLine.replace(/#.*$/, "").trim();
46 if (!line) continue;
47 const parts = line.split(/\s+/);
48 if (parts.length < 2) continue;
49 const pattern = parts[0];
50 const owners = parts
51 .slice(1)
52 .map((o) => o.replace(/^@/, "").trim())
53 .filter(Boolean);
54 if (owners.length === 0) continue;
55 rules.push({ pattern, owners });
56 }
57 return rules;
58}
59
60/**
61 * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`.
62 * Patterns anchored at the repo root if they start with `/`.
63 */
64function patternToRegex(pattern: string): RegExp {
65 const anchored = pattern.startsWith("/");
66 let p = anchored ? pattern.slice(1) : pattern;
67 // Escape regex metacharacters except * and /
68 p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
69 p = p.replace(/\*\*/g, "__DOUBLEGLOB__");
70 p = p.replace(/\*/g, "[^/]*");
71 p = p.replace(/__DOUBLEGLOB__/g, ".*");
72 const prefix = anchored ? "^" : "^(?:.*/)?";
73 const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$";
74 return new RegExp(prefix + p + suffix);
75}
76
77/**
78 * Return owner usernames for a given file path. Last matching rule wins.
79 */
80export function ownersForPath(
81 path: string,
82 rules: OwnerRule[]
83): string[] {
84 let matched: string[] = [];
85 for (const r of rules) {
86 if (patternToRegex(r.pattern).test(path)) {
87 matched = r.owners;
88 }
89 }
90 return matched;
91}
92
93/**
94 * Replace all rules for a repo in the DB.
95 */
96export async function syncCodeowners(
97 repositoryId: string,
98 rules: OwnerRule[]
99): Promise<void> {
100 try {
101 await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId));
102 if (rules.length === 0) return;
103 await db.insert(codeOwners).values(
104 rules.map((r) => ({
105 repositoryId,
106 pathPattern: r.pattern,
107 ownerUsernames: r.owners.join(","),
108 }))
109 );
110 } catch (err) {
111 console.error("[codeowners] sync failed:", err);
112 }
113}
114
115/**
116 * Resolve a single `org/team` token to the set of usernames currently on
117 * the team. Returns `[]` on unknown org, unknown team, or DB error — never
118 * throws. Pure helper; exported for unit tests.
119 */
120export async function expandTeamToken(token: string): Promise<string[]> {
121 if (!isTeamToken(token)) return [];
122 const [orgSlug, teamSlug] = token.split("/", 2);
123 if (!orgSlug || !teamSlug) return [];
124 try {
125 const [org] = await db
126 .select({ id: organizations.id })
127 .from(organizations)
128 .where(eq(organizations.slug, orgSlug))
129 .limit(1);
130 if (!org) return [];
131 const [team] = await db
132 .select({ id: teams.id })
133 .from(teams)
134 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
135 .limit(1);
136 if (!team) return [];
137 const rows = await db
138 .select({ username: users.username })
139 .from(teamMembers)
140 .innerJoin(users, eq(users.id, teamMembers.userId))
141 .where(eq(teamMembers.teamId, team.id));
142 return rows.map((r) => r.username);
143 } catch (err) {
144 console.error("[codeowners] expandTeamToken:", err);
145 return [];
146 }
147}
148
149/**
150 * Expand a list of owner tokens to concrete usernames.
151 * - Plain usernames pass through.
152 * - `org/team` tokens are expanded to the team's current members.
153 * - Unknown tokens are dropped.
154 */
155export async function expandOwnerTokens(tokens: string[]): Promise<string[]> {
156 const out = new Set<string>();
157 for (const t of tokens) {
158 if (!t) continue;
159 if (isTeamToken(t)) {
160 for (const u of await expandTeamToken(t)) out.add(u);
161 } else {
162 out.add(t);
163 }
164 }
165 return [...out];
166}
167
168/**
169 * Given a PR's changed file list, return all unique owner usernames to
170 * auto-request review from. Team references are expanded.
171 */
172export async function reviewersForChangedFiles(
173 repositoryId: string,
174 paths: string[]
175): Promise<string[]> {
176 try {
177 const rules = await db
178 .select()
179 .from(codeOwners)
180 .where(eq(codeOwners.repositoryId, repositoryId));
181 const parsed: OwnerRule[] = rules.map((r) => ({
182 pattern: r.pathPattern,
183 owners: r.ownerUsernames.split(",").filter(Boolean),
184 }));
185 const tokens = new Set<string>();
186 for (const p of paths) {
187 for (const u of ownersForPath(p, parsed)) tokens.add(u);
188 }
189 return await expandOwnerTokens([...tokens]);
190 } catch {
191 return [];
192 }
193}
Addedsrc/lib/commit-statuses.ts+196−0View fileUnifiedSplit
1/**
2 * Block J8 — Commit statuses (GitHub-parity external CI signal).
3 *
4 * External systems POST per-commit (sha, context) statuses that appear on
5 * the commit detail view and combined-status rollup endpoints. Upsert
6 * semantics: a post with the same (repo, sha, context) replaces the prior
7 * row. State vocabulary: pending | success | failure | error.
8 */
9
10import { and, desc, eq } from "drizzle-orm";
11import { db } from "../db";
12import { commitStatuses, type CommitStatus } from "../db/schema";
13
14export type StatusState = "pending" | "success" | "failure" | "error";
15
16export const STATUS_STATES: StatusState[] = [
17 "pending",
18 "success",
19 "failure",
20 "error",
21];
22
23const CONTEXT_MAX = 120;
24const DESCRIPTION_MAX = 1000;
25const URL_MAX = 2048;
26
27export interface SetStatusInput {
28 repositoryId: string;
29 commitSha: string;
30 state: StatusState;
31 context?: string | null;
32 description?: string | null;
33 targetUrl?: string | null;
34 creatorId?: string | null;
35}
36
37/** Git short-sha / full-sha sanity check. */
38export function isValidSha(sha: string | null | undefined): boolean {
39 if (!sha) return false;
40 if (sha.length < 4 || sha.length > 40) return false;
41 return /^[a-f0-9]+$/i.test(sha);
42}
43
44export function isValidState(s: unknown): s is StatusState {
45 return typeof s === "string" && STATUS_STATES.includes(s as StatusState);
46}
47
48export function sanitiseContext(ctx: string | null | undefined): string {
49 const raw = (ctx || "default").trim();
50 if (!raw) return "default";
51 return raw.slice(0, CONTEXT_MAX);
52}
53
54function clamp(
55 s: string | null | undefined,
56 max: number
57): string | null {
58 if (!s) return null;
59 const t = s.toString();
60 if (!t.length) return null;
61 return t.slice(0, max);
62}
63
64/**
65 * Upsert a commit status. Returns the final row. Throws only on bad state
66 * input — callers normalise via `isValidState` first.
67 */
68export async function setStatus(
69 input: SetStatusInput
70): Promise<CommitStatus | null> {
71 if (!isValidState(input.state)) return null;
72 if (!isValidSha(input.commitSha)) return null;
73 const ctx = sanitiseContext(input.context);
74 const sha = input.commitSha.toLowerCase();
75 const description = clamp(input.description, DESCRIPTION_MAX);
76 const targetUrl = clamp(input.targetUrl, URL_MAX);
77 // Delete-then-insert keeps the table simple without relying on ON CONFLICT.
78 await db
79 .delete(commitStatuses)
80 .where(
81 and(
82 eq(commitStatuses.repositoryId, input.repositoryId),
83 eq(commitStatuses.commitSha, sha),
84 eq(commitStatuses.context, ctx)
85 )
86 );
87 const [row] = await db
88 .insert(commitStatuses)
89 .values({
90 repositoryId: input.repositoryId,
91 commitSha: sha,
92 state: input.state,
93 context: ctx,
94 description,
95 targetUrl,
96 creatorId: input.creatorId || null,
97 })
98 .returning();
99 return row || null;
100}
101
102/** List statuses for a commit, newest first. */
103export async function listStatuses(
104 repositoryId: string,
105 commitSha: string
106): Promise<CommitStatus[]> {
107 if (!isValidSha(commitSha)) return [];
108 return db
109 .select()
110 .from(commitStatuses)
111 .where(
112 and(
113 eq(commitStatuses.repositoryId, repositoryId),
114 eq(commitStatuses.commitSha, commitSha.toLowerCase())
115 )
116 )
117 .orderBy(desc(commitStatuses.updatedAt));
118}
119
120export interface CombinedStatus {
121 state: StatusState | "success";
122 total: number;
123 counts: Record<StatusState, number>;
124 contexts: Array<{
125 context: string;
126 state: StatusState;
127 description: string | null;
128 targetUrl: string | null;
129 updatedAt: Date;
130 }>;
131}
132
133/**
134 * Reduce a list of statuses to a single roll-up state.
135 * any failure/error → "failure"
136 * any pending → "pending"
137 * all success → "success"
138 * empty list → "success" (no signal means no blocker)
139 *
140 * Pure — exposed for tests.
141 */
142export function reduceCombined(states: StatusState[]): StatusState {
143 if (!states.length) return "success" as StatusState;
144 if (states.some((s) => s === "failure" || s === "error")) return "failure";
145 if (states.some((s) => s === "pending")) return "pending";
146 return "success";
147}
148
149/**
150 * GitHub-style combined status. Groups statuses by context (latest per
151 * context wins — which the upsert guarantees anyway) and reduces to a
152 * single state.
153 */
154export async function combinedStatus(
155 repositoryId: string,
156 commitSha: string
157): Promise<CombinedStatus> {
158 const rows = await listStatuses(repositoryId, commitSha);
159 const byContext = new Map<string, CommitStatus>();
160 for (const r of rows) {
161 const prev = byContext.get(r.context);
162 if (!prev || prev.updatedAt < r.updatedAt) byContext.set(r.context, r);
163 }
164 const latest = [...byContext.values()];
165 const counts: Record<StatusState, number> = {
166 pending: 0,
167 success: 0,
168 failure: 0,
169 error: 0,
170 };
171 for (const r of latest) {
172 if (isValidState(r.state)) counts[r.state]++;
173 }
174 const state = reduceCombined(latest.map((r) => r.state as StatusState));
175 return {
176 state,
177 total: latest.length,
178 counts,
179 contexts: latest
180 .sort((a, b) => a.context.localeCompare(b.context))
181 .map((r) => ({
182 context: r.context,
183 state: r.state as StatusState,
184 description: r.description,
185 targetUrl: r.targetUrl,
186 updatedAt: r.updatedAt,
187 })),
188 };
189}
190
191export const __internal = {
192 CONTEXT_MAX,
193 DESCRIPTION_MAX,
194 URL_MAX,
195 clamp,
196};
Modifiedsrc/lib/config.ts+47−0View fileUnifiedSplit
1313 get gatetestUrl() {
1414 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
1515 },
16 get gatetestApiKey() {
17 return process.env.GATETEST_API_KEY || "";
18 },
1619 get crontechDeployUrl() {
1720 return (
1821 process.env.CRONTECH_DEPLOY_URL ||
1922 "https://crontech.ai/api/trpc/tenant.deploy"
2023 );
2124 },
25 get anthropicApiKey() {
26 return process.env.ANTHROPIC_API_KEY || "";
27 },
28 /** Email provider: "log" (dev, writes to stderr) or "resend" (HTTPS). */
29 get emailProvider() {
30 const v = (process.env.EMAIL_PROVIDER || "log").toLowerCase();
31 return v === "resend" ? "resend" : "log";
32 },
33 /** "From" address for outbound email. */
34 get emailFrom() {
35 return process.env.EMAIL_FROM || "gluecron <no-reply@gluecron.local>";
36 },
37 /** Resend API key (only used when EMAIL_PROVIDER=resend). */
38 get resendApiKey() {
39 return process.env.RESEND_API_KEY || "";
40 },
41 /** Canonical base URL for outbound links in emails + webhooks. */
42 get appBaseUrl() {
43 return (process.env.APP_BASE_URL || "http://localhost:3000").replace(
44 /\/+$/,
45 ""
46 );
47 },
48 /**
49 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
50 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
51 * replayed against another, so this must be stable.
52 */
53 get webauthnRpId() {
54 if (process.env.WEBAUTHN_RP_ID) return process.env.WEBAUTHN_RP_ID;
55 try {
56 return new URL(this.appBaseUrl).hostname;
57 } catch {
58 return "localhost";
59 }
60 },
61 /** WebAuthn expected origin (must include scheme + port). */
62 get webauthnOrigin() {
63 return process.env.WEBAUTHN_ORIGIN || this.appBaseUrl;
64 },
65 /** Human-facing RP name shown by the browser. */
66 get webauthnRpName() {
67 return process.env.WEBAUTHN_RP_NAME || "gluecron";
68 },
2269};
Addedsrc/lib/dep-updater.ts+590−0View fileUnifiedSplit
1/**
2 * Block D2 — AI-native dependency updater (Dependabot equivalent).
3 *
4 * Reads a repo's `package.json`, queries the npm registry for updates,
5 * and (best-effort) opens a pull request with the bumped versions.
6 *
7 * Implementation notes:
8 * - Pure-function helpers (parseManifest / planUpdates / applyBumps) are
9 * fully covered by unit tests and make no network or disk I/O.
10 * - The git plumbing (creating a branch + commit + PR row) is wired in
11 * `runDepUpdateRun`. It uses `Bun.spawn` to drive `git hash-object`,
12 * `git mktree`, `git commit-tree`, `git update-ref` — the same pattern
13 * used by `src/routes/editor.tsx`. If any step fails, the run is
14 * recorded with `status:"failed"` and the function never throws.
15 * - If spawning git fails for any reason (missing binary, missing repo,
16 * etc.), the run still records the planned bumps in `attemptedBumps`
17 * so the UI can show what *would* have been done.
18 */
19
20import { eq, and, sql } from "drizzle-orm";
21import { db } from "../db";
22import {
23 depUpdateRuns,
24 pullRequests,
25 repositories,
26 users,
27} from "../db/schema";
28import {
29 getBlob,
30 getDefaultBranch,
31 getRepoPath,
32 resolveRef,
33} from "../git/repository";
34
35export type Bump = {
36 name: string;
37 from: string;
38 to: string;
39 kind: "dep" | "dev";
40 major: boolean;
41};
42
43export type ParsedManifest = {
44 dependencies: Record<string, string>;
45 devDependencies: Record<string, string>;
46 name?: string;
47};
48
49/**
50 * Tolerant JSON parser. Returns empty structures on any parse error.
51 */
52export function parseManifest(text: string): ParsedManifest {
53 const empty: ParsedManifest = { dependencies: {}, devDependencies: {} };
54 if (!text || typeof text !== "string") return empty;
55 try {
56 const raw = JSON.parse(text);
57 if (!raw || typeof raw !== "object") return empty;
58 const deps =
59 raw.dependencies && typeof raw.dependencies === "object"
60 ? (raw.dependencies as Record<string, string>)
61 : {};
62 const dev =
63 raw.devDependencies && typeof raw.devDependencies === "object"
64 ? (raw.devDependencies as Record<string, string>)
65 : {};
66 // Filter to string values only.
67 const dependencies: Record<string, string> = {};
68 for (const [k, v] of Object.entries(deps)) {
69 if (typeof v === "string") dependencies[k] = v;
70 }
71 const devDependencies: Record<string, string> = {};
72 for (const [k, v] of Object.entries(dev)) {
73 if (typeof v === "string") devDependencies[k] = v;
74 }
75 return {
76 dependencies,
77 devDependencies,
78 name: typeof raw.name === "string" ? raw.name : undefined,
79 };
80 } catch {
81 return empty;
82 }
83}
84
85/**
86 * Query the npm registry for the latest version of a package.
87 * Returns null on any network / parse error.
88 */
89export async function queryNpmLatest(
90 pkgName: string
91): Promise<string | null> {
92 try {
93 const safe = encodeURIComponent(pkgName).replace(/%40/g, "@");
94 const res = await fetch(`https://registry.npmjs.org/${safe}/latest`, {
95 headers: { accept: "application/json" },
96 });
97 if (!res.ok) return null;
98 const data = (await res.json()) as { version?: unknown };
99 if (typeof data.version !== "string") return null;
100 return data.version;
101 } catch {
102 return null;
103 }
104}
105
106/**
107 * Extract a pure semver x.y.z from a range string like `^1.2.3`, `~1.2.3`,
108 * `1.2.3`, `>=1.2.3 <2`, etc. Returns null for non-semver strings such as
109 * `workspace:*`, `github:foo/bar`, `file:./x`, `latest`, `*`, or `https://…`.
110 */
111function extractSemver(range: string): { major: number; minor: number; patch: number } | null {
112 if (typeof range !== "string") return null;
113 const trimmed = range.trim();
114 if (!trimmed) return null;
115 // Reject obvious non-registry sources.
116 if (/^(workspace:|github:|git\+|git:|file:|link:|http:|https:|npm:)/i.test(trimmed)) {
117 return null;
118 }
119 if (trimmed === "*" || /^latest$/i.test(trimmed)) return null;
120 const m = trimmed.match(/(\d+)\.(\d+)\.(\d+)/);
121 if (!m) return null;
122 return {
123 major: parseInt(m[1], 10),
124 minor: parseInt(m[2], 10),
125 patch: parseInt(m[3], 10),
126 };
127}
128
129function cmpSemver(
130 a: { major: number; minor: number; patch: number },
131 b: { major: number; minor: number; patch: number }
132): number {
133 if (a.major !== b.major) return a.major - b.major;
134 if (a.minor !== b.minor) return a.minor - b.minor;
135 return a.patch - b.patch;
136}
137
138/**
139 * Walk a manifest and produce a list of bumps. Skips packages with
140 * non-semver range strings, no-op bumps, and downgrades.
141 */
142export async function planUpdates(
143 manifest: {
144 dependencies: Record<string, string>;
145 devDependencies: Record<string, string>;
146 },
147 opts?: { fetchLatest?: (name: string) => Promise<string | null> }
148): Promise<Bump[]> {
149 const fetchLatest = opts?.fetchLatest ?? queryNpmLatest;
150 const bumps: Bump[] = [];
151
152 const walk = async (
153 bucket: Record<string, string>,
154 kind: "dep" | "dev"
155 ) => {
156 for (const [name, current] of Object.entries(bucket)) {
157 const currentParsed = extractSemver(current);
158 if (!currentParsed) continue;
159 const latest = await fetchLatest(name);
160 if (!latest) continue;
161 const latestParsed = extractSemver(latest);
162 if (!latestParsed) continue;
163 // Skip no-ops and downgrades.
164 if (cmpSemver(latestParsed, currentParsed) <= 0) continue;
165 bumps.push({
166 name,
167 from: current,
168 to: latest,
169 kind,
170 major: latestParsed.major > currentParsed.major,
171 });
172 }
173 };
174
175 await walk(manifest.dependencies || {}, "dep");
176 await walk(manifest.devDependencies || {}, "dev");
177
178 return bumps;
179}
180
181/**
182 * Rewrite `package.json` text in-place, preserving formatting as much as
183 * possible. For each bump, locates the matching `"name": "..."` line inside
184 * the correct stanza (`dependencies` vs `devDependencies`) and rewrites
185 * only the version string. Preserves the trailing newline.
186 */
187export function applyBumps(
188 manifestText: string,
189 bumps: Array<{ name: string; to: string; kind: "dep" | "dev" }>
190): string {
191 if (!manifestText) return manifestText;
192
193 const hadTrailingNewline = manifestText.endsWith("\n");
194 let text = manifestText;
195
196 // Preserve the user's original version prefix (^ / ~ / >= / exact).
197 const prefixOf = (val: string): string => {
198 const m = val.match(/^\s*([\^~><=]+)/);
199 return m ? m[1] : "";
200 };
201
202 const stanzaRange = (
203 body: string,
204 key: "dependencies" | "devDependencies"
205 ): { start: number; end: number } | null => {
206 // Find the "dependencies": { ... } block. Matches from the key through
207 // its matching closing brace, accounting for nested braces (shouldn't
208 // occur in package.json, but defensive).
209 const keyRe = new RegExp(`"${key}"\\s*:\\s*\\{`);
210 const keyMatch = keyRe.exec(body);
211 if (!keyMatch) return null;
212 const openIdx = body.indexOf("{", keyMatch.index);
213 if (openIdx === -1) return null;
214 let depth = 1;
215 let i = openIdx + 1;
216 for (; i < body.length && depth > 0; i++) {
217 const ch = body[i];
218 if (ch === "{") depth++;
219 else if (ch === "}") depth--;
220 }
221 if (depth !== 0) return null;
222 return { start: openIdx + 1, end: i - 1 }; // content between braces
223 };
224
225 for (const bump of bumps) {
226 const key = bump.kind === "dep" ? "dependencies" : "devDependencies";
227 const range = stanzaRange(text, key);
228 if (!range) continue;
229 const before = text.slice(0, range.start);
230 const inside = text.slice(range.start, range.end);
231 const after = text.slice(range.end);
232
233 // Match `"<name>": "<version>"` inside the stanza. Escape special chars
234 // in name (npm scopes contain `@` and `/`).
235 const escapedName = bump.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
236 const lineRe = new RegExp(
237 `("${escapedName}"\\s*:\\s*")([^"]+)(")`
238 );
239 const m = lineRe.exec(inside);
240 if (!m) continue;
241 const prefix = prefixOf(m[2]);
242 const replacement = `${m[1]}${prefix}${bump.to}${m[3]}`;
243 const newInside =
244 inside.slice(0, m.index) +
245 replacement +
246 inside.slice(m.index + m[0].length);
247 text = before + newInside + after;
248 }
249
250 if (hadTrailingNewline && !text.endsWith("\n")) text += "\n";
251 if (!hadTrailingNewline && text.endsWith("\n") && !manifestText.endsWith("\n")) {
252 // Shouldn't happen, but keep invariant strict.
253 text = text.replace(/\n+$/, "");
254 }
255 return text;
256}
257
258/**
259 * Format a markdown table describing the applied bumps — used as the PR body.
260 */
261function renderBumpTable(bumps: Bump[]): string {
262 const lines: string[] = [];
263 lines.push("Automated dependency update by GlueCron.");
264 lines.push("");
265 lines.push("| Package | From | To | Kind | Major? |");
266 lines.push("| --- | --- | --- | --- | --- |");
267 for (const b of bumps) {
268 lines.push(
269 `| \`${b.name}\` | ${b.from} | ${b.to} | ${b.kind} | ${b.major ? "yes" : "no"} |`
270 );
271 }
272 lines.push("");
273 lines.push(
274 "_This PR was opened by the GlueCron AI dependency updater. Review carefully before merging — major bumps may contain breaking changes._"
275 );
276 return lines.join("\n");
277}
278
279/**
280 * Spawn helper used for git plumbing. Returns trimmed stdout and exit code.
281 * Never throws — callers check the exit code.
282 */
283async function spawn(
284 cmd: string[],
285 cwd: string,
286 stdin?: string,
287 env?: Record<string, string>
288): Promise<{ stdout: string; stderr: string; exitCode: number }> {
289 try {
290 const proc = Bun.spawn(cmd, {
291 cwd,
292 stdout: "pipe",
293 stderr: "pipe",
294 stdin: stdin !== undefined ? "pipe" : undefined,
295 env: { ...process.env, ...(env || {}) },
296 });
297 if (stdin !== undefined && proc.stdin) {
298 (proc.stdin as WritableStreamDefaultWriter<Uint8Array> | any).write(
299 new TextEncoder().encode(stdin)
300 );
301 (proc.stdin as any).end();
302 }
303 const [stdout, stderr] = await Promise.all([
304 new Response(proc.stdout).text(),
305 new Response(proc.stderr).text(),
306 ]);
307 const exitCode = await proc.exited;
308 return { stdout: stdout.trim(), stderr, exitCode };
309 } catch (err: any) {
310 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
311 }
312}
313
314/**
315 * Write updated file content onto a new branch by building a fresh tree
316 * from the current tree entries with one blob replaced, committing it, and
317 * pointing the new branch ref at the new commit. Returns `{ ok: true,
318 * commitSha }` or `{ ok: false, error }`.
319 */
320async function writeFileToBranch(
321 repoDir: string,
322 baseRef: string,
323 newBranch: string,
324 filePath: string,
325 content: string,
326 authorName: string,
327 authorEmail: string,
328 message: string
329): Promise<{ ok: true; commitSha: string } | { ok: false; error: string }> {
330 // 1. Hash the new blob.
331 const hashed = await spawn(
332 ["git", "hash-object", "-w", "--stdin"],
333 repoDir,
334 content
335 );
336 if (hashed.exitCode !== 0 || !hashed.stdout) {
337 return { ok: false, error: `hash-object failed: ${hashed.stderr}` };
338 }
339 const blobSha = hashed.stdout;
340
341 // 2. Read the existing tree at baseRef.
342 const lsTree = await spawn(["git", "ls-tree", "-r", baseRef], repoDir);
343 if (lsTree.exitCode !== 0) {
344 return { ok: false, error: `ls-tree failed: ${lsTree.stderr}` };
345 }
346 const entries = lsTree.stdout.split("\n").filter(Boolean);
347 let replaced = false;
348 const rewritten = entries
349 .map((line) => {
350 const match = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
351 if (!match) return line;
352 if (match[4] === filePath) {
353 replaced = true;
354 return `${match[1]} blob ${blobSha}\t${match[4]}`;
355 }
356 return line;
357 })
358 .join("\n");
359 const treeInput = replaced
360 ? rewritten + "\n"
361 : rewritten + (entries.length ? "\n" : "") + `100644 blob ${blobSha}\t${filePath}\n`;
362
363 // 3. Build the new tree.
364 const mktree = await spawn(["git", "mktree"], repoDir, treeInput);
365 if (mktree.exitCode !== 0 || !mktree.stdout) {
366 return { ok: false, error: `mktree failed: ${mktree.stderr}` };
367 }
368 const newTreeSha = mktree.stdout;
369
370 // 4. Look up the parent commit.
371 const parent = await spawn(["git", "rev-parse", baseRef], repoDir);
372 if (parent.exitCode !== 0 || !parent.stdout) {
373 return { ok: false, error: `rev-parse failed: ${parent.stderr}` };
374 }
375 const parentSha = parent.stdout;
376
377 // 5. Create the commit.
378 const commit = await spawn(
379 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
380 repoDir,
381 undefined,
382 {
383 GIT_AUTHOR_NAME: authorName,
384 GIT_AUTHOR_EMAIL: authorEmail,
385 GIT_COMMITTER_NAME: authorName,
386 GIT_COMMITTER_EMAIL: authorEmail,
387 }
388 );
389 if (commit.exitCode !== 0 || !commit.stdout) {
390 return { ok: false, error: `commit-tree failed: ${commit.stderr}` };
391 }
392 const commitSha = commit.stdout;
393
394 // 6. Point the new branch at the new commit.
395 const update = await spawn(
396 ["git", "update-ref", `refs/heads/${newBranch}`, commitSha],
397 repoDir
398 );
399 if (update.exitCode !== 0) {
400 return { ok: false, error: `update-ref failed: ${update.stderr}` };
401 }
402
403 return { ok: true, commitSha };
404}
405
406/**
407 * Main orchestrator — plan + apply + commit + PR. Never throws.
408 *
409 * Any failure is recorded on the run row with `status:"failed"`.
410 */
411export async function runDepUpdateRun(params: {
412 repositoryId: string;
413 owner: string;
414 repo: string;
415 userId: string | null;
416 manifestPath?: string;
417}): Promise<{ runId: string | null; status: string }> {
418 const {
419 repositoryId,
420 owner,
421 repo,
422 userId,
423 manifestPath = "package.json",
424 } = params;
425
426 // 1. Insert the run row in "running" state so the UI has something to show.
427 let runId: string | null = null;
428 try {
429 const [inserted] = await db
430 .insert(depUpdateRuns)
431 .values({
432 repositoryId,
433 status: "running",
434 ecosystem: "npm",
435 manifestPath,
436 triggeredBy: userId,
437 })
438 .returning();
439 runId = inserted?.id ?? null;
440 } catch (err) {
441 // If the DB isn't reachable, we've already lost — bail with failure.
442 return { runId: null, status: "failed" };
443 }
444
445 const finish = async (
446 patch: Partial<typeof depUpdateRuns.$inferInsert> & { status: string }
447 ) => {
448 if (!runId) return;
449 try {
450 await db
451 .update(depUpdateRuns)
452 .set({ ...patch, completedAt: new Date() })
453 .where(eq(depUpdateRuns.id, runId));
454 } catch {
455 // Swallow — we already did our best.
456 }
457 };
458
459 try {
460 // 2. Load the manifest from the default branch.
461 const branch = (await getDefaultBranch(owner, repo)) || "main";
462 const blob = await getBlob(owner, repo, branch, manifestPath);
463 if (!blob || blob.isBinary) {
464 await finish({
465 status: "failed",
466 errorMessage: `Could not read ${manifestPath} on ${branch}`,
467 });
468 return { runId, status: "failed" };
469 }
470
471 const manifest = parseManifest(blob.content);
472 const bumps = await planUpdates(manifest);
473
474 const attempted = JSON.stringify(bumps);
475
476 if (bumps.length === 0) {
477 await finish({
478 status: "no_updates",
479 attemptedBumps: attempted,
480 appliedBumps: "[]",
481 });
482 return { runId, status: "no_updates" };
483 }
484
485 // 3. Apply the bumps to the manifest text.
486 const rewritten = applyBumps(blob.content, bumps);
487
488 // 4. Create a new branch with the rewritten manifest.
489 const repoDir = getRepoPath(owner, repo);
490 const stamp = new Date().toISOString().replace(/[:.]/g, "-");
491 const branchName = `gluecron/dep-update-${stamp}`;
492
493 const authorName = "GlueCron Bot";
494 const authorEmail = "bot@gluecron.com";
495 const commitMessage = `chore(deps): bump ${bumps.length} package${bumps.length === 1 ? "" : "s"}`;
496
497 const writeResult = await writeFileToBranch(
498 repoDir,
499 branch,
500 branchName,
501 manifestPath,
502 rewritten,
503 authorName,
504 authorEmail,
505 commitMessage
506 );
507
508 if (!writeResult.ok) {
509 // Record the plan but note the failure — useful for the UI.
510 await finish({
511 status: "failed",
512 attemptedBumps: attempted,
513 appliedBumps: "[]",
514 errorMessage: writeResult.error,
515 });
516 return { runId, status: "failed" };
517 }
518
519 // 5. Insert the PR row. `number` is a serial column in the schema so
520 // the DB assigns it; we just read it back from the RETURNING row.
521 let prNumber: number | null = null;
522 try {
523 const authorId = userId ?? (await resolveBotAuthorId(owner));
524 if (!authorId) throw new Error("no author");
525 const prBody = renderBumpTable(bumps);
526 const prTitle = `chore(deps): bump ${bumps.length} package${bumps.length === 1 ? "" : "s"}`;
527 const [pr] = await db
528 .insert(pullRequests)
529 .values({
530 repositoryId,
531 authorId,
532 title: prTitle,
533 body: prBody,
534 baseBranch: branch,
535 headBranch: branchName,
536 isDraft: false,
537 })
538 .returning();
539 prNumber = pr?.number ?? null;
540 } catch (err: any) {
541 await finish({
542 status: "failed",
543 attemptedBumps: attempted,
544 appliedBumps: attempted,
545 branchName,
546 errorMessage: `PR insert failed: ${String(err?.message || err)}`,
547 });
548 return { runId, status: "failed" };
549 }
550
551 await finish({
552 status: "success",
553 attemptedBumps: attempted,
554 appliedBumps: attempted,
555 branchName,
556 prNumber: prNumber ?? undefined,
557 });
558 return { runId, status: "success" };
559 } catch (err: any) {
560 await finish({
561 status: "failed",
562 errorMessage: String(err?.message || err),
563 });
564 return { runId, status: "failed" };
565 }
566}
567
568/**
569 * Fallback author for bot-authored PRs — the repo owner. We don't have a
570 * dedicated bot user row, and the `authorId` column is NOT NULL.
571 */
572async function resolveBotAuthorId(ownerName: string): Promise<string | null> {
573 try {
574 const [row] = await db
575 .select({ id: users.id })
576 .from(users)
577 .where(eq(users.username, ownerName))
578 .limit(1);
579 return row?.id ?? null;
580 } catch {
581 return null;
582 }
583}
584
585export const __internal = {
586 extractSemver,
587 cmpSemver,
588 renderBumpTable,
589 writeFileToBranch,
590};
Addedsrc/lib/deps.ts+602−0View fileUnifiedSplit
1/**
2 * Block J1 — Dependency graph.
3 *
4 * Parses common manifest files and records each dependency as a row in
5 * `repo_dependencies`. Per-reindex we REPLACE the whole set for the repo,
6 * so querying the table is always a snapshot.
7 *
8 * Supported ecosystems:
9 * - npm → package.json (dependencies + devDependencies)
10 * - pypi → requirements.txt, pyproject.toml (project.dependencies)
11 * - go → go.mod (require blocks)
12 * - cargo → Cargo.toml ([dependencies] + [dev-dependencies])
13 * - rubygems → Gemfile (gem "name", "version")
14 * - composer → composer.json (require + require-dev)
15 *
16 * We deliberately do not resolve transitive dependencies — we only surface
17 * what's declared in the manifest files. That's the GitHub "dependency
18 * graph" contract: authoritative wrt the repo, not wrt the runtime.
19 */
20
21import { and, desc, eq } from "drizzle-orm";
22import { db } from "../db";
23import {
24 repoDependencies,
25 repositories,
26 users,
27 type RepoDependency,
28} from "../db/schema";
29import {
30 getBlob,
31 getDefaultBranch,
32 getTree,
33 resolveRef,
34 type GitTreeEntry,
35} from "../git/repository";
36
37// ----------------------------------------------------------------------------
38// Types
39// ----------------------------------------------------------------------------
40
41export type Ecosystem =
42 | "npm"
43 | "pypi"
44 | "go"
45 | "rubygems"
46 | "cargo"
47 | "composer";
48
49export interface ParsedDep {
50 ecosystem: Ecosystem;
51 name: string;
52 versionSpec: string | null;
53 isDev: boolean;
54}
55
56/**
57 * Filename → (content) → ParsedDep[]. Keys are the basename; we match
58 * case-insensitively. `manifestPath` (the full path) is attached at a
59 * higher level.
60 */
61const PARSERS: Record<string, (content: string) => ParsedDep[]> = {
62 "package.json": parsePackageJson,
63 "requirements.txt": parseRequirementsTxt,
64 "pyproject.toml": parsePyprojectToml,
65 "go.mod": parseGoMod,
66 "cargo.toml": parseCargoToml,
67 gemfile: parseGemfile,
68 "composer.json": parseComposerJson,
69};
70
71// ----------------------------------------------------------------------------
72// Individual parsers — each is defensive; one bad file does not abort indexing
73// ----------------------------------------------------------------------------
74
75export function parsePackageJson(content: string): ParsedDep[] {
76 const out: ParsedDep[] = [];
77 let obj: any;
78 try {
79 obj = JSON.parse(content);
80 } catch {
81 return out;
82 }
83 if (!obj || typeof obj !== "object") return out;
84 for (const [key, isDev] of [
85 ["dependencies", false],
86 ["devDependencies", true],
87 ["peerDependencies", false],
88 ["optionalDependencies", false],
89 ] as const) {
90 const deps = obj[key];
91 if (!deps || typeof deps !== "object") continue;
92 for (const [name, spec] of Object.entries(deps)) {
93 if (typeof name !== "string" || !name) continue;
94 out.push({
95 ecosystem: "npm",
96 name,
97 versionSpec: typeof spec === "string" ? spec : null,
98 isDev,
99 });
100 }
101 }
102 return out;
103}
104
105export function parseRequirementsTxt(content: string): ParsedDep[] {
106 const out: ParsedDep[] = [];
107 for (const rawLine of content.split(/\r?\n/)) {
108 const line = rawLine.split("#")[0].trim();
109 if (!line) continue;
110 // Skip editable / url installs
111 if (line.startsWith("-e ") || line.startsWith("--") || line.startsWith("git+")) continue;
112 // Match "name", "name==1.2.3", "name>=1.0,<2.0", "name[extra]==1.0"
113 const m = line.match(
114 /^([A-Za-z0-9_][A-Za-z0-9_.\-]*)(?:\[[^\]]+\])?\s*([<>=!~].+)?$/
115 );
116 if (!m) continue;
117 out.push({
118 ecosystem: "pypi",
119 name: m[1],
120 versionSpec: m[2] ? m[2].trim() : null,
121 isDev: false,
122 });
123 }
124 return out;
125}
126
127export function parsePyprojectToml(content: string): ParsedDep[] {
128 const out: ParsedDep[] = [];
129 // We only look for `[project]` → `dependencies = [...]` and
130 // `[project.optional-dependencies]` → `dev = [...]`. Full TOML parsing
131 // is overkill — a regex-over-sections pass is sufficient here.
132 const sections = splitTomlSections(content);
133 const projectDeps = extractTomlArray(sections.project, "dependencies");
134 for (const dep of projectDeps) {
135 const parsed = pythonRequirementToDep(dep, false);
136 if (parsed) out.push(parsed);
137 }
138 const optDeps = sections["project.optional-dependencies"] || "";
139 for (const line of optDeps.split(/\r?\n/)) {
140 const keyMatch = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*\[/);
141 if (keyMatch) {
142 // Consume until closing ]
143 const start = line.indexOf("[");
144 const tail = line.slice(start);
145 const closeIdx = tail.indexOf("]");
146 if (closeIdx > -1) {
147 const inner = tail.slice(1, closeIdx);
148 for (const item of splitTomlArrayItems(inner)) {
149 const parsed = pythonRequirementToDep(item, true);
150 if (parsed) out.push(parsed);
151 }
152 }
153 }
154 }
155 return out;
156}
157
158function pythonRequirementToDep(
159 raw: string,
160 isDev: boolean
161): ParsedDep | null {
162 const s = raw.trim().replace(/^["']|["']$/g, "");
163 if (!s) return null;
164 const m = s.match(
165 /^([A-Za-z0-9_][A-Za-z0-9_.\-]*)(?:\[[^\]]+\])?\s*([<>=!~].+)?$/
166 );
167 if (!m) return null;
168 return {
169 ecosystem: "pypi",
170 name: m[1],
171 versionSpec: m[2] ? m[2].trim() : null,
172 isDev,
173 };
174}
175
176export function parseGoMod(content: string): ParsedDep[] {
177 const out: ParsedDep[] = [];
178 // `require (` block or single-line `require foo v1.0.0`
179 const blockMatch = content.match(/require\s*\(([\s\S]*?)\)/);
180 const lines: string[] = [];
181 if (blockMatch) {
182 lines.push(...blockMatch[1].split(/\r?\n/));
183 }
184 for (const rawLine of content.split(/\r?\n/)) {
185 const m = rawLine.match(/^\s*require\s+(\S+)\s+(\S+)/);
186 if (m) lines.push(`${m[1]} ${m[2]}`);
187 }
188 for (const rawLine of lines) {
189 const line = rawLine.split("//")[0].trim();
190 if (!line) continue;
191 const m = line.match(/^(\S+)\s+(\S+)(\s+\/\/ indirect)?$/);
192 if (!m) continue;
193 out.push({
194 ecosystem: "go",
195 name: m[1],
196 versionSpec: m[2],
197 isDev: false,
198 });
199 }
200 return out;
201}
202
203export function parseCargoToml(content: string): ParsedDep[] {
204 const out: ParsedDep[] = [];
205 const sections = splitTomlSections(content);
206 for (const [header, isDev] of [
207 ["dependencies", false],
208 ["dev-dependencies", true],
209 ["build-dependencies", false],
210 ] as const) {
211 const body = sections[header];
212 if (!body) continue;
213 for (const line of body.split(/\r?\n/)) {
214 const trimmed = line.split("#")[0].trim();
215 if (!trimmed) continue;
216 // foo = "1.2.3" OR foo = { version = "1.2.3" }
217 const m = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
218 if (!m) continue;
219 const name = m[1];
220 const rhs = m[2].trim();
221 let versionSpec: string | null = null;
222 if (rhs.startsWith('"') || rhs.startsWith("'")) {
223 const q = rhs[0];
224 const end = rhs.indexOf(q, 1);
225 if (end > 0) versionSpec = rhs.slice(1, end);
226 } else if (rhs.startsWith("{")) {
227 const vm = rhs.match(/version\s*=\s*["']([^"']+)["']/);
228 if (vm) versionSpec = vm[1];
229 }
230 out.push({
231 ecosystem: "cargo",
232 name,
233 versionSpec,
234 isDev,
235 });
236 }
237 }
238 return out;
239}
240
241export function parseGemfile(content: string): ParsedDep[] {
242 const out: ParsedDep[] = [];
243 let inDevGroup = false;
244 for (const rawLine of content.split(/\r?\n/)) {
245 const line = rawLine.split("#")[0].trim();
246 if (!line) continue;
247 // group :development, :test do
248 if (/^group\s+.*(:development|:test)/i.test(line)) {
249 inDevGroup = true;
250 continue;
251 }
252 if (/^end\b/.test(line)) {
253 inDevGroup = false;
254 continue;
255 }
256 const m = line.match(
257 /^gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/
258 );
259 if (!m) continue;
260 out.push({
261 ecosystem: "rubygems",
262 name: m[1],
263 versionSpec: m[2] || null,
264 isDev: inDevGroup,
265 });
266 }
267 return out;
268}
269
270export function parseComposerJson(content: string): ParsedDep[] {
271 const out: ParsedDep[] = [];
272 let obj: any;
273 try {
274 obj = JSON.parse(content);
275 } catch {
276 return out;
277 }
278 if (!obj || typeof obj !== "object") return out;
279 for (const [key, isDev] of [
280 ["require", false],
281 ["require-dev", true],
282 ] as const) {
283 const deps = obj[key];
284 if (!deps || typeof deps !== "object") continue;
285 for (const [name, spec] of Object.entries(deps)) {
286 if (!name || name === "php") continue;
287 out.push({
288 ecosystem: "composer",
289 name,
290 versionSpec: typeof spec === "string" ? spec : null,
291 isDev,
292 });
293 }
294 }
295 return out;
296}
297
298// ----------------------------------------------------------------------------
299// TOML helpers — intentionally minimal
300// ----------------------------------------------------------------------------
301
302function splitTomlSections(content: string): Record<string, string> {
303 const out: Record<string, string> = { __root__: "" };
304 let current = "__root__";
305 for (const rawLine of content.split(/\r?\n/)) {
306 const headerMatch = rawLine.match(/^\s*\[([^\]]+)\]\s*$/);
307 if (headerMatch) {
308 current = headerMatch[1].trim();
309 if (!out[current]) out[current] = "";
310 continue;
311 }
312 out[current] = (out[current] || "") + rawLine + "\n";
313 }
314 // Also expose `project` when it appears at the root.
315 if (!out["project"] && out["__root__"]) {
316 const m = out["__root__"].match(/\[project\]([\s\S]*)/);
317 if (m) out["project"] = m[1];
318 }
319 return out;
320}
321
322function extractTomlArray(body: string | undefined, key: string): string[] {
323 if (!body) return [];
324 const start = body.search(new RegExp(`^\\s*${key}\\s*=\\s*\\[`, "m"));
325 if (start < 0) return [];
326 const open = body.indexOf("[", start);
327 if (open < 0) return [];
328 const close = findMatchingBracket(body, open);
329 if (close < 0) return [];
330 const inner = body.slice(open + 1, close);
331 return splitTomlArrayItems(inner);
332}
333
334function findMatchingBracket(s: string, open: number): number {
335 let depth = 0;
336 for (let i = open; i < s.length; i++) {
337 if (s[i] === "[") depth++;
338 else if (s[i] === "]") {
339 depth--;
340 if (depth === 0) return i;
341 }
342 }
343 return -1;
344}
345
346function splitTomlArrayItems(inner: string): string[] {
347 // Strip newlines + split by commas; handle simple quoted strings.
348 const items: string[] = [];
349 let cur = "";
350 let inQ: string | null = null;
351 for (const ch of inner) {
352 if (inQ) {
353 cur += ch;
354 if (ch === inQ) inQ = null;
355 continue;
356 }
357 if (ch === '"' || ch === "'") {
358 inQ = ch;
359 cur += ch;
360 continue;
361 }
362 if (ch === ",") {
363 if (cur.trim()) items.push(cur);
364 cur = "";
365 continue;
366 }
367 cur += ch;
368 }
369 if (cur.trim()) items.push(cur);
370 return items.map((s) => s.trim()).filter(Boolean);
371}
372
373// ----------------------------------------------------------------------------
374// File detection + tree walk
375// ----------------------------------------------------------------------------
376
377const MANIFEST_BASENAMES = new Set(Object.keys(PARSERS));
378const MAX_MANIFEST_BYTES = 1_000_000;
379const MAX_MANIFESTS = 200;
380
381export function isManifestPath(path: string): boolean {
382 const base = path.split("/").pop()?.toLowerCase() || "";
383 return MANIFEST_BASENAMES.has(base);
384}
385
386export function parseManifest(
387 path: string,
388 content: string
389): ParsedDep[] {
390 const base = path.split("/").pop()?.toLowerCase() || "";
391 const parser = PARSERS[base];
392 if (!parser) return [];
393 try {
394 return parser(content);
395 } catch {
396 return [];
397 }
398}
399
400async function walkManifestPaths(
401 owner: string,
402 repo: string,
403 ref: string
404): Promise<Array<{ path: string; size?: number }>> {
405 const out: Array<{ path: string; size?: number }> = [];
406 const queue: string[] = [""];
407 while (queue.length && out.length < MAX_MANIFESTS) {
408 const dir = queue.shift()!;
409 let entries: GitTreeEntry[] = [];
410 try {
411 entries = await getTree(owner, repo, ref, dir);
412 } catch {
413 continue;
414 }
415 for (const e of entries) {
416 const p = dir ? `${dir}/${e.name}` : e.name;
417 if (e.type === "tree") {
418 const base = e.name.toLowerCase();
419 if (
420 base === "node_modules" ||
421 base === ".git" ||
422 base === "dist" ||
423 base === "build" ||
424 base === "vendor" ||
425 base === "target" ||
426 base === "__pycache__"
427 ) {
428 continue;
429 }
430 queue.push(p);
431 } else if (e.type === "blob") {
432 if (!isManifestPath(p)) continue;
433 if (e.size !== undefined && e.size > MAX_MANIFEST_BYTES) continue;
434 out.push({ path: p, size: e.size });
435 if (out.length >= MAX_MANIFESTS) break;
436 }
437 }
438 }
439 return out;
440}
441
442// ----------------------------------------------------------------------------
443// Reindex + queries
444// ----------------------------------------------------------------------------
445
446export async function indexRepositoryDependencies(
447 repositoryId: string
448): Promise<
449 | {
450 indexed: number;
451 manifests: number;
452 commitSha: string;
453 }
454 | null
455> {
456 try {
457 const [repo] = await db
458 .select()
459 .from(repositories)
460 .where(eq(repositories.id, repositoryId))
461 .limit(1);
462 if (!repo) return null;
463
464 const [owner] = await db
465 .select({ username: users.username })
466 .from(users)
467 .where(eq(users.id, repo.ownerId))
468 .limit(1);
469 if (!owner) return null;
470
471 const defaultBranch =
472 (await getDefaultBranch(owner.username, repo.name)) || "main";
473 const head = await resolveRef(owner.username, repo.name, defaultBranch);
474 if (!head) return null;
475
476 const manifests = await walkManifestPaths(
477 owner.username,
478 repo.name,
479 head
480 );
481
482 const rows: Array<Omit<RepoDependency, "id" | "indexedAt">> = [];
483 for (const f of manifests) {
484 const blob = await getBlob(owner.username, repo.name, head, f.path).catch(
485 () => null
486 );
487 if (!blob) continue;
488 const content =
489 typeof blob === "string"
490 ? blob
491 : new TextDecoder().decode(blob as any);
492 const deps = parseManifest(f.path, content);
493 for (const dep of deps) {
494 rows.push({
495 repositoryId,
496 ecosystem: dep.ecosystem,
497 name: dep.name,
498 versionSpec: dep.versionSpec,
499 manifestPath: f.path,
500 isDev: dep.isDev,
501 commitSha: head,
502 });
503 }
504 }
505
506 await db
507 .delete(repoDependencies)
508 .where(eq(repoDependencies.repositoryId, repositoryId));
509
510 // Insert in chunks to stay under parameter limits.
511 const CHUNK = 500;
512 for (let i = 0; i < rows.length; i += CHUNK) {
513 const slice = rows.slice(i, i + CHUNK);
514 if (slice.length) await db.insert(repoDependencies).values(slice);
515 }
516
517 return {
518 indexed: rows.length,
519 manifests: manifests.length,
520 commitSha: head,
521 };
522 } catch (err) {
523 console.error("[deps] indexRepositoryDependencies:", err);
524 return null;
525 }
526}
527
528export async function listDependenciesForRepo(
529 repositoryId: string
530): Promise<RepoDependency[]> {
531 try {
532 return await db
533 .select()
534 .from(repoDependencies)
535 .where(eq(repoDependencies.repositoryId, repositoryId))
536 .orderBy(desc(repoDependencies.ecosystem), desc(repoDependencies.name));
537 } catch {
538 return [];
539 }
540}
541
542export interface EcosystemSummary {
543 ecosystem: string;
544 count: number;
545}
546
547export async function summarizeDependencies(
548 repositoryId: string
549): Promise<EcosystemSummary[]> {
550 const rows = await listDependenciesForRepo(repositoryId);
551 const counts = new Map<string, number>();
552 for (const r of rows) {
553 counts.set(r.ecosystem, (counts.get(r.ecosystem) || 0) + 1);
554 }
555 return Array.from(counts.entries())
556 .map(([ecosystem, count]) => ({ ecosystem, count }))
557 .sort((a, b) => b.count - a.count);
558}
559
560/** Look up reverse deps — which repos list this package? (Network graph.) */
561export async function repositoriesDependingOn(
562 ecosystem: string,
563 name: string,
564 limit = 50
565): Promise<
566 Array<{
567 repositoryId: string;
568 versionSpec: string | null;
569 manifestPath: string;
570 }>
571> {
572 try {
573 const rows = await db
574 .select({
575 repositoryId: repoDependencies.repositoryId,
576 versionSpec: repoDependencies.versionSpec,
577 manifestPath: repoDependencies.manifestPath,
578 })
579 .from(repoDependencies)
580 .where(
581 and(
582 eq(repoDependencies.ecosystem, ecosystem),
583 eq(repoDependencies.name, name)
584 )
585 )
586 .limit(limit);
587 return rows;
588 } catch {
589 return [];
590 }
591}
592
593// ----------------------------------------------------------------------------
594// Test-only exports
595// ----------------------------------------------------------------------------
596
597export const __internal = {
598 splitTomlSections,
599 extractTomlArray,
600 splitTomlArrayItems,
601 pythonRequirementToDep,
602};
Addedsrc/lib/email-digest.ts+312−0View fileUnifiedSplit
1/**
2 * Block I7 — Weekly email digest.
3 *
4 * Composes a per-user digest of activity over the last 7 days (or a custom
5 * window) and sends it via the shared email module. Run from a cron or
6 * manually via `POST /admin/digests/run`.
7 *
8 * Data sources:
9 * - notifications (unread + read-last-7d)
10 * - gate_runs (failed / repaired)
11 * - pull_requests (merged by the user's repos)
12 *
13 * Never throws — the caller can fire-and-forget.
14 */
15
16import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
17import { db } from "./../db";
18import {
19 gateRuns,
20 notifications,
21 pullRequests,
22 repositories,
23 users,
24} from "./../db/schema";
25import { sendEmail, type EmailResult } from "./email";
26import { config } from "./config";
27
28export interface DigestInput {
29 userId: string;
30 since?: Date;
31 /** When false, skip `sendEmail` and just compose. Used for preview. */
32 send?: boolean;
33}
34
35export interface DigestBody {
36 subject: string;
37 text: string;
38 html: string;
39 counts: {
40 notifications: number;
41 failedGates: number;
42 repairedGates: number;
43 mergedPrs: number;
44 };
45}
46
47function fmtRange(from: Date, to: Date): string {
48 const f = from.toISOString().slice(0, 10);
49 const t = to.toISOString().slice(0, 10);
50 return f === t ? f : `${f} \u2192 ${t}`;
51}
52
53export async function composeDigest(
54 userId: string,
55 since?: Date
56): Promise<DigestBody | null> {
57 const now = new Date();
58 const from = since || new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
59 try {
60 const [user] = await db
61 .select()
62 .from(users)
63 .where(eq(users.id, userId))
64 .limit(1);
65 if (!user) return null;
66
67 // Pull notifications
68 const notifs = await db
69 .select()
70 .from(notifications)
71 .where(
72 and(
73 eq(notifications.userId, userId),
74 gte(notifications.createdAt, from)
75 )
76 )
77 .orderBy(desc(notifications.createdAt))
78 .limit(25);
79
80 // User's repos (owner only — org-aware digest can come later)
81 const ownedRepos = await db
82 .select({ id: repositories.id, name: repositories.name })
83 .from(repositories)
84 .where(eq(repositories.ownerId, userId));
85 const repoIds = ownedRepos.map((r) => r.id);
86
87 let failedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
88 let repairedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
89 let mergedPrs: Array<{ repoName: string; title: string }> = [];
90
91 if (repoIds.length > 0) {
92 const gates = await db
93 .select()
94 .from(gateRuns)
95 .where(
96 and(
97 inArray(gateRuns.repositoryId, repoIds),
98 gte(gateRuns.createdAt, from)
99 )
100 )
101 .orderBy(desc(gateRuns.createdAt))
102 .limit(50);
103 const byId = new Map(ownedRepos.map((r) => [r.id, r.name]));
104 for (const g of gates) {
105 const repoName = byId.get(g.repositoryId) || "?";
106 if (g.status === "failed") {
107 failedGates.push({
108 repoName,
109 gateName: g.gateName,
110 sha: g.commitSha.slice(0, 7),
111 });
112 } else if (g.status === "repaired") {
113 repairedGates.push({
114 repoName,
115 gateName: g.gateName,
116 sha: g.commitSha.slice(0, 7),
117 });
118 }
119 }
120
121 const merged = await db
122 .select()
123 .from(pullRequests)
124 .where(
125 and(
126 inArray(pullRequests.repositoryId, repoIds),
127 eq(pullRequests.state, "merged"),
128 gte(pullRequests.updatedAt, from)
129 )
130 )
131 .limit(25);
132 for (const pr of merged) {
133 mergedPrs.push({
134 repoName: byId.get(pr.repositoryId) || "?",
135 title: pr.title,
136 });
137 }
138 }
139
140 const counts = {
141 notifications: notifs.length,
142 failedGates: failedGates.length,
143 repairedGates: repairedGates.length,
144 mergedPrs: mergedPrs.length,
145 };
146
147 const base = config.appBaseUrl || "https://gluecron.com";
148 const subject = `Your Gluecron digest (${fmtRange(from, now)})`;
149 const lines: string[] = [];
150 lines.push(`Hi ${user.username},`);
151 lines.push("");
152 lines.push(`Here's what happened across your repos this week.`);
153 lines.push("");
154 lines.push(
155 `Notifications: ${counts.notifications} · Failed gates: ${counts.failedGates} · Auto-repaired: ${counts.repairedGates} · PRs merged: ${counts.mergedPrs}`
156 );
157 lines.push("");
158
159 if (notifs.length > 0) {
160 lines.push("## Notifications");
161 for (const n of notifs.slice(0, 10)) {
162 const when = new Date(n.createdAt).toLocaleDateString();
163 lines.push(`- [${n.kind}] ${n.title || "(untitled)"}${when}`);
164 }
165 lines.push("");
166 }
167
168 if (failedGates.length > 0) {
169 lines.push("## Failed gates");
170 for (const g of failedGates.slice(0, 10)) {
171 lines.push(`- ${g.repoName}${g.gateName} (${g.sha})`);
172 }
173 lines.push("");
174 }
175
176 if (repairedGates.length > 0) {
177 lines.push("## Auto-repairs");
178 for (const g of repairedGates.slice(0, 10)) {
179 lines.push(`- ${g.repoName}${g.gateName} (${g.sha})`);
180 }
181 lines.push("");
182 }
183
184 if (mergedPrs.length > 0) {
185 lines.push("## Merged PRs");
186 for (const pr of mergedPrs.slice(0, 10)) {
187 lines.push(`- ${pr.repoName}${pr.title}`);
188 }
189 lines.push("");
190 }
191
192 lines.push("---");
193 lines.push(
194 `You're receiving this because you opted into weekly digests. Manage at ${base}/settings.`
195 );
196
197 const text = lines.join("\n");
198 const html = textToHtml(text, base);
199 return { subject, text, html, counts };
200 } catch (err) {
201 console.error("[digest] composeDigest error:", err);
202 return null;
203 }
204}
205
206function textToHtml(text: string, base: string): string {
207 const lines = text.split("\n");
208 const out: string[] = [
209 `<html><body style="font-family:system-ui,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#111">`,
210 ];
211 for (const line of lines) {
212 if (line.startsWith("## ")) {
213 out.push(
214 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px">${escapeHtml(line.slice(3))}</h3>`
215 );
216 } else if (line.startsWith("- ")) {
217 out.push(`<li>${escapeHtml(line.slice(2))}</li>`);
218 } else if (line === "---") {
219 out.push(`<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />`);
220 } else if (line.trim() === "") {
221 out.push("<br>");
222 } else {
223 out.push(`<p>${escapeHtml(line)}</p>`);
224 }
225 }
226 out.push(
227 `<p style="font-size:12px;color:#777"><a href="${escapeHtml(base)}">${escapeHtml(base)}</a></p>`
228 );
229 out.push("</body></html>");
230 return out.join("\n");
231}
232
233function escapeHtml(s: string): string {
234 return s
235 .replace(/&/g, "&amp;")
236 .replace(/</g, "&lt;")
237 .replace(/>/g, "&gt;")
238 .replace(/"/g, "&quot;");
239}
240
241/** Compose + send for a single user. Records `last_digest_sent_at` on success. */
242export async function sendDigestForUser(
243 userId: string
244): Promise<EmailResult | { ok: false; provider: "none"; skipped: string }> {
245 try {
246 const [user] = await db
247 .select()
248 .from(users)
249 .where(eq(users.id, userId))
250 .limit(1);
251 if (!user) return { ok: false, provider: "none", skipped: "user not found" };
252 if (!user.notifyEmailDigestWeekly) {
253 return { ok: false, provider: "none", skipped: "opted out" };
254 }
255 const body = await composeDigest(userId);
256 if (!body) {
257 return { ok: false, provider: "none", skipped: "compose failed" };
258 }
259 const result = await sendEmail({
260 to: user.email,
261 subject: body.subject,
262 text: body.text,
263 html: body.html,
264 });
265 if (result.ok) {
266 await db
267 .update(users)
268 .set({ lastDigestSentAt: new Date() })
269 .where(eq(users.id, userId));
270 }
271 return result;
272 } catch (err) {
273 console.error("[digest] sendDigestForUser error:", err);
274 return { ok: false, provider: "none", skipped: "error" };
275 }
276}
277
278/** Iterates all opted-in users. Returns per-user results for logging. */
279export async function sendDigestsToAll(): Promise<
280 Array<{ userId: string; username: string; ok: boolean; skipped?: string }>
281> {
282 const results: Array<{
283 userId: string;
284 username: string;
285 ok: boolean;
286 skipped?: string;
287 }> = [];
288 try {
289 const opted = await db
290 .select({ id: users.id, username: users.username })
291 .from(users)
292 .where(eq(users.notifyEmailDigestWeekly, true));
293 for (const u of opted) {
294 const r = await sendDigestForUser(u.id);
295 results.push({
296 userId: u.id,
297 username: u.username,
298 ok: r.ok,
299 skipped: "skipped" in r ? r.skipped : undefined,
300 });
301 }
302 } catch (err) {
303 console.error("[digest] sendDigestsToAll error:", err);
304 }
305 return results;
306}
307
308/** Pure helper exported for tests. */
309export const __internal = { textToHtml, escapeHtml, fmtRange };
310
311// Keep sql unused-import warnings silent
312void sql;
Addedsrc/lib/email.ts+132−0View fileUnifiedSplit
1/**
2 * Email sending — provider-pluggable, never-throws.
3 *
4 * Providers:
5 * log — writes a formatted message to stderr (default, dev-safe)
6 * resend — POSTs to api.resend.com using RESEND_API_KEY
7 *
8 * Configured via:
9 * EMAIL_PROVIDER=log|resend
10 * EMAIL_FROM="gluecron <no-reply@gluecron.app>"
11 * RESEND_API_KEY=...
12 * APP_BASE_URL=https://gluecron.com
13 *
14 * Contract: sendEmail() must never reject. Failures are logged and swallowed
15 * so a downed email provider never breaks the primary request path. Callers
16 * await the returned promise to preserve ordering but may ignore the result.
17 */
18
19import { config } from "./config";
20
21export interface EmailMessage {
22 to: string;
23 subject: string;
24 text: string;
25 html?: string;
26}
27
28export interface EmailResult {
29 ok: boolean;
30 provider: "log" | "resend" | "none";
31 skipped?: string;
32 error?: string;
33 id?: string;
34}
35
36function looksLikeEmail(s: string): boolean {
37 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
38}
39
40function renderPlainFallback(text: string): string {
41 // Minimal HTML fallback when caller didn't supply one.
42 const escaped = text
43 .replace(/&/g, "&amp;")
44 .replace(/</g, "&lt;")
45 .replace(/>/g, "&gt;");
46 return `<pre style="font-family:ui-monospace,SF-Mono,Menlo,monospace;font-size:13px;white-space:pre-wrap;color:#c9d1d9;background:#0d1117;padding:16px;border-radius:6px">${escaped}</pre>`;
47}
48
49async function sendViaResend(msg: EmailMessage): Promise<EmailResult> {
50 if (!config.resendApiKey) {
51 return { ok: false, provider: "resend", skipped: "RESEND_API_KEY unset" };
52 }
53 try {
54 const res = await fetch("https://api.resend.com/emails", {
55 method: "POST",
56 headers: {
57 authorization: `Bearer ${config.resendApiKey}`,
58 "content-type": "application/json",
59 },
60 body: JSON.stringify({
61 from: config.emailFrom,
62 to: [msg.to],
63 subject: msg.subject,
64 text: msg.text,
65 html: msg.html || renderPlainFallback(msg.text),
66 }),
67 });
68 if (!res.ok) {
69 const body = await res.text();
70 return {
71 ok: false,
72 provider: "resend",
73 error: `resend ${res.status}: ${body.slice(0, 200)}`,
74 };
75 }
76 const body = (await res.json().catch(() => ({}))) as { id?: string };
77 return { ok: true, provider: "resend", id: body.id };
78 } catch (err) {
79 return {
80 ok: false,
81 provider: "resend",
82 error: String((err as Error)?.message || err),
83 };
84 }
85}
86
87function sendViaLog(msg: EmailMessage): EmailResult {
88 // Structured, grep-able log. Written to stderr so prod log collectors pick it up.
89 console.error(
90 `[email:log] to=${msg.to} subject=${JSON.stringify(msg.subject)}\n` +
91 msg.text.split("\n").map((l) => " " + l).join("\n")
92 );
93 return { ok: true, provider: "log" };
94}
95
96/**
97 * Send an email. Always resolves — never throws, never rejects.
98 * Returns { ok, provider, ... } so callers can surface errors in admin UIs
99 * without having to wrap in try/catch.
100 */
101export async function sendEmail(msg: EmailMessage): Promise<EmailResult> {
102 if (!msg.to || !looksLikeEmail(msg.to)) {
103 return { ok: false, provider: "none", skipped: "invalid recipient" };
104 }
105 if (!msg.subject || !msg.text) {
106 return { ok: false, provider: "none", skipped: "missing subject or body" };
107 }
108 try {
109 if (config.emailProvider === "resend") {
110 return await sendViaResend(msg);
111 }
112 return sendViaLog(msg);
113 } catch (err) {
114 // Defence-in-depth — provider handlers already swallow, but just in case.
115 return {
116 ok: false,
117 provider: config.emailProvider,
118 error: String((err as Error)?.message || err),
119 };
120 }
121}
122
123/**
124 * Build a fully-qualified URL from a path, using APP_BASE_URL.
125 * Safe with relative or absolute inputs.
126 */
127export function absoluteUrl(pathOrUrl: string | undefined | null): string {
128 if (!pathOrUrl) return config.appBaseUrl;
129 if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
130 const suffix = pathOrUrl.startsWith("/") ? pathOrUrl : "/" + pathOrUrl;
131 return config.appBaseUrl + suffix;
132}
Addedsrc/lib/environments.ts+304−0View fileUnifiedSplit
1/**
2 * Block C4 — Environment + deployment-approval helpers.
3 *
4 * v1 semantics:
5 * - approval = any single reviewer approves (required = 1).
6 * - rejection by any reviewer hard-stops the deploy.
7 * - if `reviewers` is empty, the repo owner is treated as the implicit reviewer.
8 * - `allowedBranches` is a JSON array of glob patterns. When non-empty only
9 * refs matching at least one pattern may deploy through the environment.
10 * - `waitTimerMinutes` is stored but NOT enforced in v1 (stub).
11 *
12 * All DB calls are wrapped in try/catch so the caller gets well-defined
13 * shapes even when the database is unreachable (keeps the hot push path safe).
14 */
15
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 environments,
20 deploymentApprovals,
21 repositories,
22} from "../db/schema";
23import type { Environment, DeploymentApproval } from "../db/schema";
24
25// ---------------------------------------------------------------------------
26// Glob matching (minimal — `*` segment, `**` any path, literals)
27// ---------------------------------------------------------------------------
28
29/** Normalise a ref for matching — strip `refs/heads/`, `refs/tags/`. */
30function normaliseRef(ref: string): string {
31 if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
32 if (ref.startsWith("refs/tags/")) return ref.slice("refs/tags/".length);
33 return ref;
34}
35
36/** Minimal glob → RegExp. `*` = one path segment, `**` = any. */
37export function matchGlob(value: string, pattern: string): boolean {
38 const v = normaliseRef(value);
39 const p = normaliseRef(pattern);
40 if (v === p) return true;
41 // Escape regex metachars, then re-expand `**` and `*`.
42 const re = p
43 .replace(/[.+^${}()|[\]\\]/g, "\\$&")
44 .replace(/\*\*/g, "::DOUBLESTAR::")
45 .replace(/\*/g, "[^/]*")
46 .replace(/::DOUBLESTAR::/g, ".*");
47 return new RegExp(`^${re}$`).test(v);
48}
49
50function matchesAny(value: string, patterns: string[]): boolean {
51 if (patterns.length === 0) return true;
52 return patterns.some((p) => matchGlob(value, p));
53}
54
55// ---------------------------------------------------------------------------
56// Environments CRUD
57// ---------------------------------------------------------------------------
58
59export async function listEnvironments(
60 repositoryId: string
61): Promise<Environment[]> {
62 try {
63 return await db
64 .select()
65 .from(environments)
66 .where(eq(environments.repositoryId, repositoryId))
67 .orderBy(desc(environments.createdAt));
68 } catch (err) {
69 console.error("[environments] list failed:", err);
70 return [];
71 }
72}
73
74export async function getEnvironmentById(
75 repositoryId: string,
76 id: string
77): Promise<Environment | null> {
78 try {
79 const [row] = await db
80 .select()
81 .from(environments)
82 .where(
83 and(eq(environments.id, id), eq(environments.repositoryId, repositoryId))
84 )
85 .limit(1);
86 return row || null;
87 } catch (err) {
88 console.error("[environments] getById failed:", err);
89 return null;
90 }
91}
92
93export async function getEnvironmentByName(
94 repositoryId: string,
95 name: string
96): Promise<Environment | null> {
97 try {
98 const [row] = await db
99 .select()
100 .from(environments)
101 .where(
102 and(
103 eq(environments.repositoryId, repositoryId),
104 eq(environments.name, name)
105 )
106 )
107 .limit(1);
108 return row || null;
109 } catch (err) {
110 console.error("[environments] getByName failed:", err);
111 return null;
112 }
113}
114
115export async function getOrCreateEnvironment(
116 repositoryId: string,
117 name: string
118): Promise<Environment> {
119 const existing = await getEnvironmentByName(repositoryId, name);
120 if (existing) return existing;
121 try {
122 const [inserted] = await db
123 .insert(environments)
124 .values({ repositoryId, name })
125 .returning();
126 if (inserted) return inserted;
127 } catch (err) {
128 // Unique-index collision from a concurrent insert — fall through to re-read.
129 console.error("[environments] create failed:", err);
130 }
131 const reread = await getEnvironmentByName(repositoryId, name);
132 if (reread) return reread;
133 // Absolute fallback — synthesize an in-memory shell so callers never crash.
134 // This path is only reached if the DB is unreachable for both insert + read.
135 return {
136 id: "",
137 repositoryId,
138 name,
139 requireApproval: false,
140 reviewers: "[]",
141 waitTimerMinutes: 0,
142 allowedBranches: "[]",
143 createdAt: new Date(),
144 updatedAt: new Date(),
145 } as Environment;
146}
147
148// ---------------------------------------------------------------------------
149// Reviewer semantics
150// ---------------------------------------------------------------------------
151
152function parseJsonArray(raw: string | null | undefined): string[] {
153 if (!raw) return [];
154 try {
155 const v = JSON.parse(raw);
156 return Array.isArray(v) ? v.map(String) : [];
157 } catch {
158 return [];
159 }
160}
161
162export function reviewerIdsOf(env: Environment): string[] {
163 return parseJsonArray(env.reviewers);
164}
165
166export function allowedBranchesOf(env: Environment): string[] {
167 return parseJsonArray(env.allowedBranches);
168}
169
170/**
171 * Return true if `userId` is allowed to approve/reject deploys for this env.
172 * If the reviewer list is empty, fall back to the repo owner.
173 */
174export async function isReviewer(
175 env: Environment,
176 userId: string
177): Promise<boolean> {
178 const reviewers = reviewerIdsOf(env);
179 if (reviewers.includes(userId)) return true;
180 if (reviewers.length === 0) {
181 try {
182 const [row] = await db
183 .select({ ownerId: repositories.ownerId })
184 .from(repositories)
185 .where(eq(repositories.id, env.repositoryId))
186 .limit(1);
187 return row?.ownerId === userId;
188 } catch (err) {
189 console.error("[environments] isReviewer owner lookup failed:", err);
190 return false;
191 }
192 }
193 return false;
194}
195
196// ---------------------------------------------------------------------------
197// Approvals
198// ---------------------------------------------------------------------------
199
200export async function listApprovals(
201 deploymentId: string
202): Promise<DeploymentApproval[]> {
203 try {
204 return await db
205 .select()
206 .from(deploymentApprovals)
207 .where(eq(deploymentApprovals.deploymentId, deploymentId))
208 .orderBy(desc(deploymentApprovals.createdAt));
209 } catch (err) {
210 console.error("[environments] listApprovals failed:", err);
211 return [];
212 }
213}
214
215/**
216 * Pure reducer — given a list of decisions, compute approved/rejected flags.
217 * Exported so tests can exercise it without a DB.
218 */
219export function reduceApprovalState(decided: DeploymentApproval[]): {
220 approved: boolean;
221 rejected: boolean;
222 decided: DeploymentApproval[];
223} {
224 const rejected = decided.some((d) => d.decision === "rejected");
225 const approved = !rejected && decided.some((d) => d.decision === "approved");
226 return { approved, rejected, decided };
227}
228
229/**
230 * v1 semantics: approved = at least one approval exists and no rejection.
231 * rejected = at least one rejection exists.
232 */
233export async function computeApprovalState(
234 deploymentId: string,
235 _env: Environment
236): Promise<{
237 approved: boolean;
238 rejected: boolean;
239 decided: DeploymentApproval[];
240}> {
241 const decided = await listApprovals(deploymentId);
242 return reduceApprovalState(decided);
243}
244
245/**
246 * Record a reviewer's decision. Returns the inserted row, or null on any
247 * failure (duplicate, DB unreachable, etc).
248 */
249export async function recordApproval(opts: {
250 deploymentId: string;
251 userId: string;
252 decision: "approved" | "rejected";
253 comment?: string;
254}): Promise<DeploymentApproval | null> {
255 try {
256 const [row] = await db
257 .insert(deploymentApprovals)
258 .values({
259 deploymentId: opts.deploymentId,
260 userId: opts.userId,
261 decision: opts.decision,
262 comment: opts.comment ?? null,
263 })
264 .returning();
265 return row || null;
266 } catch (err) {
267 console.error("[environments] recordApproval failed:", err);
268 return null;
269 }
270}
271
272// ---------------------------------------------------------------------------
273// Push-time gate (called by post-receive before the deploy executes)
274// ---------------------------------------------------------------------------
275
276/**
277 * Pure read — returns whether a deploy to (repo, envName, ref) needs approval.
278 *
279 * { required: true, env } → caller should create the deployment row with
280 * status="pending_approval" (or "blocked" if
281 * env?.blockedReason would apply — that's the
282 * caller's call; we only flag required=true).
283 * { required: false, env } → caller may proceed to status="pending".
284 *
285 * Branch-glob enforcement: if the env's `allowedBranches` is non-empty and the
286 * ref does not match any pattern, we still return `required: true` so the
287 * caller knows to block. The caller can read `allowedBranchesOf(env)` to set
288 * `blockedReason: "branch not allowed for environment"` on the deployment row.
289 */
290export async function requiresApprovalFor(
291 repositoryId: string,
292 envName: string,
293 ref: string
294): Promise<{ required: boolean; env: Environment | null }> {
295 const env = await getEnvironmentByName(repositoryId, envName);
296 if (!env) return { required: false, env: null };
297
298 const allowed = allowedBranchesOf(env);
299 if (allowed.length > 0 && !matchesAny(ref, allowed)) {
300 return { required: true, env };
301 }
302 if (env.requireApproval) return { required: true, env };
303 return { required: false, env };
304}
Addedsrc/lib/follows.ts+251−0View fileUnifiedSplit
1/**
2 * Block J4 — User following + personalised feed.
3 *
4 * Core graph ops and a personalised feed built on top of `activity_feed`.
5 * No caches / materialised views — the follow set is small (tens to low
6 * hundreds) and the activity_feed already carries the indexes we need.
7 */
8
9import { and, desc, eq, inArray } from "drizzle-orm";
10import { db } from "../db";
11import {
12 activityFeed,
13 repositories,
14 userFollows,
15 users,
16 type ActivityEntry,
17 type User,
18} from "../db/schema";
19
20// ----------------------------------------------------------------------------
21// Graph mutations
22// ----------------------------------------------------------------------------
23
24export async function followUser(
25 followerId: string,
26 followingId: string
27): Promise<"ok" | "self" | "already" | "error"> {
28 if (followerId === followingId) return "self";
29 try {
30 const rows = await db
31 .insert(userFollows)
32 .values({ followerId, followingId })
33 .onConflictDoNothing()
34 .returning();
35 return rows.length > 0 ? "ok" : "already";
36 } catch {
37 return "error";
38 }
39}
40
41export async function unfollowUser(
42 followerId: string,
43 followingId: string
44): Promise<boolean> {
45 const rows = await db
46 .delete(userFollows)
47 .where(
48 and(
49 eq(userFollows.followerId, followerId),
50 eq(userFollows.followingId, followingId)
51 )
52 )
53 .returning();
54 return rows.length > 0;
55}
56
57export async function isFollowing(
58 followerId: string,
59 followingId: string
60): Promise<boolean> {
61 if (followerId === followingId) return false;
62 const [row] = await db
63 .select({ f: userFollows.followerId })
64 .from(userFollows)
65 .where(
66 and(
67 eq(userFollows.followerId, followerId),
68 eq(userFollows.followingId, followingId)
69 )
70 )
71 .limit(1);
72 return !!row;
73}
74
75// ----------------------------------------------------------------------------
76// Lists
77// ----------------------------------------------------------------------------
78
79export async function listFollowers(userId: string): Promise<User[]> {
80 return db
81 .select({
82 id: users.id,
83 username: users.username,
84 email: users.email,
85 displayName: users.displayName,
86 passwordHash: users.passwordHash,
87 avatarUrl: users.avatarUrl,
88 bio: users.bio,
89 notifyEmailOnMention: users.notifyEmailOnMention,
90 notifyEmailOnAssign: users.notifyEmailOnAssign,
91 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
92 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
93 lastDigestSentAt: users.lastDigestSentAt,
94 createdAt: users.createdAt,
95 updatedAt: users.updatedAt,
96 })
97 .from(userFollows)
98 .innerJoin(users, eq(userFollows.followerId, users.id))
99 .where(eq(userFollows.followingId, userId))
100 .orderBy(desc(userFollows.createdAt));
101}
102
103export async function listFollowing(userId: string): Promise<User[]> {
104 return db
105 .select({
106 id: users.id,
107 username: users.username,
108 email: users.email,
109 displayName: users.displayName,
110 passwordHash: users.passwordHash,
111 avatarUrl: users.avatarUrl,
112 bio: users.bio,
113 notifyEmailOnMention: users.notifyEmailOnMention,
114 notifyEmailOnAssign: users.notifyEmailOnAssign,
115 notifyEmailOnGateFail: users.notifyEmailOnGateFail,
116 notifyEmailDigestWeekly: users.notifyEmailDigestWeekly,
117 lastDigestSentAt: users.lastDigestSentAt,
118 createdAt: users.createdAt,
119 updatedAt: users.updatedAt,
120 })
121 .from(userFollows)
122 .innerJoin(users, eq(userFollows.followingId, users.id))
123 .where(eq(userFollows.followerId, userId))
124 .orderBy(desc(userFollows.createdAt));
125}
126
127export async function followCounts(userId: string): Promise<{
128 followers: number;
129 following: number;
130}> {
131 const [followers, following] = await Promise.all([
132 db
133 .select({ uid: userFollows.followerId })
134 .from(userFollows)
135 .where(eq(userFollows.followingId, userId)),
136 db
137 .select({ uid: userFollows.followingId })
138 .from(userFollows)
139 .where(eq(userFollows.followerId, userId)),
140 ]);
141 return { followers: followers.length, following: following.length };
142}
143
144// ----------------------------------------------------------------------------
145// Feed
146// ----------------------------------------------------------------------------
147
148export interface FeedEntry {
149 activity: ActivityEntry;
150 actor: { id: string; username: string; displayName: string | null };
151 repository: { id: string; name: string; ownerId: string; isPrivate: boolean };
152 ownerUsername: string;
153}
154
155/**
156 * Activity feed filtered to the users the viewer follows. We cap at 200
157 * following edges to bound the IN list. Private repos are excluded unless
158 * the viewer owns them.
159 */
160export async function feedForUser(
161 userId: string,
162 limit = 50
163): Promise<FeedEntry[]> {
164 const following = await db
165 .select({ id: userFollows.followingId })
166 .from(userFollows)
167 .where(eq(userFollows.followerId, userId))
168 .limit(200);
169 const ids = following.map((f) => f.id);
170 if (ids.length === 0) return [];
171
172 const rows = await db
173 .select({
174 activity: activityFeed,
175 actor: users,
176 repository: repositories,
177 })
178 .from(activityFeed)
179 .innerJoin(users, eq(activityFeed.userId, users.id))
180 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
181 .where(inArray(activityFeed.userId, ids))
182 .orderBy(desc(activityFeed.createdAt))
183 .limit(limit);
184
185 // Resolve owner usernames for repo links.
186 const ownerIds = Array.from(new Set(rows.map((r) => r.repository.ownerId)));
187 let ownerMap: Record<string, string> = {};
188 if (ownerIds.length) {
189 const owners = await db
190 .select({ id: users.id, username: users.username })
191 .from(users)
192 .where(inArray(users.id, ownerIds));
193 for (const o of owners) ownerMap[o.id] = o.username;
194 }
195
196 return rows
197 .filter(
198 (r) => !r.repository.isPrivate || r.repository.ownerId === userId
199 )
200 .map((r) => ({
201 activity: r.activity,
202 actor: {
203 id: r.actor.id,
204 username: r.actor.username,
205 displayName: r.actor.displayName,
206 },
207 repository: {
208 id: r.repository.id,
209 name: r.repository.name,
210 ownerId: r.repository.ownerId,
211 isPrivate: r.repository.isPrivate,
212 },
213 ownerUsername: ownerMap[r.repository.ownerId] || "",
214 }));
215}
216
217/** Human-readable verb for an activity_feed `action` token. */
218export function describeAction(action: string): string {
219 switch (action) {
220 case "push":
221 return "pushed to";
222 case "issue_open":
223 return "opened an issue in";
224 case "issue_close":
225 return "closed an issue in";
226 case "pr_open":
227 return "opened a pull request in";
228 case "pr_merge":
229 return "merged a pull request in";
230 case "pr_close":
231 return "closed a pull request in";
232 case "star":
233 return "starred";
234 case "comment":
235 return "commented in";
236 default:
237 return action.replace(/_/g, " ");
238 }
239}
240
241/** Resolve a username → user ID or null. */
242export async function resolveUserByName(
243 username: string
244): Promise<User | null> {
245 const [row] = await db
246 .select()
247 .from(users)
248 .where(eq(users.username, username))
249 .limit(1);
250 return row ?? null;
251}
Addedsrc/lib/gate.ts+412−0View fileUnifiedSplit
1/**
2 * Green gate enforcement — the heart of the "nothing broken ships" guarantee.
3 *
4 * Runs every configured gate for a repo on push / on PR / on merge:
5 * 1. GateTest scan (external test/lint runner)
6 * 2. Secret scan (regex secrets + AI security review)
7 * 3. AI code review (for PRs)
8 * 4. Merge check (for PRs)
9 * 5. Dependency/vuln scan (best-effort, skipped if not configured)
10 *
11 * Each result is persisted to `gate_runs`. If auto-repair is enabled
12 * and a gate fails, the engine attempts a fix before reporting a hard fail.
13 */
14
15import { eq } from "drizzle-orm";
16import { config } from "./config";
17import { db } from "../db";
18import { gateRuns, repoSettings, repositories, users } from "../db/schema";
19import { getOrCreateSettings } from "./repo-bootstrap";
20import { scanForSecrets, aiSecurityScan } from "./security-scan";
21import type { SecretFinding, SecurityFinding } from "./security-scan";
22import { repairSecrets, repairSecurityIssues } from "./auto-repair";
23import { readFile } from "fs/promises";
24import { join } from "path";
25
26export interface GateCheckResult {
27 name: string;
28 passed: boolean;
29 details: string;
30 skipped?: boolean;
31 repaired?: boolean;
32 repairCommitSha?: string;
33}
34
35export interface GateResult {
36 allPassed: boolean;
37 checks: GateCheckResult[];
38}
39
40/**
41 * Record a gate run in the DB. Fire-and-forget; swallows DB errors.
42 */
43async function recordGateRun(opts: {
44 repositoryId: string;
45 pullRequestId?: string;
46 commitSha: string;
47 ref: string;
48 gateName: string;
49 status: "passed" | "failed" | "skipped" | "repaired";
50 summary: string;
51 details?: unknown;
52 repairAttempted?: boolean;
53 repairSucceeded?: boolean;
54 repairCommitSha?: string;
55 durationMs?: number;
56}): Promise<void> {
57 try {
58 await db.insert(gateRuns).values({
59 repositoryId: opts.repositoryId,
60 pullRequestId: opts.pullRequestId,
61 commitSha: opts.commitSha,
62 ref: opts.ref,
63 gateName: opts.gateName,
64 status: opts.status,
65 summary: opts.summary,
66 details: opts.details ? JSON.stringify(opts.details) : null,
67 repairAttempted: opts.repairAttempted ?? false,
68 repairSucceeded: opts.repairSucceeded ?? false,
69 repairCommitSha: opts.repairCommitSha,
70 durationMs: opts.durationMs,
71 completedAt: new Date(),
72 });
73 } catch (err) {
74 console.error("[gate] recordGateRun failed:", err);
75 }
76}
77
78/**
79 * Look up the repository row by owner/name.
80 */
81async function lookupRepo(
82 owner: string,
83 repo: string
84): Promise<{ id: string } | null> {
85 try {
86 const [u] = await db.select().from(users).where(eq(users.username, owner)).limit(1);
87 if (!u) return null;
88 const { and } = await import("drizzle-orm");
89 const [r] = await db
90 .select()
91 .from(repositories)
92 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, repo)))
93 .limit(1);
94 return r ? { id: r.id } : null;
95 } catch {
96 return null;
97 }
98}
99
100/**
101 * Run GateTest scan on a repository at a specific ref.
102 */
103export async function runGateTestScan(
104 owner: string,
105 repo: string,
106 ref: string,
107 headSha: string
108): Promise<GateCheckResult> {
109 if (!config.gatetestUrl) {
110 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
111 }
112
113 try {
114 const headers: Record<string, string> = { "Content-Type": "application/json" };
115 if (config.gatetestApiKey) {
116 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
117 }
118
119 const response = await fetch(config.gatetestUrl, {
120 method: "POST",
121 headers,
122 body: JSON.stringify({
123 repository: `${owner}/${repo}`,
124 ref,
125 sha: headSha,
126 source: "gluecron",
127 mode: "blocking",
128 }),
129 signal: AbortSignal.timeout(60_000),
130 });
131
132 if (!response.ok) {
133 const body = await response.text().catch(() => "");
134 return {
135 name: "GateTest",
136 passed: false,
137 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
138 };
139 }
140
141 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
142 const passed =
143 result.passed === true || result.status === "passed" || result.status === "success";
144 const summary =
145 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
146 return { name: "GateTest", passed, details: summary };
147 } catch (err) {
148 console.error("[gate] GateTest scan error:", err);
149 return {
150 name: "GateTest",
151 passed: false,
152 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
153 };
154 }
155}
156
157/**
158 * Check for merge conflicts between branches.
159 */
160export async function checkMergeability(
161 owner: string,
162 repo: string,
163 baseBranch: string,
164 headBranch: string
165): Promise<GateCheckResult> {
166 const { getRepoPath } = await import("../git/repository");
167 const repoDir = getRepoPath(owner, repo);
168
169 const ffCheck = Bun.spawn(
170 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
171 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
172 );
173 const ffExit = await ffCheck.exited;
174 if (ffExit === 0) {
175 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
176 }
177
178 const mergeBase = Bun.spawn(
179 ["git", "merge-base", baseBranch, headBranch],
180 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
181 );
182 const baseOut = await new Response(mergeBase.stdout).text();
183 const baseExit = await mergeBase.exited;
184
185 if (baseExit !== 0) {
186 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
187 }
188
189 const mergeTree = Bun.spawn(
190 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
191 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
192 );
193 const treeOut = await new Response(mergeTree.stdout).text();
194 await mergeTree.exited;
195
196 const hasConflicts = treeOut.includes("<<<<<<<");
197 return {
198 name: "Merge check",
199 passed: !hasConflicts,
200 details: hasConflicts
201 ? "Merge conflicts detected — auto-resolution will be attempted"
202 : "Clean merge possible",
203 };
204}
205
206/**
207 * Secret + security scan. Runs the regex scanner on files at the given ref,
208 * then optionally runs the AI semantic scan on the diff.
209 */
210export async function runSecretAndSecurityScan(
211 owner: string,
212 repo: string,
213 ref: string,
214 headSha: string,
215 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
216): Promise<{
217 secretResult: GateCheckResult;
218 securityResult: GateCheckResult;
219 secrets: SecretFinding[];
220 securityIssues: SecurityFinding[];
221}> {
222 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
223 const repoDir = getRepoPath(owner, repo);
224
225 // Snapshot top-level + one level deep files at the ref
226 const files: Array<{ path: string; content: string }> = [];
227 const branches = await listBranches(owner, repo);
228 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
229 ? ref.replace(/^refs\/heads\//, "")
230 : headSha;
231
232 async function walk(dir: string, depth: number): Promise<void> {
233 if (depth > 4) return;
234 const tree = await getTree(owner, repo, effectiveRef, dir);
235 for (const entry of tree) {
236 const full = dir ? `${dir}/${entry.name}` : entry.name;
237 if (entry.type === "tree") {
238 await walk(full, depth + 1);
239 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
240 try {
241 const blob = await getBlob(owner, repo, effectiveRef, full);
242 if (blob && !blob.isBinary) {
243 files.push({ path: full, content: blob.content });
244 }
245 } catch {
246 // skip
247 }
248 }
249 if (files.length >= 500) return;
250 }
251 }
252
253 try {
254 await walk("", 0);
255 } catch {
256 // Unable to walk — the ref may not exist yet. Bail gracefully.
257 }
258
259 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
260 const securityIssues =
261 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
262
263 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
264 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
265
266 return {
267 secretResult: {
268 name: "Secret scan",
269 passed: criticalSecrets === 0,
270 details:
271 secrets.length === 0
272 ? "No secrets detected"
273 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
274 },
275 securityResult: {
276 name: "Security scan",
277 passed: criticalSec === 0,
278 skipped: !opts.scanSecurity || !opts.diffText,
279 details:
280 securityIssues.length === 0
281 ? opts.scanSecurity && opts.diffText
282 ? "No security issues found"
283 : "Skipped — no diff provided"
284 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
285 },
286 secrets,
287 securityIssues,
288 };
289}
290
291/**
292 * Run every configured gate for a PR merge.
293 * Records gate_runs entries. Optionally invokes auto-repair.
294 */
295export async function runAllGateChecks(
296 owner: string,
297 repo: string,
298 baseBranch: string,
299 headBranch: string,
300 headSha: string,
301 aiReviewApproved: boolean,
302 opts: {
303 pullRequestId?: string;
304 enableAutoRepair?: boolean;
305 diffText?: string;
306 } = {}
307): Promise<GateResult> {
308 const started = Date.now();
309 const repoRow = await lookupRepo(owner, repo);
310 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
311
312 // Decide which gates to run
313 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
314 const runSecretScan = settings?.secretScanEnabled !== false;
315 const runSecurityScan = settings?.securityScanEnabled !== false;
316 const runAiReview = settings?.aiReviewEnabled !== false;
317 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
318
319 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
320 runGateTest
321 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
322 : Promise.resolve<GateCheckResult>({
323 name: "GateTest",
324 passed: true,
325 skipped: true,
326 details: "Disabled in settings",
327 }),
328 checkMergeability(owner, repo, baseBranch, headBranch),
329 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
330 scanSecrets: runSecretScan,
331 scanSecurity: runSecurityScan,
332 diffText: opts.diffText,
333 }),
334 ]);
335
336 const checks: GateCheckResult[] = [gateTestResult];
337 checks.push(scanResults.secretResult);
338 if (runSecurityScan) checks.push(scanResults.securityResult);
339 checks.push(mergeResult);
340 checks.push({
341 name: "AI Review",
342 passed: !runAiReview || aiReviewApproved,
343 skipped: !runAiReview,
344 details: !runAiReview
345 ? "Disabled in settings"
346 : aiReviewApproved
347 ? "AI review approved"
348 : "AI review found blocking issues — resolve before merging",
349 });
350
351 // ---- Auto-repair on failures ----
352 if (enableRepair) {
353 // Secrets
354 if (!scanResults.secretResult.passed) {
355 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
356 if (repair.success) {
357 scanResults.secretResult.passed = true;
358 scanResults.secretResult.repaired = true;
359 scanResults.secretResult.repairCommitSha = repair.commitSha;
360 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
361 }
362 }
363 // Security
364 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
365 const repair = await repairSecurityIssues(
366 owner,
367 repo,
368 headBranch,
369 scanResults.securityIssues
370 );
371 if (repair.success) {
372 scanResults.securityResult.passed = true;
373 scanResults.securityResult.repaired = true;
374 scanResults.securityResult.repairCommitSha = repair.commitSha;
375 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
376 }
377 }
378 }
379
380 // Persist gate_runs
381 if (repoRow) {
382 const duration = Date.now() - started;
383 await Promise.all(
384 checks.map((check) =>
385 recordGateRun({
386 repositoryId: repoRow.id,
387 pullRequestId: opts.pullRequestId,
388 commitSha: headSha,
389 ref: `refs/heads/${headBranch}`,
390 gateName: check.name,
391 status: check.skipped
392 ? "skipped"
393 : check.repaired
394 ? "repaired"
395 : check.passed
396 ? "passed"
397 : "failed",
398 summary: check.details,
399 repairAttempted: !!check.repaired,
400 repairSucceeded: !!check.repaired,
401 repairCommitSha: check.repairCommitSha,
402 durationMs: duration,
403 })
404 )
405 );
406 }
407
408 return {
409 allPassed: checks.every((c) => c.passed || c.skipped),
410 checks,
411 };
412}
Addedsrc/lib/graphql.ts+488−0View fileUnifiedSplit
1/**
2 * Block G2 — GraphQL mirror of REST.
3 *
4 * A deliberately-tiny, dependency-free GraphQL-over-HTTP handler. Supports
5 * a fixed set of query fields that mirror the existing REST endpoints:
6 *
7 * query {
8 * viewer { id username email }
9 * user(username:"...") { id username createdAt repos { id name visibility } }
10 * repository(owner:"...", name:"...") {
11 * id name description visibility starCount forkCount createdAt
12 * owner { id username }
13 * issues(state:"open", limit:20) { id number title state createdAt }
14 * pullRequests(state:"open", limit:20) { id number title state createdAt }
15 * }
16 * search(q:"...", limit:20) { id name ownerUsername }
17 * rateLimit { remaining reset }
18 * }
19 *
20 * No mutations — callers should use the REST + /api endpoints for writes.
21 * This keeps the attack surface small and sidesteps the need for an
22 * auth/authz layer beyond softAuth.
23 *
24 * The parser is a hand-rolled recursive descent over the subset of
25 * GraphQL syntax we actually support (selection sets, named fields,
26 * string/number/boolean/enum args). It's not a spec-complete parser —
27 * it rejects anything it doesn't understand with a friendly error.
28 */
29
30import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
31import { db } from "../db";
32import {
33 issues,
34 pullRequests,
35 repositories,
36 users,
37} from "../db/schema";
38
39// ---------- Types ----------
40
41export interface GqlError {
42 message: string;
43 path?: string[];
44}
45
46export interface GqlResponse {
47 data?: Record<string, any> | null;
48 errors?: GqlError[];
49}
50
51type ArgValue = string | number | boolean | null;
52
53interface Field {
54 name: string;
55 alias?: string;
56 args: Record<string, ArgValue>;
57 selections: Field[];
58}
59
60// ---------- Parser ----------
61
62/** Parses a query. Returns either the top-level selection set or an error. */
63export function parseQuery(
64 src: string
65): { ok: true; fields: Field[] } | { ok: false; error: string } {
66 const s = src.trim();
67 try {
68 const p = new Parser(s);
69 p.skipWs();
70 // Optional "query" or "query Name" prefix.
71 if (p.peekWord() === "query") {
72 p.consumeWord("query");
73 p.skipWs();
74 // optional operation name
75 if (p.peek() && /[A-Za-z_]/.test(p.peek()!)) p.readName();
76 p.skipWs();
77 }
78 const fields = p.parseSelectionSet();
79 return { ok: true, fields };
80 } catch (err) {
81 return { ok: false, error: (err as Error).message };
82 }
83}
84
85class Parser {
86 private i = 0;
87 constructor(private src: string) {}
88
89 peek(): string | null {
90 return this.i < this.src.length ? this.src[this.i] : null;
91 }
92
93 peekWord(): string {
94 this.skipWs();
95 let j = this.i;
96 let out = "";
97 while (j < this.src.length && /[A-Za-z_]/.test(this.src[j])) {
98 out += this.src[j];
99 j++;
100 }
101 return out;
102 }
103
104 consumeWord(expected: string) {
105 this.skipWs();
106 const w = this.readName();
107 if (w !== expected) {
108 throw new Error(`expected '${expected}', got '${w}'`);
109 }
110 }
111
112 readName(): string {
113 this.skipWs();
114 let out = "";
115 while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) {
116 out += this.src[this.i];
117 this.i++;
118 }
119 if (!out) throw new Error(`expected identifier at ${this.i}`);
120 return out;
121 }
122
123 expect(ch: string) {
124 this.skipWs();
125 if (this.src[this.i] !== ch) {
126 throw new Error(`expected '${ch}' at ${this.i}, got '${this.src[this.i] || "EOF"}'`);
127 }
128 this.i++;
129 }
130
131 skipWs() {
132 while (
133 this.i < this.src.length &&
134 /[\s,]/.test(this.src[this.i])
135 )
136 this.i++;
137 // Line comments (#)
138 if (this.src[this.i] === "#") {
139 while (this.i < this.src.length && this.src[this.i] !== "\n") this.i++;
140 this.skipWs();
141 }
142 }
143
144 parseSelectionSet(): Field[] {
145 this.skipWs();
146 this.expect("{");
147 const out: Field[] = [];
148 while (true) {
149 this.skipWs();
150 if (this.peek() === "}") {
151 this.i++;
152 return out;
153 }
154 out.push(this.parseField());
155 }
156 }
157
158 parseField(): Field {
159 this.skipWs();
160 const first = this.readName();
161 let alias: string | undefined;
162 let name = first;
163 this.skipWs();
164 if (this.peek() === ":") {
165 this.i++;
166 alias = first;
167 name = this.readName();
168 }
169 this.skipWs();
170 const args: Record<string, ArgValue> = {};
171 if (this.peek() === "(") {
172 this.i++;
173 while (true) {
174 this.skipWs();
175 if (this.peek() === ")") {
176 this.i++;
177 break;
178 }
179 const argName = this.readName();
180 this.skipWs();
181 this.expect(":");
182 const val = this.parseValue();
183 args[argName] = val;
184 }
185 }
186 this.skipWs();
187 let selections: Field[] = [];
188 if (this.peek() === "{") {
189 selections = this.parseSelectionSet();
190 }
191 return { name, alias, args, selections };
192 }
193
194 parseValue(): ArgValue {
195 this.skipWs();
196 const ch = this.peek();
197 if (ch === '"') {
198 this.i++;
199 let out = "";
200 while (this.i < this.src.length && this.src[this.i] !== '"') {
201 if (this.src[this.i] === "\\") {
202 this.i++;
203 out += this.src[this.i];
204 } else {
205 out += this.src[this.i];
206 }
207 this.i++;
208 }
209 this.expect('"');
210 return out;
211 }
212 // number
213 if (ch && /[0-9-]/.test(ch)) {
214 let out = "";
215 while (this.i < this.src.length && /[0-9.-]/.test(this.src[this.i])) {
216 out += this.src[this.i];
217 this.i++;
218 }
219 return Number(out);
220 }
221 // boolean / null / enum
222 const w = this.readName();
223 if (w === "true") return true;
224 if (w === "false") return false;
225 if (w === "null") return null;
226 return w; // enum-ish
227 }
228}
229
230// ---------- Executor ----------
231
232type Resolver = (
233 args: Record<string, ArgValue>,
234 sel: Field[],
235 ctx: ExecCtx
236) => Promise<any>;
237
238export interface ExecCtx {
239 user: { id: string; username?: string } | null;
240}
241
242async function resolveSelections(
243 obj: Record<string, any>,
244 selections: Field[]
245): Promise<Record<string, any>> {
246 if (!obj || selections.length === 0) return obj;
247 const out: Record<string, any> = {};
248 for (const s of selections) {
249 const key = s.alias || s.name;
250 const val = obj[s.name];
251 if (val == null) {
252 out[key] = null;
253 continue;
254 }
255 if (Array.isArray(val)) {
256 out[key] = val.map((v) =>
257 typeof v === "object" ? resolveSelections(v, s.selections) : v
258 );
259 // Awaits inside map are wrapped individually above (resolveSelections
260 // returns a promise only if we call it async). Normalise here:
261 out[key] = await Promise.all(
262 val.map((v) =>
263 typeof v === "object"
264 ? resolveSelections(v, s.selections)
265 : Promise.resolve(v)
266 )
267 );
268 } else if (typeof val === "object") {
269 out[key] = await resolveSelections(val, s.selections);
270 } else {
271 out[key] = val;
272 }
273 }
274 return out;
275}
276
277const ROOT: Record<string, Resolver> = {
278 viewer: async (_args, sel, ctx) => {
279 if (!ctx.user) return null;
280 const [u] = await db
281 .select({
282 id: users.id,
283 username: users.username,
284 email: users.email,
285 })
286 .from(users)
287 .where(eq(users.id, ctx.user.id))
288 .limit(1);
289 return u ? resolveSelections(u, sel) : null;
290 },
291
292 user: async (args, sel, _ctx) => {
293 const username = String(args.username || "");
294 if (!username) return null;
295 const [u] = await db
296 .select({
297 id: users.id,
298 username: users.username,
299 email: users.email,
300 createdAt: users.createdAt,
301 })
302 .from(users)
303 .where(eq(users.username, username))
304 .limit(1);
305 if (!u) return null;
306 const needsRepos = sel.some((s) => s.name === "repos");
307 let repos: any[] = [];
308 if (needsRepos) {
309 repos = await db
310 .select({
311 id: repositories.id,
312 name: repositories.name,
313 visibility: repositories.visibility,
314 starCount: repositories.starCount,
315 createdAt: repositories.createdAt,
316 })
317 .from(repositories)
318 .where(
319 and(
320 eq(repositories.ownerId, u.id),
321 eq(repositories.visibility, "public")
322 )
323 )
324 .orderBy(desc(repositories.createdAt))
325 .limit(100);
326 }
327 return resolveSelections({ ...u, repos }, sel);
328 },
329
330 repository: async (args, sel, _ctx) => {
331 const owner = String(args.owner || "");
332 const name = String(args.name || "");
333 if (!owner || !name) return null;
334 const [r] = await db
335 .select({
336 id: repositories.id,
337 name: repositories.name,
338 description: repositories.description,
339 visibility: repositories.visibility,
340 starCount: repositories.starCount,
341 forkCount: repositories.forkCount,
342 createdAt: repositories.createdAt,
343 ownerId: repositories.ownerId,
344 ownerUsername: users.username,
345 })
346 .from(repositories)
347 .innerJoin(users, eq(repositories.ownerId, users.id))
348 .where(and(eq(users.username, owner), eq(repositories.name, name)))
349 .limit(1);
350 if (!r || r.visibility !== "public") return null;
351
352 const payload: Record<string, any> = {
353 ...r,
354 owner: { id: r.ownerId, username: r.ownerUsername },
355 };
356
357 const issuesSel = sel.find((s) => s.name === "issues");
358 if (issuesSel) {
359 const state = String(issuesSel.args.state || "open");
360 const limit = Math.min(100, Number(issuesSel.args.limit || 20));
361 payload.issues = await db
362 .select({
363 id: issues.id,
364 number: issues.number,
365 title: issues.title,
366 state: issues.state,
367 createdAt: issues.createdAt,
368 })
369 .from(issues)
370 .where(
371 and(eq(issues.repositoryId, r.id), eq(issues.state, state))
372 )
373 .orderBy(desc(issues.createdAt))
374 .limit(limit);
375 }
376
377 const prSel = sel.find((s) => s.name === "pullRequests");
378 if (prSel) {
379 const state = String(prSel.args.state || "open");
380 const limit = Math.min(100, Number(prSel.args.limit || 20));
381 payload.pullRequests = await db
382 .select({
383 id: pullRequests.id,
384 number: pullRequests.number,
385 title: pullRequests.title,
386 state: pullRequests.state,
387 createdAt: pullRequests.createdAt,
388 })
389 .from(pullRequests)
390 .where(
391 and(
392 eq(pullRequests.repositoryId, r.id),
393 eq(pullRequests.state, state)
394 )
395 )
396 .orderBy(desc(pullRequests.createdAt))
397 .limit(limit);
398 }
399
400 return resolveSelections(payload, sel);
401 },
402
403 search: async (args, sel, _ctx) => {
404 const q = String(args.q || "").trim();
405 if (!q) return [];
406 const limit = Math.min(50, Number(args.limit || 20));
407 const rows = await db
408 .select({
409 id: repositories.id,
410 name: repositories.name,
411 ownerUsername: users.username,
412 })
413 .from(repositories)
414 .innerJoin(users, eq(repositories.ownerId, users.id))
415 .where(
416 and(
417 eq(repositories.visibility, "public"),
418 or(
419 ilike(repositories.name, `%${q}%`),
420 ilike(repositories.description, `%${q}%`)
421 )!
422 )
423 )
424 .limit(limit);
425 return Promise.all(rows.map((r) => resolveSelections(r, sel)));
426 },
427
428 rateLimit: async (_args, _sel, _ctx) => {
429 // GraphQL doesn't share the REST rate-limit state directly. Surface a
430 // permissive synthetic window so clients can consume the shape.
431 return { remaining: 1000, reset: Math.floor(Date.now() / 1000) + 3600 };
432 },
433};
434
435export async function execute(
436 src: string,
437 ctx: ExecCtx
438): Promise<GqlResponse> {
439 const parsed = parseQuery(src);
440 if (!parsed.ok) {
441 return { errors: [{ message: parsed.error }] };
442 }
443 const out: Record<string, any> = {};
444 const errors: GqlError[] = [];
445 for (const field of parsed.fields) {
446 const resolver = ROOT[field.name];
447 const key = field.alias || field.name;
448 if (!resolver) {
449 errors.push({
450 message: `Unknown root field '${field.name}'`,
451 path: [key],
452 });
453 out[key] = null;
454 continue;
455 }
456 try {
457 const raw = await resolver(field.args, field.selections, ctx);
458 if (raw == null) {
459 out[key] = null;
460 continue;
461 }
462 if (Array.isArray(raw)) {
463 out[key] = await Promise.all(
464 raw.map((v) =>
465 typeof v === "object"
466 ? resolveSelections(v, field.selections)
467 : Promise.resolve(v)
468 )
469 );
470 } else if (typeof raw === "object") {
471 // resolver may already have expanded object to selection set; if not,
472 // do it now.
473 out[key] = await resolveSelections(raw, field.selections);
474 } else {
475 out[key] = raw;
476 }
477 } catch (err) {
478 errors.push({
479 message: (err as Error).message || "execution error",
480 path: [key],
481 });
482 out[key] = null;
483 }
484 }
485 const response: GqlResponse = { data: out };
486 if (errors.length > 0) response.errors = errors;
487 return response;
488}
Addedsrc/lib/marketplace.ts+484−0View fileUnifiedSplit
1/**
2 * Block H — App marketplace + bot identities.
3 *
4 * Known permission names. These are the vocabulary that apps declare and
5 * installers grant. Permissions are string-matched at request time — for v1,
6 * handlers that consume app tokens call `hasPermission(token, "issues:write")`
7 * and fail closed when absent.
8 *
9 * Higher-level permissions imply lower ones (write implies read) so the UI
10 * only presents one level at a time.
11 */
12
13import { createHash, randomBytes } from "node:crypto";
14import { and, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 apps,
18 appBots,
19 appInstallations,
20 appInstallTokens,
21 appEvents,
22 users,
23 type App,
24 type AppInstallation,
25} from "../db/schema";
26
27export const KNOWN_PERMISSIONS = [
28 "contents:read",
29 "contents:write",
30 "issues:read",
31 "issues:write",
32 "pulls:read",
33 "pulls:write",
34 "checks:read",
35 "checks:write",
36 "deployments:read",
37 "deployments:write",
38 "metadata:read",
39] as const;
40
41export type Permission = (typeof KNOWN_PERMISSIONS)[number];
42
43export const KNOWN_EVENTS = [
44 "push",
45 "issues",
46 "issue_comment",
47 "pull_request",
48 "pull_request_review",
49 "check_run",
50 "deployment",
51 "release",
52] as const;
53
54export type EventName = (typeof KNOWN_EVENTS)[number];
55
56// ---------- Pure helpers ----------
57
58/**
59 * Slugify an app name — lowercase alphanumeric + dashes, trim leading/trailing
60 * dashes, cap at 40 chars.
61 */
62export function slugify(name: string): string {
63 return name
64 .toLowerCase()
65 .replace(/[^a-z0-9]+/g, "-")
66 .replace(/^-+|-+$/g, "")
67 .slice(0, 40);
68}
69
70/** Bot usernames are always `<slug>[bot]`. */
71export function botUsername(slug: string): string {
72 return `${slug}[bot]`;
73}
74
75/** Is `granted` a subset of `requested`? Used when validating install forms. */
76export function permissionsSubset(
77 granted: readonly string[],
78 requested: readonly string[]
79): boolean {
80 const req = new Set(requested);
81 return granted.every((g) => req.has(g));
82}
83
84/** Normalise + de-dup permissions. Drops unknown values. */
85export function normalisePermissions(input: readonly string[]): Permission[] {
86 const seen = new Set<string>();
87 const out: Permission[] = [];
88 for (const p of input) {
89 if ((KNOWN_PERMISSIONS as readonly string[]).includes(p) && !seen.has(p)) {
90 seen.add(p);
91 out.push(p as Permission);
92 }
93 }
94 return out;
95}
96
97/** Parse a JSON permission list out of the DB column. */
98export function parsePermissions(raw: string | null | undefined): Permission[] {
99 if (!raw) return [];
100 try {
101 const parsed = JSON.parse(raw);
102 if (!Array.isArray(parsed)) return [];
103 return normalisePermissions(parsed);
104 } catch {
105 return [];
106 }
107}
108
109/** write:* implies read:* on the same resource family. */
110export function hasPermission(
111 granted: readonly string[],
112 required: string
113): boolean {
114 if (granted.includes(required)) return true;
115 // write implies read
116 if (required.endsWith(":read")) {
117 const writeEquivalent = required.replace(":read", ":write");
118 return granted.includes(writeEquivalent);
119 }
120 return false;
121}
122
123export function generateBearerToken(): { token: string; hash: string } {
124 const token = "ghi_" + randomBytes(24).toString("hex");
125 const hash = createHash("sha256").update(token).digest("hex");
126 return { token, hash };
127}
128
129export function hashBearer(token: string): string {
130 return createHash("sha256").update(token).digest("hex");
131}
132
133// ---------- DB helpers ----------
134
135export async function listPublicApps(query = ""): Promise<App[]> {
136 try {
137 const rows = await db
138 .select()
139 .from(apps)
140 .where(
141 query
142 ? and(
143 eq(apps.isPublic, true),
144 or(
145 ilike(apps.name, `%${query}%`),
146 ilike(apps.description, `%${query}%`),
147 ilike(apps.slug, `%${query}%`)
148 )!
149 )
150 : eq(apps.isPublic, true)
151 )
152 .orderBy(desc(apps.createdAt))
153 .limit(100);
154 return rows;
155 } catch {
156 return [];
157 }
158}
159
160export async function getAppBySlug(slug: string): Promise<App | null> {
161 try {
162 const [r] = await db
163 .select()
164 .from(apps)
165 .where(eq(apps.slug, slug))
166 .limit(1);
167 return r || null;
168 } catch {
169 return null;
170 }
171}
172
173export interface CreateAppArgs {
174 name: string;
175 description?: string;
176 iconUrl?: string;
177 homepageUrl?: string;
178 webhookUrl?: string;
179 creatorId: string;
180 permissions: readonly string[];
181 defaultEvents?: readonly string[];
182 isPublic?: boolean;
183}
184
185/** Create an app + matching bot. Slug is derived from name; retries on collision. */
186export async function createApp(args: CreateAppArgs): Promise<App | null> {
187 const baseSlug = slugify(args.name) || "app";
188 for (let attempt = 0; attempt < 6; attempt++) {
189 const slug = attempt === 0 ? baseSlug : `${baseSlug}-${randomBytes(2).toString("hex")}`;
190 const webhookSecret = randomBytes(24).toString("hex");
191 try {
192 const [row] = await db
193 .insert(apps)
194 .values({
195 slug,
196 name: args.name,
197 description: args.description || "",
198 iconUrl: args.iconUrl,
199 homepageUrl: args.homepageUrl,
200 webhookUrl: args.webhookUrl,
201 webhookSecret,
202 creatorId: args.creatorId,
203 permissions: JSON.stringify(normalisePermissions(args.permissions)),
204 defaultEvents: JSON.stringify(
205 (args.defaultEvents || []).filter((e) =>
206 (KNOWN_EVENTS as readonly string[]).includes(e)
207 )
208 ),
209 isPublic: args.isPublic ?? true,
210 })
211 .returning();
212 if (!row) return null;
213 // Create matching bot account
214 await db.insert(appBots).values({
215 appId: row.id,
216 username: botUsername(slug),
217 displayName: `${args.name} (bot)`,
218 avatarUrl: args.iconUrl,
219 });
220 return row;
221 } catch (err: any) {
222 if (String(err?.message || "").includes("duplicate")) continue;
223 console.error("[marketplace] createApp:", err);
224 return null;
225 }
226 }
227 return null;
228}
229
230export async function listInstallationsForApp(
231 appId: string
232): Promise<AppInstallation[]> {
233 try {
234 return await db
235 .select()
236 .from(appInstallations)
237 .where(
238 and(eq(appInstallations.appId, appId), isNull(appInstallations.uninstalledAt))
239 )
240 .orderBy(desc(appInstallations.createdAt));
241 } catch {
242 return [];
243 }
244}
245
246export async function listInstallationsForTarget(
247 targetType: "user" | "org" | "repository",
248 targetId: string
249): Promise<Array<AppInstallation & { app: App | null }>> {
250 try {
251 const rows = await db
252 .select({
253 install: appInstallations,
254 app: apps,
255 })
256 .from(appInstallations)
257 .leftJoin(apps, eq(appInstallations.appId, apps.id))
258 .where(
259 and(
260 eq(appInstallations.targetType, targetType),
261 eq(appInstallations.targetId, targetId),
262 isNull(appInstallations.uninstalledAt)
263 )
264 )
265 .orderBy(desc(appInstallations.createdAt));
266 return rows.map((r) => ({ ...r.install, app: r.app }));
267 } catch {
268 return [];
269 }
270}
271
272export interface InstallArgs {
273 appId: string;
274 installedBy: string;
275 targetType: "user" | "org" | "repository";
276 targetId: string;
277 grantedPermissions: readonly string[];
278}
279
280export async function installApp(
281 args: InstallArgs
282): Promise<AppInstallation | null> {
283 try {
284 // Find the app to validate permissions
285 const [app] = await db
286 .select()
287 .from(apps)
288 .where(eq(apps.id, args.appId))
289 .limit(1);
290 if (!app) return null;
291 const appPerms = parsePermissions(app.permissions);
292 const granted = normalisePermissions(args.grantedPermissions);
293 // Only allow granting what the app actually requests
294 const filtered = granted.filter((p) => appPerms.includes(p));
295 // If a non-uninstalled row exists, soft-update it (idempotent)
296 const [existing] = await db
297 .select()
298 .from(appInstallations)
299 .where(
300 and(
301 eq(appInstallations.appId, args.appId),
302 eq(appInstallations.targetType, args.targetType),
303 eq(appInstallations.targetId, args.targetId),
304 isNull(appInstallations.uninstalledAt)
305 )
306 )
307 .limit(1);
308 if (existing) {
309 await db
310 .update(appInstallations)
311 .set({ grantedPermissions: JSON.stringify(filtered) })
312 .where(eq(appInstallations.id, existing.id));
313 await db.insert(appEvents).values({
314 appId: args.appId,
315 installationId: existing.id,
316 kind: "installed",
317 payload: JSON.stringify({ updated: true }),
318 });
319 return existing;
320 }
321 const [row] = await db
322 .insert(appInstallations)
323 .values({
324 appId: args.appId,
325 installedBy: args.installedBy,
326 targetType: args.targetType,
327 targetId: args.targetId,
328 grantedPermissions: JSON.stringify(filtered),
329 })
330 .returning();
331 if (row) {
332 await db.insert(appEvents).values({
333 appId: args.appId,
334 installationId: row.id,
335 kind: "installed",
336 payload: JSON.stringify({
337 targetType: args.targetType,
338 targetId: args.targetId,
339 }),
340 });
341 }
342 return row || null;
343 } catch (err) {
344 console.error("[marketplace] installApp:", err);
345 return null;
346 }
347}
348
349export async function uninstallApp(installationId: string): Promise<boolean> {
350 try {
351 const [row] = await db
352 .update(appInstallations)
353 .set({ uninstalledAt: new Date() })
354 .where(
355 and(
356 eq(appInstallations.id, installationId),
357 isNull(appInstallations.uninstalledAt)
358 )
359 )
360 .returning();
361 if (row) {
362 await db.insert(appEvents).values({
363 appId: row.appId,
364 installationId: row.id,
365 kind: "uninstalled",
366 });
367 // Revoke all tokens
368 await db
369 .update(appInstallTokens)
370 .set({ revokedAt: new Date() })
371 .where(
372 and(
373 eq(appInstallTokens.installationId, installationId),
374 isNull(appInstallTokens.revokedAt)
375 )
376 );
377 return true;
378 }
379 return false;
380 } catch (err) {
381 console.error("[marketplace] uninstallApp:", err);
382 return false;
383 }
384}
385
386/** Issue a bearer token scoped to a single installation. Default TTL: 1h. */
387export async function issueInstallToken(
388 installationId: string,
389 ttlSeconds = 3600
390): Promise<{ token: string; expiresAt: Date } | null> {
391 try {
392 const { token, hash } = generateBearerToken();
393 const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
394 await db
395 .insert(appInstallTokens)
396 .values({ installationId, tokenHash: hash, expiresAt });
397 return { token, expiresAt };
398 } catch (err) {
399 console.error("[marketplace] issueInstallToken:", err);
400 return null;
401 }
402}
403
404/**
405 * Verify a bearer and return the matched installation + permissions. Returns
406 * null when the token is unknown, revoked, or expired.
407 */
408export async function verifyInstallToken(
409 token: string
410): Promise<{
411 installation: AppInstallation;
412 app: App;
413 botUsername: string;
414 permissions: Permission[];
415} | null> {
416 if (!token || !token.startsWith("ghi_")) return null;
417 const hash = hashBearer(token);
418 try {
419 const [row] = await db
420 .select({
421 inst: appInstallations,
422 app: apps,
423 tok: appInstallTokens,
424 })
425 .from(appInstallTokens)
426 .innerJoin(
427 appInstallations,
428 eq(appInstallTokens.installationId, appInstallations.id)
429 )
430 .innerJoin(apps, eq(appInstallations.appId, apps.id))
431 .where(eq(appInstallTokens.tokenHash, hash))
432 .limit(1);
433 if (!row) return null;
434 if (row.tok.revokedAt) return null;
435 if (row.tok.expiresAt < new Date()) return null;
436 if (row.inst.uninstalledAt) return null;
437 if (row.inst.suspendedAt) return null;
438 const perms = parsePermissions(row.inst.grantedPermissions);
439 const slug = row.app.slug;
440 return {
441 installation: row.inst,
442 app: row.app,
443 botUsername: botUsername(slug),
444 permissions: perms,
445 };
446 } catch {
447 return null;
448 }
449}
450
451/** Admin-ish listing of an app's recent event log. */
452export async function listEventsForApp(
453 appId: string,
454 limit = 50
455): Promise<Array<typeof appEvents.$inferSelect>> {
456 try {
457 return await db
458 .select()
459 .from(appEvents)
460 .where(eq(appEvents.appId, appId))
461 .orderBy(desc(appEvents.createdAt))
462 .limit(limit);
463 } catch {
464 return [];
465 }
466}
467
468/** Count live installs — for app detail page. */
469export async function countInstalls(appId: string): Promise<number> {
470 try {
471 const [r] = await db
472 .select({ n: sql<number>`count(*)::int` })
473 .from(appInstallations)
474 .where(
475 and(
476 eq(appInstallations.appId, appId),
477 isNull(appInstallations.uninstalledAt)
478 )
479 );
480 return Number(r?.n || 0);
481 } catch {
482 return 0;
483 }
484}
Addedsrc/lib/merge-queue.ts+313−0View fileUnifiedSplit
1/**
2 * Block E5 — Merge queue helpers.
3 *
4 * A merge queue serialises merges on `(repository_id, base_branch)`: instead
5 * of merging a PR immediately, it's enqueued. A worker (or the manual
6 * "process next" button surfaced on the queue UI) pops the head of the queue,
7 * re-runs gates against the latest base, and — if green — performs the merge.
8 *
9 * This module is deliberately minimal: no side-effects on gate execution or
10 * the actual git merge (those are owned by `pulls.tsx`). We just manage
11 * the queue state + ordering. Every DB path is wrapped to never throw.
12 */
13
14import { and, asc, eq, sql } from "drizzle-orm";
15import { db } from "../db";
16import { mergeQueueEntries, pullRequests } from "../db/schema";
17import type { MergeQueueEntry } from "../db/schema";
18
19export interface EnqueueArgs {
20 repositoryId: string;
21 pullRequestId: string;
22 baseBranch: string;
23 enqueuedBy?: string | null;
24}
25
26export interface EnqueueResult {
27 ok: boolean;
28 entry?: MergeQueueEntry;
29 reason?: string;
30}
31
32/**
33 * Append a PR to the end of the queue for its `(repo, baseBranch)`. No-op
34 * (returns ok:false with a reason) if the PR is already queued or running.
35 */
36export async function enqueuePr(args: EnqueueArgs): Promise<EnqueueResult> {
37 try {
38 // Check for existing active entry for this PR.
39 const existing = await db
40 .select()
41 .from(mergeQueueEntries)
42 .where(eq(mergeQueueEntries.pullRequestId, args.pullRequestId));
43 const active = existing.find(
44 (e) => e.state === "queued" || e.state === "running"
45 );
46 if (active) {
47 return { ok: false, reason: "Pull request is already in the queue." };
48 }
49
50 // Compute next position in this (repo, base) queue.
51 const rows = await db
52 .select({ maxPos: sql<number>`COALESCE(MAX(${mergeQueueEntries.position}), -1)` })
53 .from(mergeQueueEntries)
54 .where(
55 and(
56 eq(mergeQueueEntries.repositoryId, args.repositoryId),
57 eq(mergeQueueEntries.baseBranch, args.baseBranch),
58 sql`${mergeQueueEntries.state} IN ('queued','running')`
59 )
60 );
61 const nextPos = (rows[0]?.maxPos ?? -1) + 1;
62
63 const [entry] = await db
64 .insert(mergeQueueEntries)
65 .values({
66 repositoryId: args.repositoryId,
67 pullRequestId: args.pullRequestId,
68 baseBranch: args.baseBranch,
69 position: nextPos,
70 enqueuedBy: args.enqueuedBy || null,
71 state: "queued",
72 })
73 .returning();
74 return { ok: true, entry };
75 } catch (err) {
76 console.error("[merge-queue] enqueue:", err);
77 return { ok: false, reason: "Failed to enqueue pull request." };
78 }
79}
80
81/**
82 * Remove an active entry from the queue (user-initiated cancel, or a PR
83 * closed while queued). Marks it `dequeued` rather than deleting for audit.
84 */
85export async function dequeueEntry(entryId: string): Promise<boolean> {
86 try {
87 const res = await db
88 .update(mergeQueueEntries)
89 .set({ state: "dequeued", finishedAt: new Date() })
90 .where(
91 and(
92 eq(mergeQueueEntries.id, entryId),
93 sql`${mergeQueueEntries.state} IN ('queued','running')`
94 )
95 )
96 .returning({ id: mergeQueueEntries.id });
97 return res.length > 0;
98 } catch (err) {
99 console.error("[merge-queue] dequeue:", err);
100 return false;
101 }
102}
103
104/**
105 * Peek the head of the queue for a `(repo, baseBranch)` pair. Returns the
106 * oldest `queued` entry — the one that would be popped next by processNext.
107 */
108export async function peekHead(
109 repositoryId: string,
110 baseBranch: string
111): Promise<MergeQueueEntry | null> {
112 try {
113 const rows = await db
114 .select()
115 .from(mergeQueueEntries)
116 .where(
117 and(
118 eq(mergeQueueEntries.repositoryId, repositoryId),
119 eq(mergeQueueEntries.baseBranch, baseBranch),
120 eq(mergeQueueEntries.state, "queued")
121 )
122 )
123 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
124 .limit(1);
125 return rows[0] || null;
126 } catch {
127 return null;
128 }
129}
130
131/**
132 * List queue entries for a repo, newest-first per base branch. Includes
133 * terminal states so the queue UI can show recent merges/failures.
134 */
135export async function listQueue(
136 repositoryId: string,
137 opts: { limit?: number; baseBranch?: string } = {}
138): Promise<MergeQueueEntry[]> {
139 const limit = opts.limit ?? 100;
140 try {
141 if (opts.baseBranch) {
142 return await db
143 .select()
144 .from(mergeQueueEntries)
145 .where(
146 and(
147 eq(mergeQueueEntries.repositoryId, repositoryId),
148 eq(mergeQueueEntries.baseBranch, opts.baseBranch)
149 )
150 )
151 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
152 .limit(limit);
153 }
154 return await db
155 .select()
156 .from(mergeQueueEntries)
157 .where(eq(mergeQueueEntries.repositoryId, repositoryId))
158 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
159 .limit(limit);
160 } catch {
161 return [];
162 }
163}
164
165/**
166 * Transition the head entry → `running`. Returns the entry (if any) so the
167 * caller can kick off gates + perform the merge. The caller must eventually
168 * call `completeEntry` with success/failure.
169 */
170export async function markHeadRunning(
171 repositoryId: string,
172 baseBranch: string
173): Promise<MergeQueueEntry | null> {
174 const head = await peekHead(repositoryId, baseBranch);
175 if (!head) return null;
176 try {
177 const [updated] = await db
178 .update(mergeQueueEntries)
179 .set({ state: "running", startedAt: new Date() })
180 .where(
181 and(
182 eq(mergeQueueEntries.id, head.id),
183 eq(mergeQueueEntries.state, "queued")
184 )
185 )
186 .returning();
187 return updated || null;
188 } catch {
189 return null;
190 }
191}
192
193/**
194 * Mark a running entry as finished. `state` is the final state
195 * (`merged` | `failed`). Non-running entries are left untouched.
196 */
197export async function completeEntry(
198 entryId: string,
199 finalState: "merged" | "failed",
200 errorMessage?: string
201): Promise<boolean> {
202 try {
203 const res = await db
204 .update(mergeQueueEntries)
205 .set({
206 state: finalState,
207 finishedAt: new Date(),
208 errorMessage: errorMessage || null,
209 })
210 .where(eq(mergeQueueEntries.id, entryId))
211 .returning({ id: mergeQueueEntries.id });
212 return res.length > 0;
213 } catch (err) {
214 console.error("[merge-queue] complete:", err);
215 return false;
216 }
217}
218
219/**
220 * Is this PR currently queued or running? Convenience helper for the merge
221 * UI (so we can swap the button label to "In queue…").
222 */
223export async function isQueued(pullRequestId: string): Promise<boolean> {
224 try {
225 const rows = await db
226 .select({ id: mergeQueueEntries.id })
227 .from(mergeQueueEntries)
228 .where(
229 and(
230 eq(mergeQueueEntries.pullRequestId, pullRequestId),
231 sql`${mergeQueueEntries.state} IN ('queued','running')`
232 )
233 )
234 .limit(1);
235 return rows.length > 0;
236 } catch {
237 return false;
238 }
239}
240
241/**
242 * Check queue depth for `(repo, baseBranch)` — number of `queued` + `running`.
243 */
244export async function queueDepth(
245 repositoryId: string,
246 baseBranch: string
247): Promise<number> {
248 try {
249 const rows = await db
250 .select({ n: sql<number>`COUNT(*)` })
251 .from(mergeQueueEntries)
252 .where(
253 and(
254 eq(mergeQueueEntries.repositoryId, repositoryId),
255 eq(mergeQueueEntries.baseBranch, baseBranch),
256 sql`${mergeQueueEntries.state} IN ('queued','running')`
257 )
258 );
259 return Number(rows[0]?.n || 0);
260 } catch {
261 return 0;
262 }
263}
264
265/**
266 * Resolve PR metadata (number, title) for a list of entries — the queue UI
267 * needs those to render links. Kept in the helper so routes don't have to
268 * re-join.
269 */
270export interface QueueEntryWithPr extends MergeQueueEntry {
271 prNumber: number | null;
272 prTitle: string | null;
273 prState: string | null;
274 prHeadBranch: string | null;
275 prAuthorId: string | null;
276}
277
278export async function listQueueWithPrs(
279 repositoryId: string
280): Promise<QueueEntryWithPr[]> {
281 try {
282 const rows = await db
283 .select({
284 id: mergeQueueEntries.id,
285 repositoryId: mergeQueueEntries.repositoryId,
286 pullRequestId: mergeQueueEntries.pullRequestId,
287 baseBranch: mergeQueueEntries.baseBranch,
288 state: mergeQueueEntries.state,
289 position: mergeQueueEntries.position,
290 enqueuedBy: mergeQueueEntries.enqueuedBy,
291 enqueuedAt: mergeQueueEntries.enqueuedAt,
292 startedAt: mergeQueueEntries.startedAt,
293 finishedAt: mergeQueueEntries.finishedAt,
294 errorMessage: mergeQueueEntries.errorMessage,
295 prNumber: pullRequests.number,
296 prTitle: pullRequests.title,
297 prState: pullRequests.state,
298 prHeadBranch: pullRequests.headBranch,
299 prAuthorId: pullRequests.authorId,
300 })
301 .from(mergeQueueEntries)
302 .leftJoin(
303 pullRequests,
304 eq(mergeQueueEntries.pullRequestId, pullRequests.id)
305 )
306 .where(eq(mergeQueueEntries.repositoryId, repositoryId))
307 .orderBy(asc(mergeQueueEntries.position), asc(mergeQueueEntries.enqueuedAt))
308 .limit(200);
309 return rows as QueueEntryWithPr[];
310 } catch {
311 return [];
312 }
313}
Addedsrc/lib/merge-resolver.ts+212−0View fileUnifiedSplit
1/**
2 * Automated merge conflict resolution using Claude.
3 *
4 * When a merge has conflicts, this module:
5 * 1. Detects conflicting files
6 * 2. Sends each conflict to Claude for resolution
7 * 3. Applies the resolved content and completes the merge
8 */
9
10import Anthropic from "@anthropic-ai/sdk";
11import { config } from "./config";
12import { getRepoPath } from "../git/repository";
13
14interface ConflictFile {
15 path: string;
16 content: string;
17}
18
19interface ResolvedFile {
20 path: string;
21 content: string;
22}
23
24interface MergeResult {
25 success: boolean;
26 resolvedFiles: string[];
27 error?: string;
28 commitSha?: string;
29}
30
31let _client: Anthropic | null = null;
32
33function getClient(): Anthropic {
34 if (!_client) {
35 if (!config.anthropicApiKey) {
36 throw new Error("ANTHROPIC_API_KEY is not set");
37 }
38 _client = new Anthropic({ apiKey: config.anthropicApiKey });
39 }
40 return _client;
41}
42
43async function exec(
44 cmd: string[],
45 opts?: { cwd?: string; env?: Record<string, string> }
46): Promise<{ stdout: string; stderr: string; exitCode: number }> {
47 const proc = Bun.spawn(cmd, {
48 cwd: opts?.cwd,
49 env: { ...process.env, ...opts?.env },
50 stdout: "pipe",
51 stderr: "pipe",
52 });
53 const [stdout, stderr] = await Promise.all([
54 new Response(proc.stdout).text(),
55 new Response(proc.stderr).text(),
56 ]);
57 const exitCode = await proc.exited;
58 return { stdout, stderr, exitCode };
59}
60
61/**
62 * Attempt to merge with automatic conflict resolution via Claude.
63 *
64 * This works in a temporary worktree to avoid disturbing the bare repo state.
65 */
66export async function mergeWithAutoResolve(
67 owner: string,
68 repo: string,
69 baseBranch: string,
70 headBranch: string,
71 mergeMessage: string
72): Promise<MergeResult> {
73 const repoDir = getRepoPath(owner, repo);
74 const worktree = `${repoDir}/_merge_worktree_${Date.now()}`;
75
76 try {
77 // Create a temporary worktree on the base branch
78 const addWt = await exec(
79 ["git", "worktree", "add", worktree, baseBranch],
80 { cwd: repoDir }
81 );
82 if (addWt.exitCode !== 0) {
83 return { success: false, resolvedFiles: [], error: `Failed to create worktree: ${addWt.stderr}` };
84 }
85
86 // Attempt the merge
87 const merge = await exec(
88 ["git", "merge", "--no-commit", "--no-ff", `origin/${headBranch}`],
89 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
90 );
91
92 // If merge succeeded clean (no conflicts), commit it
93 if (merge.exitCode === 0) {
94 const commit = await exec(
95 ["git", "commit", "-m", mergeMessage],
96 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
97 );
98
99 // Get the merge commit SHA
100 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
101
102 // Update the bare repo's base branch ref
103 await exec(
104 ["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
105 { cwd: repoDir }
106 );
107
108 return { success: true, resolvedFiles: [], commitSha: sha.trim() };
109 }
110
111 // There are conflicts — get the list of conflicting files
112 const { stdout: statusOut } = await exec(["git", "diff", "--name-only", "--diff-filter=U"], { cwd: worktree });
113 const conflictPaths = statusOut.trim().split("\n").filter(Boolean);
114
115 if (conflictPaths.length === 0) {
116 return { success: false, resolvedFiles: [], error: "Merge failed but no conflicts detected" };
117 }
118
119 // Read each conflicting file and resolve with Claude
120 const resolvedFiles: string[] = [];
121 for (const filePath of conflictPaths) {
122 const { stdout: conflictContent } = await exec(["cat", filePath], { cwd: worktree });
123 const resolved = await resolveConflict(filePath, conflictContent);
124
125 if (resolved) {
126 // Write resolved content
127 await Bun.write(`${worktree}/${filePath}`, resolved.content);
128 await exec(["git", "add", filePath], { cwd: worktree });
129 resolvedFiles.push(filePath);
130 } else {
131 // Could not resolve this file — abort
132 await exec(["git", "merge", "--abort"], { cwd: worktree });
133 return {
134 success: false,
135 resolvedFiles: [],
136 error: `Could not auto-resolve conflict in ${filePath}`,
137 };
138 }
139 }
140
141 // All conflicts resolved — commit
142 const commit = await exec(
143 ["git", "commit", "-m", `${mergeMessage}\n\nAuto-resolved conflicts in: ${resolvedFiles.join(", ")}`],
144 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
145 );
146
147 if (commit.exitCode !== 0) {
148 return { success: false, resolvedFiles, error: `Commit failed: ${commit.stderr}` };
149 }
150
151 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
152
153 // Update the bare repo ref
154 await exec(
155 ["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
156 { cwd: repoDir }
157 );
158
159 return { success: true, resolvedFiles, commitSha: sha.trim() };
160 } finally {
161 // Clean up the worktree
162 await exec(["git", "worktree", "remove", "--force", worktree], { cwd: repoDir }).catch(() => {});
163 }
164}
165
166/**
167 * Use Claude to resolve a single file's merge conflicts.
168 */
169async function resolveConflict(
170 filePath: string,
171 conflictContent: string
172): Promise<ResolvedFile | null> {
173 const client = getClient();
174
175 try {
176 const message = await client.messages.create({
177 model: "claude-sonnet-4-20250514",
178 max_tokens: 8192,
179 messages: [
180 {
181 role: "user",
182 content: `You are resolving a git merge conflict in the file "${filePath}".
183
184The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Your job is to produce the correctly merged version of the file.
185
186Rules:
187- Keep BOTH sides' changes when they don't contradict
188- When changes truly conflict, choose the version that preserves correctness and doesn't break functionality
189- Remove ALL conflict markers (<<<<<<< HEAD, =======, >>>>>>>)
190- The output must be valid, working code
191- Output ONLY the resolved file content, no explanation, no code fences
192
193File content with conflicts:
194${conflictContent}`,
195 },
196 ],
197 });
198
199 const text = message.content[0].type === "text" ? message.content[0].text : "";
200
201 // Verify no conflict markers remain
202 if (text.includes("<<<<<<<") || text.includes(">>>>>>>")) {
203 console.error(`[merge-resolver] Claude left conflict markers in ${filePath}`);
204 return null;
205 }
206
207 return { path: filePath, content: text };
208 } catch (err) {
209 console.error(`[merge-resolver] Failed to resolve ${filePath}:`, err);
210 return null;
211 }
212}
Addedsrc/lib/mirrors.ts+389−0View fileUnifiedSplit
1/**
2 * Block I9 — Repository mirroring.
3 *
4 * Pull-style mirroring: a mirrored repo has an upstream URL that we
5 * periodically `git fetch` from. We run it as `git remote update` into
6 * the bare repo so refs/heads/* are kept in sync with upstream's.
7 *
8 * SECURITY: only http(s) and git:// URLs are accepted. We refuse any URL
9 * with shell metacharacters, `file://`, `ssh://`, or paths that could
10 * escape the bare repo. Credentials embedded in URLs are allowed (the
11 * caller decides whether to persist them) but stripped from logs.
12 */
13
14import { and, desc, eq } from "drizzle-orm";
15import { db } from "../db";
16import {
17 repoMirrors,
18 repoMirrorRuns,
19 repositories,
20 users,
21} from "../db/schema";
22import { getRepoPath } from "../git/repository";
23
24const MIRROR_REMOTE_NAME = "gluecron-mirror";
25const FETCH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
26
27export interface ValidationResult {
28 ok: boolean;
29 error?: string;
30}
31
32/** Pure — validates an upstream URL for use as a mirror. */
33export function validateUpstreamUrl(url: string): ValidationResult {
34 if (!url || typeof url !== "string") {
35 return { ok: false, error: "URL is required" };
36 }
37 const trimmed = url.trim();
38 if (trimmed.length === 0) {
39 return { ok: false, error: "URL is required" };
40 }
41 if (trimmed.length > 2048) {
42 return { ok: false, error: "URL too long" };
43 }
44 // Reject shell metacharacters that Bun.spawn would pass through safely,
45 // but which should never appear in a legitimate git URL.
46 if (/[\s;&|`$\\<>]/.test(trimmed)) {
47 return { ok: false, error: "URL contains invalid characters" };
48 }
49 // Accept only https/http/git schemes. Reject ssh/file/local paths —
50 // ssh needs key management we don't have yet, file:// lets the user
51 // escape into the server's filesystem.
52 const allowed = /^(https?:\/\/|git:\/\/)/i;
53 if (!allowed.test(trimmed)) {
54 return { ok: false, error: "URL must start with https://, http://, or git://" };
55 }
56 return { ok: true };
57}
58
59/** Strip credentials from a URL for safe logging. */
60export function safeUrlForLog(url: string): string {
61 try {
62 const u = new URL(url);
63 if (u.username || u.password) {
64 u.username = "***";
65 u.password = "";
66 return u.toString();
67 }
68 return url;
69 } catch {
70 return url;
71 }
72}
73
74export interface UpsertMirrorInput {
75 repositoryId: string;
76 upstreamUrl: string;
77 intervalMinutes?: number;
78 isEnabled?: boolean;
79}
80
81/** Create or update the mirror config for a repository. */
82export async function upsertMirror(
83 input: UpsertMirrorInput
84): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
85 const v = validateUpstreamUrl(input.upstreamUrl);
86 if (!v.ok) return { ok: false, error: v.error! };
87
88 try {
89 const [existing] = await db
90 .select()
91 .from(repoMirrors)
92 .where(eq(repoMirrors.repositoryId, input.repositoryId))
93 .limit(1);
94
95 if (existing) {
96 await db
97 .update(repoMirrors)
98 .set({
99 upstreamUrl: input.upstreamUrl.trim(),
100 intervalMinutes: input.intervalMinutes ?? existing.intervalMinutes,
101 isEnabled: input.isEnabled ?? existing.isEnabled,
102 updatedAt: new Date(),
103 })
104 .where(eq(repoMirrors.id, existing.id));
105 return { ok: true, id: existing.id };
106 }
107
108 const [row] = await db
109 .insert(repoMirrors)
110 .values({
111 repositoryId: input.repositoryId,
112 upstreamUrl: input.upstreamUrl.trim(),
113 intervalMinutes: input.intervalMinutes ?? 1440,
114 isEnabled: input.isEnabled ?? true,
115 })
116 .returning({ id: repoMirrors.id });
117 return { ok: true, id: row.id };
118 } catch (err) {
119 console.error("[mirrors] upsertMirror error:", err);
120 return { ok: false, error: "Failed to save mirror configuration" };
121 }
122}
123
124export async function deleteMirror(repositoryId: string): Promise<void> {
125 try {
126 await db
127 .delete(repoMirrors)
128 .where(eq(repoMirrors.repositoryId, repositoryId));
129 } catch (err) {
130 console.error("[mirrors] deleteMirror error:", err);
131 }
132}
133
134export async function getMirrorForRepo(repositoryId: string) {
135 try {
136 const [row] = await db
137 .select()
138 .from(repoMirrors)
139 .where(eq(repoMirrors.repositoryId, repositoryId))
140 .limit(1);
141 return row || null;
142 } catch {
143 return null;
144 }
145}
146
147export async function listRecentRuns(
148 mirrorId: string,
149 limit = 20
150): Promise<Array<typeof repoMirrorRuns.$inferSelect>> {
151 try {
152 return await db
153 .select()
154 .from(repoMirrorRuns)
155 .where(eq(repoMirrorRuns.mirrorId, mirrorId))
156 .orderBy(desc(repoMirrorRuns.startedAt))
157 .limit(limit);
158 } catch {
159 return [];
160 }
161}
162
163/**
164 * Execute one sync run for a mirror. Returns the run row after completion.
165 * Safe to call concurrently per-repo (we'd still end up fetching serially
166 * because git locks `packed-refs` during fetch).
167 */
168export async function runMirrorSync(
169 mirrorId: string
170): Promise<{ ok: boolean; message: string; exitCode: number }> {
171 // Load mirror + owning repo in one shot.
172 const [row] = await db
173 .select({
174 mirror: repoMirrors,
175 repoName: repositories.name,
176 ownerName: users.username,
177 })
178 .from(repoMirrors)
179 .innerJoin(repositories, eq(repoMirrors.repositoryId, repositories.id))
180 .innerJoin(users, eq(repositories.ownerId, users.id))
181 .where(eq(repoMirrors.id, mirrorId))
182 .limit(1);
183
184 if (!row) {
185 return { ok: false, message: "mirror not found", exitCode: -1 };
186 }
187 if (!row.mirror.isEnabled) {
188 return { ok: false, message: "mirror disabled", exitCode: -1 };
189 }
190
191 const repoPath = getRepoPath(row.ownerName, row.repoName);
192
193 const [runRow] = await db
194 .insert(repoMirrorRuns)
195 .values({ mirrorId, status: "running" })
196 .returning();
197
198 const url = row.mirror.upstreamUrl;
199 let exitCode = -1;
200 let stdout = "";
201 let stderr = "";
202
203 try {
204 // Ensure the remote exists and points at the current URL.
205 await runGit(["git", "remote", "remove", MIRROR_REMOTE_NAME], repoPath);
206 const addRes = await runGit(
207 ["git", "remote", "add", MIRROR_REMOTE_NAME, url],
208 repoPath
209 );
210 if (addRes.exitCode !== 0) {
211 throw new Error(`remote add failed: ${addRes.stderr}`);
212 }
213
214 const fetchRes = await runGit(
215 [
216 "git",
217 "fetch",
218 "--prune",
219 "--tags",
220 "--no-write-fetch-head",
221 MIRROR_REMOTE_NAME,
222 "+refs/heads/*:refs/heads/*",
223 ],
224 repoPath,
225 FETCH_TIMEOUT_MS
226 );
227 exitCode = fetchRes.exitCode;
228 stdout = fetchRes.stdout;
229 stderr = fetchRes.stderr;
230 if (exitCode !== 0) {
231 throw new Error(stderr.slice(0, 4000) || "git fetch failed");
232 }
233
234 const messageLines: string[] = [];
235 if (stdout.trim()) messageLines.push(`stdout:\n${stdout.trim()}`);
236 if (stderr.trim()) messageLines.push(`stderr:\n${stderr.trim()}`);
237 const message =
238 messageLines.join("\n\n").slice(0, 4000) || "Mirror synced (no changes)";
239
240 await db
241 .update(repoMirrorRuns)
242 .set({
243 finishedAt: new Date(),
244 status: "ok",
245 message,
246 exitCode,
247 })
248 .where(eq(repoMirrorRuns.id, runRow.id));
249
250 await db
251 .update(repoMirrors)
252 .set({
253 lastSyncedAt: new Date(),
254 lastStatus: "ok",
255 lastError: null,
256 updatedAt: new Date(),
257 })
258 .where(eq(repoMirrors.id, mirrorId));
259
260 return { ok: true, message, exitCode };
261 } catch (err: any) {
262 const errMsg = String(err?.message || err || "unknown error").slice(0, 4000);
263 await db
264 .update(repoMirrorRuns)
265 .set({
266 finishedAt: new Date(),
267 status: "error",
268 message: errMsg,
269 exitCode,
270 })
271 .where(eq(repoMirrorRuns.id, runRow.id));
272
273 await db
274 .update(repoMirrors)
275 .set({
276 lastSyncedAt: new Date(),
277 lastStatus: "error",
278 lastError: errMsg,
279 updatedAt: new Date(),
280 })
281 .where(eq(repoMirrors.id, mirrorId));
282
283 return { ok: false, message: errMsg, exitCode };
284 }
285}
286
287// ---------- Internal ----------
288
289async function runGit(
290 cmd: string[],
291 cwd: string,
292 timeoutMs = 60_000
293): Promise<{ exitCode: number; stdout: string; stderr: string }> {
294 try {
295 const proc = Bun.spawn(cmd, {
296 cwd,
297 stdout: "pipe",
298 stderr: "pipe",
299 env: {
300 ...process.env,
301 GIT_TERMINAL_PROMPT: "0", // never prompt for creds
302 },
303 });
304 const timer = setTimeout(() => {
305 try {
306 proc.kill("SIGKILL");
307 } catch {
308 // ignore
309 }
310 }, timeoutMs);
311 const [stdout, stderr] = await Promise.all([
312 new Response(proc.stdout).text(),
313 new Response(proc.stderr).text(),
314 ]);
315 const exitCode = await proc.exited;
316 clearTimeout(timer);
317 return { exitCode, stdout, stderr };
318 } catch (err: any) {
319 return {
320 exitCode: -1,
321 stdout: "",
322 stderr: String(err?.message || err || "spawn failed"),
323 };
324 }
325}
326
327/** Returns mirrors that are due for a sync (used by admin cron trigger). */
328export async function listDueMirrors(
329 now: Date = new Date()
330): Promise<Array<{ id: string; repositoryId: string; upstreamUrl: string }>> {
331 try {
332 const rows = await db
333 .select()
334 .from(repoMirrors)
335 .where(eq(repoMirrors.isEnabled, true));
336 const due: Array<{
337 id: string;
338 repositoryId: string;
339 upstreamUrl: string;
340 }> = [];
341 for (const r of rows) {
342 if (!r.lastSyncedAt) {
343 due.push({
344 id: r.id,
345 repositoryId: r.repositoryId,
346 upstreamUrl: r.upstreamUrl,
347 });
348 continue;
349 }
350 const last = new Date(r.lastSyncedAt as any).getTime();
351 const elapsedMin = (now.getTime() - last) / 60000;
352 if (elapsedMin >= r.intervalMinutes) {
353 due.push({
354 id: r.id,
355 repositoryId: r.repositoryId,
356 upstreamUrl: r.upstreamUrl,
357 });
358 }
359 }
360 return due;
361 } catch {
362 return [];
363 }
364}
365
366/** Run sync for every due mirror. Returns summary counts. */
367export async function syncAllDue(): Promise<{
368 total: number;
369 ok: number;
370 failed: number;
371}> {
372 const due = await listDueMirrors();
373 let ok = 0;
374 let failed = 0;
375 for (const m of due) {
376 const r = await runMirrorSync(m.id);
377 if (r.ok) ok++;
378 else failed++;
379 }
380 return { total: due.length, ok, failed };
381}
382
383// Suppress unused import warning for `and`.
384void and;
385
386export const __internal = {
387 MIRROR_REMOTE_NAME,
388 FETCH_TIMEOUT_MS,
389};
Addedsrc/lib/namespace.ts+113−0View fileUnifiedSplit
1/**
2 * Namespace resolution (Block B2).
3 *
4 * The URL path `/:slug` can resolve to either a user or an organization.
5 * Usernames and org slugs occupy the same routing namespace; at creation
6 * time we refuse an org slug that collides with a username (and vice-versa
7 * at register time — see `routes/auth.tsx`).
8 *
9 * Helpers here are read-only and swallow DB errors: they return `null` on
10 * failure so page handlers can fall through to 404 instead of 500.
11 */
12
13import { and, eq, isNull } from "drizzle-orm";
14import { db } from "../db";
15import { users, organizations, repositories } from "../db/schema";
16
17export type Namespace =
18 | { kind: "user"; id: string; slug: string }
19 | { kind: "org"; id: string; slug: string };
20
21/**
22 * Resolve a URL slug to either a user or an org. User lookups win first
23 * (usernames are the legacy, most-used namespace). Returns `null` if neither
24 * exists or the DB is unreachable.
25 */
26export async function resolveNamespace(
27 slug: string
28): Promise<Namespace | null> {
29 if (!slug) return null;
30 try {
31 const [u] = await db
32 .select({ id: users.id, slug: users.username })
33 .from(users)
34 .where(eq(users.username, slug))
35 .limit(1);
36 if (u) return { kind: "user", id: u.id, slug: u.slug };
37
38 const [o] = await db
39 .select({ id: organizations.id, slug: organizations.slug })
40 .from(organizations)
41 .where(eq(organizations.slug, slug))
42 .limit(1);
43 if (o) return { kind: "org", id: o.id, slug: o.slug };
44
45 return null;
46 } catch (err) {
47 console.error("[namespace] resolveNamespace:", err);
48 return null;
49 }
50}
51
52/**
53 * Load a repo by its URL path `:owner/:repo`. Works for both user-owned
54 * and org-owned repos.
55 */
56export async function loadRepoByPath(
57 ownerSlug: string,
58 repoName: string
59): Promise<typeof repositories.$inferSelect | null> {
60 const ns = await resolveNamespace(ownerSlug);
61 if (!ns) return null;
62 try {
63 if (ns.kind === "user") {
64 const [r] = await db
65 .select()
66 .from(repositories)
67 .where(
68 and(
69 eq(repositories.ownerId, ns.id),
70 eq(repositories.name, repoName),
71 isNull(repositories.orgId)
72 )
73 )
74 .limit(1);
75 return r || null;
76 }
77 const [r] = await db
78 .select()
79 .from(repositories)
80 .where(
81 and(eq(repositories.orgId, ns.id), eq(repositories.name, repoName))
82 )
83 .limit(1);
84 return r || null;
85 } catch (err) {
86 console.error("[namespace] loadRepoByPath:", err);
87 return null;
88 }
89}
90
91/**
92 * List all repos (user or org) for a URL slug. Used by the profile page
93 * to render a unified "repos owned by X" list.
94 */
95export async function listReposForNamespace(ns: Namespace) {
96 try {
97 if (ns.kind === "user") {
98 return await db
99 .select()
100 .from(repositories)
101 .where(
102 and(eq(repositories.ownerId, ns.id), isNull(repositories.orgId))
103 );
104 }
105 return await db
106 .select()
107 .from(repositories)
108 .where(eq(repositories.orgId, ns.id));
109 } catch (err) {
110 console.error("[namespace] listReposForNamespace:", err);
111 return [];
112 }
113}
Addedsrc/lib/notify.ts+225−0View fileUnifiedSplit
1/**
2 * Notifications + audit log helpers.
3 * Swallows DB failures so notifications never break the primary request path.
4 *
5 * Email fan-out (Block A8):
6 * For certain kinds (mention / assigned / gate_failed / review_requested)
7 * we ALSO send an email, if the recipient has opted in via their profile
8 * preferences. Email failures are logged and swallowed.
9 */
10
11import { inArray, eq } from "drizzle-orm";
12import { db } from "../db";
13import { notifications, auditLog, users } from "../db/schema";
14import { sendEmail, absoluteUrl } from "./email";
15
16export type NotificationKind =
17 | "mention"
18 | "review_requested"
19 | "pr_opened"
20 | "pr_merged"
21 | "pr_closed"
22 | "issue_opened"
23 | "issue_closed"
24 | "assigned"
25 | "ai_review"
26 | "gate_failed"
27 | "gate_repaired"
28 | "gate_passed"
29 | "security_alert"
30 | "deploy_success"
31 | "deploy_failed"
32 | "deployment_approval"
33 | "release_published"
34 | "repo_archived";
35
36/** Kinds that can trigger email delivery. Keep this list conservative — any
37 * kind here must map to a user preference column on the users table. */
38const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([
39 "mention",
40 "review_requested",
41 "assigned",
42 "gate_failed",
43]);
44
45/** Map notification kind → user preference column name. */
46function prefFor(kind: NotificationKind):
47 | "notifyEmailOnMention"
48 | "notifyEmailOnAssign"
49 | "notifyEmailOnGateFail"
50 | null {
51 switch (kind) {
52 case "mention":
53 case "review_requested":
54 return "notifyEmailOnMention";
55 case "assigned":
56 return "notifyEmailOnAssign";
57 case "gate_failed":
58 return "notifyEmailOnGateFail";
59 default:
60 return null;
61 }
62}
63
64function subjectFor(kind: NotificationKind, title: string): string {
65 const tag =
66 kind === "gate_failed"
67 ? "[gate failed]"
68 : kind === "assigned"
69 ? "[assigned]"
70 : kind === "review_requested"
71 ? "[review requested]"
72 : kind === "mention"
73 ? "[mention]"
74 : `[${kind}]`;
75 return `${tag} ${title}`.slice(0, 180);
76}
77
78function bodyFor(title: string, body: string | undefined, url: string | undefined): string {
79 const lines = [title];
80 if (body) lines.push("", body);
81 if (url) lines.push("", absoluteUrl(url));
82 lines.push("", "—", "You can opt out of these emails at /settings.");
83 return lines.join("\n");
84}
85
86async function maybeEmail(
87 userIds: string[],
88 kind: NotificationKind,
89 opts: { title: string; body?: string; url?: string }
90): Promise<void> {
91 if (!EMAIL_ELIGIBLE.has(kind)) return;
92 const prefCol = prefFor(kind);
93 if (!prefCol) return;
94 if (userIds.length === 0) return;
95
96 let recipients: Array<{ email: string; pref: boolean }> = [];
97 try {
98 const rows = await db
99 .select({
100 id: users.id,
101 email: users.email,
102 mention: users.notifyEmailOnMention,
103 assign: users.notifyEmailOnAssign,
104 gate: users.notifyEmailOnGateFail,
105 })
106 .from(users)
107 .where(inArray(users.id, userIds));
108 recipients = rows.map((r) => ({
109 email: r.email,
110 pref:
111 prefCol === "notifyEmailOnMention"
112 ? r.mention
113 : prefCol === "notifyEmailOnAssign"
114 ? r.assign
115 : r.gate,
116 }));
117 } catch (err) {
118 console.error("[notify] email recipient lookup failed:", err);
119 return;
120 }
121
122 const subject = subjectFor(kind, opts.title);
123 const text = bodyFor(opts.title, opts.body, opts.url);
124
125 // Fire in parallel; each call swallows its own errors.
126 await Promise.all(
127 recipients
128 .filter((r) => r.pref && r.email)
129 .map((r) =>
130 sendEmail({ to: r.email, subject, text }).catch((err) => {
131 console.error("[notify] sendEmail threw:", err);
132 return { ok: false as const, provider: "none" as const };
133 })
134 )
135 );
136}
137
138export async function notify(
139 userId: string,
140 opts: {
141 kind: NotificationKind;
142 title: string;
143 body?: string;
144 url?: string;
145 repositoryId?: string;
146 }
147): Promise<void> {
148 try {
149 await db.insert(notifications).values({
150 userId,
151 kind: opts.kind,
152 title: opts.title,
153 body: opts.body,
154 url: opts.url,
155 repositoryId: opts.repositoryId,
156 });
157 } catch (err) {
158 console.error("[notify] failed:", err);
159 }
160 await maybeEmail([userId], opts.kind, opts);
161}
162
163export async function notifyMany(
164 userIds: string[],
165 opts: {
166 kind: NotificationKind;
167 title: string;
168 body?: string;
169 url?: string;
170 repositoryId?: string;
171 }
172): Promise<void> {
173 const unique = Array.from(new Set(userIds));
174 if (unique.length === 0) return;
175 try {
176 await db.insert(notifications).values(
177 unique.map((userId) => ({
178 userId,
179 kind: opts.kind,
180 title: opts.title,
181 body: opts.body,
182 url: opts.url,
183 repositoryId: opts.repositoryId,
184 }))
185 );
186 } catch (err) {
187 console.error("[notify] batch failed:", err);
188 }
189 await maybeEmail(unique, opts.kind, opts);
190}
191
192export async function audit(opts: {
193 userId?: string | null;
194 repositoryId?: string | null;
195 action: string;
196 targetType?: string;
197 targetId?: string;
198 ip?: string;
199 userAgent?: string;
200 metadata?: Record<string, unknown>;
201}): Promise<void> {
202 try {
203 await db.insert(auditLog).values({
204 userId: opts.userId ?? null,
205 repositoryId: opts.repositoryId ?? null,
206 action: opts.action,
207 targetType: opts.targetType,
208 targetId: opts.targetId,
209 ip: opts.ip,
210 userAgent: opts.userAgent,
211 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
212 });
213 } catch (err) {
214 // Audit must never break the primary flow
215 console.error("[audit] failed:", err);
216 }
217}
218
219/** Test-only hook so unit tests can assert the kind→pref mapping. */
220export const __internal = {
221 EMAIL_ELIGIBLE,
222 prefFor,
223 subjectFor,
224 bodyFor,
225};
Addedsrc/lib/oauth.ts+197−0View fileUnifiedSplit
1/**
2 * OAuth 2.0 helpers (Block B6).
3 *
4 * Stateless utilities for the OAuth provider implemented in
5 * `src/routes/oauth.tsx`:
6 *
7 * - token / code / secret generation
8 * - constant-time SHA-256 hashing (matches how we store PATs)
9 * - PKCE (RFC 7636) code_challenge verification
10 * - scope parsing + validation
11 * - redirect-URI matching (exact match, no wildcards)
12 *
13 * All outputs that end up in URLs or Authorization headers are prefix-tagged
14 * so they're greppable in logs without leaking the secret portion.
15 */
16
17/** Supported OAuth scopes. Add new ones here + document on the consent screen. */
18export const SUPPORTED_SCOPES = [
19 "read:user",
20 "read:repo",
21 "write:repo",
22 "read:org",
23 "write:org",
24 "read:issue",
25 "write:issue",
26 "read:pr",
27 "write:pr",
28] as const;
29
30export type OauthScope = (typeof SUPPORTED_SCOPES)[number];
31
32/** Default access token TTL (1 hour). */
33export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
34/** Refresh token TTL (30 days). */
35export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
36/** Authorization code TTL (10 min — well within RFC 6749's 10-minute max). */
37export const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
38
39function randomHex(byteLen: number): string {
40 const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
41 let s = "";
42 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
43 return s;
44}
45
46/** Returns a 20-char client_id like `glc_app_<12 hex>`. */
47export function generateClientId(): string {
48 return "glc_app_" + randomHex(12);
49}
50
51/** Returns a 40-char client secret like `glcs_<32 hex>`. */
52export function generateClientSecret(): string {
53 return "glcs_" + randomHex(32);
54}
55
56export function generateAuthCode(): string {
57 return "glca_" + randomHex(24);
58}
59
60export function generateAccessToken(): string {
61 return "glct_" + randomHex(32);
62}
63
64export function generateRefreshToken(): string {
65 return "glcr_" + randomHex(32);
66}
67
68/** SHA-256 hex digest. Same algorithm as src/routes/tokens.ts. */
69export async function sha256Hex(input: string): Promise<string> {
70 const data = new TextEncoder().encode(input);
71 const digest = await crypto.subtle.digest("SHA-256", data);
72 const bytes = new Uint8Array(digest);
73 let s = "";
74 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
75 return s;
76}
77
78/** Base64url (no padding) — used by PKCE. */
79export function b64urlFromBytes(bytes: Uint8Array): string {
80 let bin = "";
81 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
82 return btoa(bin)
83 .replace(/\+/g, "-")
84 .replace(/\//g, "_")
85 .replace(/=+$/, "");
86}
87
88/**
89 * PKCE verification (RFC 7636).
90 * For "S256": base64url(SHA-256(verifier)) must equal the challenge.
91 * For "plain": the verifier must equal the challenge literally.
92 * Returns true when the method is unrecognized but `challenge` is empty —
93 * callers should refuse before calling this if PKCE is required.
94 */
95export async function verifyPkce(opts: {
96 challenge: string | null | undefined;
97 method: string | null | undefined;
98 verifier: string;
99}): Promise<boolean> {
100 const challenge = (opts.challenge || "").trim();
101 if (!challenge) return false;
102 const method = (opts.method || "plain").toLowerCase();
103
104 if (method === "s256") {
105 const data = new TextEncoder().encode(opts.verifier);
106 const digest = await crypto.subtle.digest("SHA-256", data);
107 const produced = b64urlFromBytes(new Uint8Array(digest));
108 return timingSafeEqual(produced, challenge);
109 }
110 if (method === "plain") {
111 return timingSafeEqual(opts.verifier, challenge);
112 }
113 return false;
114}
115
116/** Constant-time string comparison. */
117export function timingSafeEqual(a: string, b: string): boolean {
118 if (a.length !== b.length) return false;
119 let diff = 0;
120 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
121 return diff === 0;
122}
123
124/**
125 * Parse a scope string (space-separated per RFC 6749 or comma-separated for
126 * convenience). Unknown scopes are dropped silently; duplicates collapsed.
127 */
128export function parseScopes(input: string | null | undefined): OauthScope[] {
129 if (!input) return [];
130 const parts = input.split(/[\s,]+/).filter(Boolean);
131 const seen = new Set<string>();
132 const out: OauthScope[] = [];
133 for (const p of parts) {
134 const s = p.trim().toLowerCase();
135 if (!s || seen.has(s)) continue;
136 if ((SUPPORTED_SCOPES as readonly string[]).includes(s)) {
137 out.push(s as OauthScope);
138 seen.add(s);
139 }
140 }
141 return out;
142}
143
144/** Serialize scopes back to a space-separated string. */
145export function serializeScopes(scopes: readonly OauthScope[]): string {
146 return scopes.join(" ");
147}
148
149/**
150 * Parse an app's stored `redirectUris` column (newline-separated).
151 * Empty / whitespace-only lines are ignored.
152 */
153export function parseRedirectUris(stored: string): string[] {
154 return stored
155 .split(/\r?\n/)
156 .map((s) => s.trim())
157 .filter(Boolean);
158}
159
160/**
161 * Validate a single redirect URI for storage:
162 * - must be absolute http(s):// (http only for localhost)
163 * - no fragment (`#...`)
164 * - no wildcards
165 */
166export function isValidRedirectUri(uri: string): boolean {
167 try {
168 const u = new URL(uri);
169 if (u.protocol !== "http:" && u.protocol !== "https:") return false;
170 if (u.protocol === "http:") {
171 if (!["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
172 return false;
173 }
174 }
175 if (u.hash) return false;
176 if (uri.includes("*")) return false;
177 return true;
178 } catch {
179 return false;
180 }
181}
182
183/** Exact-match check against the app's registered list. */
184export function redirectUriAllowed(
185 candidate: string,
186 registered: readonly string[]
187): boolean {
188 if (!candidate) return false;
189 for (const r of registered) {
190 if (timingSafeEqual(candidate, r)) return true;
191 }
192 return false;
193}
194
195export const __test = {
196 randomHex,
197};
Addedsrc/lib/orgs.ts+208−0View fileUnifiedSplit
1/**
2 * Organization helpers (Block B1).
3 *
4 * Keeps slug validation + role math out of the route handler so they
5 * can be unit-tested without touching the database.
6 */
7
8import { and, eq } from "drizzle-orm";
9import { db } from "../db";
10import {
11 organizations,
12 orgMembers,
13 teams,
14 teamMembers,
15 users,
16 type OrgRole,
17 type TeamRole,
18} from "../db/schema";
19
20/**
21 * Slug rules: 2–39 chars, [a-z0-9-], cannot start or end with a hyphen,
22 * cannot contain consecutive hyphens. Same shape as GitHub org slugs.
23 */
24const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$/;
25
26/** Reserved slugs we never allow (collision with app routes). */
27const RESERVED_SLUGS: ReadonlySet<string> = new Set([
28 "api",
29 "admin",
30 "auth",
31 "login",
32 "logout",
33 "register",
34 "settings",
35 "dashboard",
36 "explore",
37 "search",
38 "new",
39 "notifications",
40 "theme",
41 "healthz",
42 "readyz",
43 "metrics",
44 "orgs",
45 "org",
46 "team",
47 "teams",
48 "user",
49 "users",
50 "repo",
51 "repos",
52 "issues",
53 "pulls",
54 "releases",
55 "shortcuts",
56 "help",
57 "docs",
58 "ask",
59 "about",
60 "static",
61 "assets",
62]);
63
64export function isValidSlug(s: string): boolean {
65 if (!s || s.length < 2 || s.length > 39) return false;
66 if (!SLUG_RE.test(s)) return false;
67 if (s.includes("--")) return false;
68 if (RESERVED_SLUGS.has(s)) return false;
69 return true;
70}
71
72export function normalizeSlug(s: string): string {
73 return s.trim().toLowerCase();
74}
75
76/**
77 * Role comparisons. Higher rank beats lower.
78 * owner > admin > member
79 */
80const ORG_ROLE_RANK: Record<OrgRole, number> = {
81 owner: 3,
82 admin: 2,
83 member: 1,
84};
85
86export function orgRoleAtLeast(have: string, need: OrgRole): boolean {
87 const h = ORG_ROLE_RANK[have as OrgRole] ?? 0;
88 const n = ORG_ROLE_RANK[need];
89 return h >= n;
90}
91
92export function isValidOrgRole(s: string): s is OrgRole {
93 return s === "owner" || s === "admin" || s === "member";
94}
95
96export function isValidTeamRole(s: string): s is TeamRole {
97 return s === "maintainer" || s === "member";
98}
99
100/** Fetch an org + the current user's role in it (if any). */
101export async function loadOrgForUser(
102 slug: string,
103 userId: string | undefined
104): Promise<{
105 org: typeof organizations.$inferSelect | null;
106 role: OrgRole | null;
107}> {
108 try {
109 const [orgRow] = await db
110 .select()
111 .from(organizations)
112 .where(eq(organizations.slug, slug))
113 .limit(1);
114 if (!orgRow) return { org: null, role: null };
115 if (!userId) return { org: orgRow, role: null };
116 const [mem] = await db
117 .select({ role: orgMembers.role })
118 .from(orgMembers)
119 .where(
120 and(eq(orgMembers.orgId, orgRow.id), eq(orgMembers.userId, userId))
121 )
122 .limit(1);
123 return {
124 org: orgRow,
125 role: mem && isValidOrgRole(mem.role) ? mem.role : null,
126 };
127 } catch (err) {
128 console.error("[orgs] loadOrgForUser:", err);
129 return { org: null, role: null };
130 }
131}
132
133export async function listOrgsForUser(userId: string) {
134 try {
135 const rows = await db
136 .select({
137 id: organizations.id,
138 slug: organizations.slug,
139 name: organizations.name,
140 description: organizations.description,
141 avatarUrl: organizations.avatarUrl,
142 role: orgMembers.role,
143 })
144 .from(orgMembers)
145 .innerJoin(organizations, eq(organizations.id, orgMembers.orgId))
146 .where(eq(orgMembers.userId, userId));
147 return rows;
148 } catch (err) {
149 console.error("[orgs] listOrgsForUser:", err);
150 return [];
151 }
152}
153
154export async function listOrgMembers(orgId: string) {
155 try {
156 return await db
157 .select({
158 userId: orgMembers.userId,
159 role: orgMembers.role,
160 username: users.username,
161 displayName: users.displayName,
162 avatarUrl: users.avatarUrl,
163 })
164 .from(orgMembers)
165 .innerJoin(users, eq(users.id, orgMembers.userId))
166 .where(eq(orgMembers.orgId, orgId));
167 } catch (err) {
168 console.error("[orgs] listOrgMembers:", err);
169 return [];
170 }
171}
172
173export async function listTeamsForOrg(orgId: string) {
174 try {
175 return await db.select().from(teams).where(eq(teams.orgId, orgId));
176 } catch (err) {
177 console.error("[orgs] listTeamsForOrg:", err);
178 return [];
179 }
180}
181
182export async function listTeamMembers(teamId: string) {
183 try {
184 return await db
185 .select({
186 userId: teamMembers.userId,
187 role: teamMembers.role,
188 username: users.username,
189 displayName: users.displayName,
190 avatarUrl: users.avatarUrl,
191 })
192 .from(teamMembers)
193 .innerJoin(users, eq(users.id, teamMembers.userId))
194 .where(eq(teamMembers.teamId, teamId));
195 } catch (err) {
196 console.error("[orgs] listTeamMembers:", err);
197 return [];
198 }
199}
200
201/**
202 * Exported for unit tests.
203 */
204export const __test = {
205 ORG_ROLE_RANK,
206 RESERVED_SLUGS,
207 SLUG_RE,
208};
Addedsrc/lib/packages.ts+229−0View fileUnifiedSplit
1/**
2 * npm-compatible package registry helpers (Block C2).
3 *
4 * Small, pure helpers used by both the protocol routes and the UI. Keeps
5 * the route file itself focused on wiring + DB access.
6 */
7
8import type { Package, PackageVersion, PackageTag } from "../db/schema";
9
10export type ParsedPackageName = {
11 scope: string | null; // "@acme" (with leading @) or null
12 name: string; // "foo"
13 full: string; // "@acme/foo" or "foo"
14};
15
16/**
17 * Parse an npm-style package name. Accepts both "foo" and "@scope/foo".
18 * Returns null on malformed input.
19 */
20export function parsePackageName(raw: string): ParsedPackageName | null {
21 if (!raw || typeof raw !== "string") return null;
22 // Decode %2F ("@scope%2Fname" — the npm client URL-encodes scoped names)
23 const decoded = (() => {
24 try {
25 return decodeURIComponent(raw);
26 } catch {
27 return raw;
28 }
29 })();
30
31 const trimmed = decoded.trim();
32 if (!trimmed) return null;
33
34 // Disallow slashes / whitespace / control chars in the bare name.
35 const safeSegment = /^[a-z0-9][a-z0-9._-]*$/i;
36
37 if (trimmed.startsWith("@")) {
38 const slash = trimmed.indexOf("/");
39 if (slash < 2) return null;
40 const scope = trimmed.slice(0, slash); // "@acme"
41 const name = trimmed.slice(slash + 1); // "foo"
42 const scopeBody = scope.slice(1); // "acme"
43 if (!safeSegment.test(scopeBody)) return null;
44 if (!safeSegment.test(name)) return null;
45 return { scope, name, full: `${scope}/${name}` };
46 }
47
48 if (!safeSegment.test(trimmed)) return null;
49 return { scope: null, name: trimmed, full: trimmed };
50}
51
52/** SHA1 hex of the given bytes (npm legacy shasum). */
53export function computeShasum(bytes: Uint8Array): string {
54 // Bun.CryptoHasher supports sha1 natively.
55 const h = new Bun.CryptoHasher("sha1");
56 h.update(bytes);
57 return h.digest("hex");
58}
59
60/** Subresource-Integrity format: "sha512-<base64 of sha512 digest>". */
61export function computeIntegrity(bytes: Uint8Array): string {
62 const h = new Bun.CryptoHasher("sha512");
63 h.update(bytes);
64 const digest = h.digest(); // Buffer
65 const b64 = Buffer.from(digest).toString("base64");
66 return `sha512-${b64}`;
67}
68
69/**
70 * Resolve the owner+repo that a package's metadata points to.
71 * Accepts either the object form `{ url: "...", type: "git" }` or the
72 * string shorthand. Returns null if we cannot locate a gluecron URL.
73 *
74 * Handles:
75 * - "http(s)://host/owner/repo(.git)?"
76 * - "git+https://host/owner/repo.git"
77 * - "git@host:owner/repo.git"
78 * - "github:owner/repo" → treated as "owner/repo" (still matched)
79 */
80export function resolveRepoFromPackageJson(
81 meta: unknown
82): { owner: string; repo: string } | null {
83 if (!meta || typeof meta !== "object") return null;
84 const m = meta as Record<string, unknown>;
85 const repoField = m["repository"];
86 let url: string | null = null;
87
88 if (typeof repoField === "string") {
89 url = repoField;
90 } else if (repoField && typeof repoField === "object") {
91 const u = (repoField as Record<string, unknown>)["url"];
92 if (typeof u === "string") url = u;
93 }
94
95 if (!url) return null;
96 return parseRepoUrl(url);
97}
98
99/** Lower-level helper also used by the publish route. Exported for tests. */
100export function parseRepoUrl(
101 urlRaw: string
102): { owner: string; repo: string } | null {
103 if (!urlRaw || typeof urlRaw !== "string") return null;
104 let url = urlRaw.trim();
105 if (!url) return null;
106
107 // Strip a "git+" prefix ("git+https://..."").
108 if (url.startsWith("git+")) url = url.slice(4);
109
110 // SCP-style: "user@host:owner/repo.git" — everything after the colon is
111 // the path we want. Accept either "git@host:a/b" or just "host:a/b".
112 if (!url.includes("://") && url.includes(":") && url.includes("/")) {
113 const colon = url.lastIndexOf(":");
114 const tail = url.slice(colon + 1);
115 if (tail.includes("/")) {
116 return splitOwnerRepo(tail);
117 }
118 }
119
120 // Regular URL form. Accept http(s)://host/owner/repo(.git)?
121 try {
122 const parsed = new URL(url);
123 const path = parsed.pathname.replace(/^\/+/, "");
124 return splitOwnerRepo(path);
125 } catch {
126 // Fall through to plain "owner/repo" path.
127 return splitOwnerRepo(url);
128 }
129}
130
131function splitOwnerRepo(
132 path: string
133): { owner: string; repo: string } | null {
134 const clean = path.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
135 const parts = clean.split("/").filter(Boolean);
136 if (parts.length < 2) return null;
137 const owner = parts[parts.length - 2];
138 const repo = parts[parts.length - 1];
139 if (!owner || !repo) return null;
140 return { owner, repo };
141}
142
143/**
144 * Build an npm "packument" document (the document you get back from
145 * `GET /:name`). Shape roughly follows
146 * https://docs.npmjs.com/registry/api.
147 *
148 * The per-version objects merge the stored `package.json` metadata with the
149 * canonical `dist` block so that `npm install` knows where to fetch the
150 * tarball from.
151 */
152export function buildPackument(
153 pkg: Package,
154 versions: PackageVersion[],
155 tags: PackageTag[],
156 baseUrl: string
157): Record<string, unknown> {
158 const fullName = pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
159 const base = baseUrl.replace(/\/+$/, "");
160
161 // versions map
162 const versionsOut: Record<string, Record<string, unknown>> = {};
163 const timeOut: Record<string, string> = {};
164
165 for (const v of versions) {
166 let meta: Record<string, unknown> = {};
167 try {
168 meta = v.metadata ? JSON.parse(v.metadata) : {};
169 } catch {
170 meta = {};
171 }
172 const tarballUrl = `${base}/npm/${encodeURI(fullName)}/-/${pkg.name}-${v.version}.tgz`;
173 versionsOut[v.version] = {
174 ...meta,
175 name: fullName,
176 version: v.version,
177 dist: {
178 tarball: tarballUrl,
179 shasum: v.shasum,
180 integrity: v.integrity ?? undefined,
181 },
182 ...(v.yanked ? { _yanked: true, _yankedReason: v.yankedReason } : {}),
183 };
184 const pub = v.publishedAt instanceof Date
185 ? v.publishedAt.toISOString()
186 : new Date(v.publishedAt as unknown as string).toISOString();
187 timeOut[v.version] = pub;
188 }
189
190 // dist-tags
191 const distTags: Record<string, string> = {};
192 const versionById = new Map(versions.map((v) => [v.id, v]));
193 for (const t of tags) {
194 const v = versionById.get(t.versionId);
195 if (v) distTags[t.tag] = v.version;
196 }
197 // Fallback: if no "latest" tag, use the most recently published version.
198 if (!distTags["latest"] && versions.length > 0) {
199 const sorted = [...versions].sort((a, b) => {
200 const ad = new Date(a.publishedAt as unknown as string).getTime();
201 const bd = new Date(b.publishedAt as unknown as string).getTime();
202 return bd - ad;
203 });
204 distTags["latest"] = sorted[0].version;
205 }
206
207 return {
208 _id: fullName,
209 name: fullName,
210 description: pkg.description ?? undefined,
211 "dist-tags": distTags,
212 versions: versionsOut,
213 time: timeOut,
214 homepage: pkg.homepage ?? undefined,
215 license: pkg.license ?? undefined,
216 readme: pkg.readme ?? undefined,
217 };
218}
219
220/**
221 * Tarball filename convention expected by npm clients: `<name>-<version>.tgz`.
222 * For scoped packages, the *scope* is part of the URL but NOT the filename.
223 */
224export function tarballFilename(
225 parsed: ParsedPackageName,
226 version: string
227): string {
228 return `${parsed.name}-${version}.tgz`;
229}
Addedsrc/lib/pages.ts+183−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages / static hosting helpers.
3 *
4 * Exposes:
5 * - onPagesPush() — called from post-receive after a gh-pages push
6 * - resolvePagesPath() — URL-rest -> list of blob paths to probe
7 * - contentTypeFor() — extension -> mime string
8 *
9 * Deployment model: every accepted push to the configured source branch
10 * (default "gh-pages") records a row in pages_deployments. Serving reads the
11 * most recent deployment's commit sha and pulls blobs directly out of the
12 * bare repo — there is no on-disk export step.
13 */
14
15import { db } from "../db";
16import { pagesDeployments } from "../db/schema";
17
18/**
19 * Minimal extension -> MIME lookup used by the pages server. Returns
20 * "application/octet-stream" for anything not in the map so the browser
21 * will at least offer the bytes as a download instead of mis-rendering.
22 */
23export function contentTypeFor(filename: string): string {
24 const lower = filename.toLowerCase();
25 const dot = lower.lastIndexOf(".");
26 if (dot < 0) return "application/octet-stream";
27 const ext = lower.slice(dot + 1);
28 switch (ext) {
29 case "html":
30 case "htm":
31 return "text/html; charset=utf-8";
32 case "css":
33 return "text/css; charset=utf-8";
34 case "js":
35 case "mjs":
36 return "application/javascript; charset=utf-8";
37 case "json":
38 return "application/json; charset=utf-8";
39 case "svg":
40 return "image/svg+xml";
41 case "png":
42 return "image/png";
43 case "jpg":
44 case "jpeg":
45 return "image/jpeg";
46 case "gif":
47 return "image/gif";
48 case "webp":
49 return "image/webp";
50 case "ico":
51 return "image/x-icon";
52 case "txt":
53 case "md":
54 return "text/plain; charset=utf-8";
55 case "pdf":
56 return "application/pdf";
57 case "xml":
58 return "application/xml; charset=utf-8";
59 case "wasm":
60 return "application/wasm";
61 case "woff":
62 return "font/woff";
63 case "woff2":
64 return "font/woff2";
65 case "ttf":
66 return "font/ttf";
67 case "otf":
68 return "font/otf";
69 case "map":
70 return "application/json; charset=utf-8";
71 default:
72 return "application/octet-stream";
73 }
74}
75
76/**
77 * Normalise a source-dir setting to a plain prefix with no leading/trailing
78 * slashes (empty string for root).
79 */
80function normaliseSourceDir(sourceDir: string): string {
81 let d = (sourceDir || "/").trim();
82 d = d.replace(/^\/+/, "").replace(/\/+$/, "");
83 return d;
84}
85
86/**
87 * Normalise a URL-rest component. Keeps internal slashes but drops leading
88 * ones and any `..` path-traversal attempts. Returns "" for empty / pure "/".
89 */
90function normaliseUrlRest(urlRest: string): string {
91 let r = (urlRest || "").trim();
92 r = r.replace(/^\/+/, "");
93 // Strip ../ segments — cheap sanity, not a full path resolver.
94 const parts = r
95 .split("/")
96 .filter((p) => p.length > 0 && p !== "." && p !== "..");
97 return parts.join("/");
98}
99
100/**
101 * Given a URL rest-path (e.g. "", "about", "blog/first/", "assets/x.png"),
102 * return the ordered list of repo paths to try in the pages blob store.
103 * The first existing blob wins.
104 *
105 * "" -> ["index.html"]
106 * "about" -> ["about.html", "about/index.html"]
107 * "about/" -> ["about/index.html"]
108 * "a/b.css" -> ["a/b.css"]
109 * sourceDir="docs" prefixes every entry with "docs/".
110 */
111export function resolvePagesPath(
112 urlRest: string,
113 sourceDir: string,
114 indexHtml = "index.html"
115): string[] {
116 const prefix = normaliseSourceDir(sourceDir);
117 const rest = normaliseUrlRest(urlRest);
118 const endsWithSlash = /\/$/.test(urlRest || "") || urlRest === "";
119
120 const join = (p: string) => (prefix ? `${prefix}/${p}` : p);
121
122 // Root / directory-style URL -> serve the index.
123 if (rest === "") {
124 return [join(indexHtml)];
125 }
126
127 // Trailing slash or explicit dir -> only try the index inside it.
128 if (endsWithSlash) {
129 return [join(`${rest}/${indexHtml}`)];
130 }
131
132 // Has a file extension -> serve exactly that path.
133 const base = rest.split("/").pop() || "";
134 if (base.includes(".")) {
135 return [join(rest)];
136 }
137
138 // Extensionless -> pretty URL. Try foo.html first, then foo/index.html.
139 return [join(`${rest}.html`), join(`${rest}/${indexHtml}`)];
140}
141
142/**
143 * Record a pages deployment. Never throws — post-receive calls this and must
144 * not have its primary push path broken by pages bookkeeping.
145 */
146export async function onPagesPush(opts: {
147 ownerLogin: string;
148 repoName: string;
149 repositoryId: string;
150 ref: string;
151 newSha: string;
152 triggeredByUserId: string | null;
153}): Promise<void> {
154 try {
155 await db.insert(pagesDeployments).values({
156 repositoryId: opts.repositoryId,
157 ref: opts.ref,
158 commitSha: opts.newSha,
159 status: "success",
160 triggeredBy: opts.triggeredByUserId,
161 });
162 console.log(
163 `[pages] deployed ${opts.ownerLogin}/${opts.repoName} ${opts.ref}@${opts.newSha.slice(0, 7)}`
164 );
165 } catch (err) {
166 console.error(
167 `[pages] failed to record deployment for ${opts.ownerLogin}/${opts.repoName}:`,
168 err
169 );
170 // Try to record a failure row so the settings UI can surface it.
171 try {
172 await db.insert(pagesDeployments).values({
173 repositoryId: opts.repositoryId,
174 ref: opts.ref,
175 commitSha: opts.newSha,
176 status: "failed",
177 triggeredBy: opts.triggeredByUserId,
178 });
179 } catch {
180 /* swallow */
181 }
182 }
183}
Addedsrc/lib/protected-tags.ts+155−0View fileUnifiedSplit
1/**
2 * Block E7 — Protected tags helpers.
3 *
4 * Owners can mark tag patterns (e.g. `v*`, `release-*`) as protected so that
5 * only owners can create, update, or delete matching tags. We enforce this
6 * inside the git push flow (post-receive + route-level) by calling
7 * `isProtectedTag`.
8 *
9 * Patterns support the same glob syntax as branch protection via `matchGlob`.
10 */
11
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { protectedTags, repositories, users } from "../db/schema";
15import type { ProtectedTag } from "../db/schema";
16import { matchGlob } from "./environments";
17
18/**
19 * Return the most specific matching protected-tag pattern for `tagName`.
20 * Exact string matches win over globs. Returns null when unprotected.
21 */
22export async function matchProtectedTag(
23 repositoryId: string,
24 tagName: string
25): Promise<ProtectedTag | null> {
26 const name = stripRefsTags(tagName);
27 let rows: ProtectedTag[];
28 try {
29 rows = await db
30 .select()
31 .from(protectedTags)
32 .where(eq(protectedTags.repositoryId, repositoryId));
33 } catch {
34 return null;
35 }
36 if (!rows || rows.length === 0) return null;
37
38 const exact = rows.find((r) => stripRefsTags(r.pattern) === name);
39 if (exact) return exact;
40
41 const globs = rows
42 .filter((r) => r.pattern.includes("*"))
43 .sort((a, b) => a.pattern.localeCompare(b.pattern));
44 for (const rule of globs) {
45 if (matchGlob(name, rule.pattern)) return rule;
46 }
47 return null;
48}
49
50export async function isProtectedTag(
51 repositoryId: string,
52 tagName: string
53): Promise<boolean> {
54 return (await matchProtectedTag(repositoryId, tagName)) !== null;
55}
56
57/**
58 * True when the given user is authorised to bypass a protected-tag rule for
59 * this repo. Currently that means "is the repo owner". A richer
60 * implementation would check org-level tag admins.
61 */
62export async function canBypassProtectedTag(
63 repositoryId: string,
64 userId: string | null | undefined
65): Promise<boolean> {
66 if (!userId) return false;
67 try {
68 const [row] = await db
69 .select({ ownerId: repositories.ownerId })
70 .from(repositories)
71 .where(eq(repositories.id, repositoryId))
72 .limit(1);
73 return !!row && row.ownerId === userId;
74 } catch {
75 return false;
76 }
77}
78
79export async function listProtectedTags(
80 repositoryId: string
81): Promise<ProtectedTag[]> {
82 try {
83 return await db
84 .select()
85 .from(protectedTags)
86 .where(eq(protectedTags.repositoryId, repositoryId));
87 } catch {
88 return [];
89 }
90}
91
92export async function addProtectedTag(args: {
93 repositoryId: string;
94 pattern: string;
95 createdBy?: string | null;
96}): Promise<ProtectedTag | null> {
97 try {
98 const [row] = await db
99 .insert(protectedTags)
100 .values({
101 repositoryId: args.repositoryId,
102 pattern: args.pattern,
103 createdBy: args.createdBy || null,
104 })
105 .returning();
106 return row || null;
107 } catch (err) {
108 console.error("[protected-tags] add:", err);
109 return null;
110 }
111}
112
113export async function removeProtectedTag(
114 repositoryId: string,
115 id: string
116): Promise<boolean> {
117 try {
118 const res = await db
119 .delete(protectedTags)
120 .where(
121 and(
122 eq(protectedTags.id, id),
123 eq(protectedTags.repositoryId, repositoryId)
124 )
125 )
126 .returning({ id: protectedTags.id });
127 return res.length > 0;
128 } catch {
129 return false;
130 }
131}
132
133function stripRefsTags(s: string): string {
134 return s.startsWith("refs/tags/") ? s.slice("refs/tags/".length) : s;
135}
136
137/**
138 * Resolve a username → user id. Used by enforcement points that only have
139 * the pusher's username (e.g. the git smart-HTTP route).
140 */
141export async function userIdFromUsername(
142 username: string | null | undefined
143): Promise<string | null> {
144 if (!username) return null;
145 try {
146 const [u] = await db
147 .select({ id: users.id })
148 .from(users)
149 .where(eq(users.username, username))
150 .limit(1);
151 return u?.id || null;
152 } catch {
153 return null;
154 }
155}
Addedsrc/lib/reactions.ts+135−0View fileUnifiedSplit
1/**
2 * Reactions helper — aggregate + toggle logic over the `reactions` table.
3 * Universal target pointer: (targetType, targetId).
4 */
5
6import { and, eq, sql } from "drizzle-orm";
7import { db } from "../db";
8import { reactions } from "../db/schema";
9
10export type TargetType = "issue" | "pr" | "issue_comment" | "pr_comment";
11
12export const ALLOWED_TARGETS: TargetType[] = [
13 "issue",
14 "pr",
15 "issue_comment",
16 "pr_comment",
17];
18
19export type Emoji =
20 | "thumbs_up"
21 | "thumbs_down"
22 | "rocket"
23 | "heart"
24 | "eyes"
25 | "laugh"
26 | "hooray"
27 | "confused";
28
29export const ALLOWED_EMOJIS: Emoji[] = [
30 "thumbs_up",
31 "thumbs_down",
32 "rocket",
33 "heart",
34 "eyes",
35 "laugh",
36 "hooray",
37 "confused",
38];
39
40export const EMOJI_GLYPH: Record<Emoji, string> = {
41 thumbs_up: "\uD83D\uDC4D",
42 thumbs_down: "\uD83D\uDC4E",
43 rocket: "\uD83D\uDE80",
44 heart: "\u2764\uFE0F",
45 eyes: "\uD83D\uDC40",
46 laugh: "\uD83D\uDE04",
47 hooray: "\uD83C\uDF89",
48 confused: "\uD83D\uDE15",
49};
50
51export function isAllowedEmoji(x: unknown): x is Emoji {
52 return typeof x === "string" && (ALLOWED_EMOJIS as string[]).includes(x);
53}
54
55export function isAllowedTarget(x: unknown): x is TargetType {
56 return typeof x === "string" && (ALLOWED_TARGETS as string[]).includes(x);
57}
58
59export type ReactionSummary = {
60 emoji: Emoji;
61 count: number;
62 reactedByMe: boolean;
63};
64
65/**
66 * Load reaction counts for a single target, + whether current user reacted.
67 */
68export async function summariseReactions(
69 targetType: TargetType,
70 targetId: string,
71 currentUserId: string | null | undefined
72): Promise<ReactionSummary[]> {
73 try {
74 const rows = await db
75 .select({
76 emoji: reactions.emoji,
77 count: sql<number>`count(*)::int`,
78 mine: sql<number>`sum(case when ${reactions.userId} = ${currentUserId || null} then 1 else 0 end)::int`,
79 })
80 .from(reactions)
81 .where(
82 and(
83 eq(reactions.targetType, targetType),
84 eq(reactions.targetId, targetId)
85 )
86 )
87 .groupBy(reactions.emoji);
88
89 return rows
90 .filter((r) => isAllowedEmoji(r.emoji))
91 .map((r) => ({
92 emoji: r.emoji as Emoji,
93 count: Number(r.count) || 0,
94 reactedByMe: Number(r.mine) > 0,
95 }));
96 } catch {
97 return [];
98 }
99}
100
101/**
102 * Toggle the user's reaction. Returns the new `reactedByMe` state.
103 */
104export async function toggleReaction(
105 userId: string,
106 targetType: TargetType,
107 targetId: string,
108 emoji: Emoji
109): Promise<{ added: boolean }> {
110 const existing = await db
111 .select({ id: reactions.id })
112 .from(reactions)
113 .where(
114 and(
115 eq(reactions.userId, userId),
116 eq(reactions.targetType, targetType),
117 eq(reactions.targetId, targetId),
118 eq(reactions.emoji, emoji)
119 )
120 )
121 .limit(1);
122
123 if (existing.length > 0) {
124 await db.delete(reactions).where(eq(reactions.id, existing[0].id));
125 return { added: false };
126 }
127
128 await db.insert(reactions).values({
129 userId,
130 targetType,
131 targetId,
132 emoji,
133 });
134 return { added: true };
135}
Addedsrc/lib/repo-bootstrap.ts+210−0View fileUnifiedSplit
1/**
2 * Repo bootstrap — wires up the "full green ecosystem by default" stance.
3 *
4 * Called immediately after a new repository row is created (including on fork).
5 * Every setting defaults to the most protective configuration — all gates on,
6 * auto-repair on, auto-deploy gated on all-green. Owners can turn things off
7 * in settings but they never have to turn things on.
8 *
9 * This is the heart of the "nothing broken reaches the customer" posture.
10 */
11
12import { db } from "../db";
13import {
14 repoSettings,
15 branchProtection,
16 labels,
17 issues,
18 issueComments,
19} from "../db/schema";
20import { audit } from "./notify";
21
22const DEFAULT_LABELS = [
23 { name: "bug", color: "#f85149", description: "Something is broken" },
24 { name: "feature", color: "#1f6feb", description: "New capability" },
25 { name: "enhancement", color: "#58a6ff", description: "Improvement to existing behaviour" },
26 { name: "security", color: "#d29922", description: "Security-related" },
27 { name: "performance", color: "#a371f7", description: "Performance-related" },
28 { name: "docs", color: "#3fb950", description: "Documentation" },
29 { name: "question", color: "#8b949e", description: "Further info requested" },
30 { name: "good first issue", color: "#7ee787", description: "Suitable for new contributors" },
31 { name: "ai-triaged", color: "#bc8cff", description: "Auto-triaged by GlueCron AI" },
32];
33
34const WELCOME_BODY = `Welcome to your new GlueCron repository.
35
36Every repository ships with the **full green ecosystem** enabled by default — nothing broken ever reaches your customers.
37
38## What's enabled out of the box
39
40- **AI code review** on every pull request
41- **Green gate enforcement** — GateTest + AI review + merge check must all pass before merge
42- **Secret & security scanning** on every push
43- **Automated merge conflict resolution** when conflicts arise
44- **AI auto-repair** — failing gates trigger a fix attempt before a human is pinged
45- **Branch protection** on \`main\` — PR required, all gates green, AI approval required
46- **Auto-deploy** to Crontech on every passing push to \`main\`
47- **AI commit messages, PR summaries, and release changelogs** on demand
48
49You can toggle any of this in **Settings → Gates & Auto-repair**. The safe defaults are on.
50
51## Quick start
52
53Push your first commit:
54
55\`\`\`
56git remote add gluecron https://gluecron.com/YOUR_USERNAME/YOUR_REPO.git
57git push -u gluecron main
58\`\`\`
59
60Ask the assistant anything:
61
62\`\`\`
63Click "Ask AI" in the repo nav or press Cmd+K and type your question.
64\`\`\`
65
66Happy shipping.`;
67
68export interface BootstrapResult {
69 settingsCreated: boolean;
70 protectionCreated: boolean;
71 labelsCreated: number;
72 welcomeIssueNumber?: number;
73}
74
75export async function bootstrapRepository(opts: {
76 repositoryId: string;
77 ownerUserId: string;
78 defaultBranch?: string;
79 skipWelcomeIssue?: boolean;
80}): Promise<BootstrapResult> {
81 const branch = opts.defaultBranch || "main";
82 let settingsCreated = false;
83 let protectionCreated = false;
84 let labelsCreated = 0;
85 let welcomeIssueNumber: number | undefined;
86
87 // 1. Settings — all gates on, all AI features on
88 try {
89 await db.insert(repoSettings).values({
90 repositoryId: opts.repositoryId,
91 });
92 settingsCreated = true;
93 } catch (err) {
94 // Ignore unique-violation if settings already exist (fork case)
95 console.warn("[bootstrap] settings:", (err as Error).message);
96 }
97
98 // 2. Branch protection on the default branch — maximum safety
99 try {
100 await db.insert(branchProtection).values({
101 repositoryId: opts.repositoryId,
102 pattern: branch,
103 requirePullRequest: true,
104 requireGreenGates: true,
105 requireAiApproval: true,
106 requireHumanReview: false,
107 requiredApprovals: 0,
108 allowForcePush: false,
109 allowDeletion: false,
110 dismissStaleReviews: true,
111 });
112 protectionCreated = true;
113 } catch (err) {
114 console.warn("[bootstrap] protection:", (err as Error).message);
115 }
116
117 // 3. Default labels
118 try {
119 const rows = DEFAULT_LABELS.map((l) => ({
120 repositoryId: opts.repositoryId,
121 name: l.name,
122 color: l.color,
123 description: l.description,
124 }));
125 await db.insert(labels).values(rows).onConflictDoNothing?.();
126 labelsCreated = rows.length;
127 } catch (err) {
128 // onConflictDoNothing might not be available on all drizzle adapters; best-effort insert
129 for (const l of DEFAULT_LABELS) {
130 try {
131 await db.insert(labels).values({
132 repositoryId: opts.repositoryId,
133 name: l.name,
134 color: l.color,
135 description: l.description,
136 });
137 labelsCreated++;
138 } catch {
139 // already exists — ignore
140 }
141 }
142 }
143
144 // 4. Welcome issue (skippable for forks)
145 if (!opts.skipWelcomeIssue) {
146 try {
147 const [issue] = await db
148 .insert(issues)
149 .values({
150 repositoryId: opts.repositoryId,
151 authorId: opts.ownerUserId,
152 title: "Welcome to GlueCron",
153 body: WELCOME_BODY,
154 state: "open",
155 })
156 .returning();
157 welcomeIssueNumber = issue?.number;
158 } catch (err) {
159 console.warn("[bootstrap] welcome issue:", (err as Error).message);
160 }
161 }
162
163 await audit({
164 userId: opts.ownerUserId,
165 repositoryId: opts.repositoryId,
166 action: "repo.bootstrap",
167 metadata: {
168 settingsCreated,
169 protectionCreated,
170 labelsCreated,
171 welcomeIssueNumber,
172 },
173 });
174
175 return {
176 settingsCreated,
177 protectionCreated,
178 labelsCreated,
179 welcomeIssueNumber,
180 };
181}
182
183/**
184 * Convenience helper to load settings (creates defaults if missing).
185 */
186export async function getOrCreateSettings(repositoryId: string) {
187 const { eq } = await import("drizzle-orm");
188 const [existing] = await db
189 .select()
190 .from(repoSettings)
191 .where(eq(repoSettings.repositoryId, repositoryId))
192 .limit(1);
193 if (existing) return existing;
194
195 try {
196 const [row] = await db
197 .insert(repoSettings)
198 .values({ repositoryId })
199 .returning();
200 return row;
201 } catch {
202 // Race — someone else inserted, re-select
203 const [row] = await db
204 .select()
205 .from(repoSettings)
206 .where(eq(repoSettings.repositoryId, repositoryId))
207 .limit(1);
208 return row;
209 }
210}
Addedsrc/lib/rulesets.ts+402−0View fileUnifiedSplit
1/**
2 * Block J6 — Repository rulesets.
3 *
4 * A ruleset groups N rules under a named policy at enforcement level active /
5 * evaluate / disabled. The evaluator is pure — callers (push hook, PR
6 * merger, web editor) pass a PushContext describing what they're about to
7 * do, and get back either an allow or the list of violations.
8 *
9 * Supported rule types in V1:
10 * - commit_message_pattern : { pattern: string, flags?: "i", require?: bool }
11 * - branch_name_pattern : { pattern: string, require?: bool }
12 * - tag_name_pattern : { pattern: string, require?: bool }
13 * - blocked_file_paths : { paths: string[] } (glob-lite: *, /)
14 * - max_file_size : { bytes: number }
15 * - forbid_force_push : {}
16 */
17
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import {
21 repoRulesets,
22 rulesetRules,
23 type RepoRuleset,
24 type RulesetRule,
25} from "../db/schema";
26
27export type RuleType =
28 | "commit_message_pattern"
29 | "branch_name_pattern"
30 | "tag_name_pattern"
31 | "blocked_file_paths"
32 | "max_file_size"
33 | "forbid_force_push";
34
35export const RULE_TYPES: RuleType[] = [
36 "commit_message_pattern",
37 "branch_name_pattern",
38 "tag_name_pattern",
39 "blocked_file_paths",
40 "max_file_size",
41 "forbid_force_push",
42];
43
44export interface CommitLike {
45 sha?: string;
46 message: string;
47 changedPaths?: string[];
48 maxBlobSize?: number;
49}
50
51export interface PushContext {
52 kind: "push";
53 refType: "branch" | "tag";
54 refName: string;
55 commits: CommitLike[];
56 forcePush?: boolean;
57}
58
59export interface Violation {
60 rulesetId: string;
61 rulesetName: string;
62 enforcement: "active" | "evaluate" | "disabled";
63 ruleType: RuleType;
64 message: string;
65}
66
67export interface EvalResult {
68 allowed: boolean;
69 violations: Violation[];
70}
71
72// ----------------------------------------------------------------------------
73// Pure rule helpers
74// ----------------------------------------------------------------------------
75
76/** glob-lite → RegExp. Supports `*` (non-slash) and `**` (anything). */
77export function globToRegex(glob: string): RegExp {
78 const parts: string[] = [];
79 let i = 0;
80 while (i < glob.length) {
81 const ch = glob[i];
82 if (ch === "*") {
83 if (glob[i + 1] === "*") {
84 parts.push(".*");
85 i += 2;
86 } else {
87 parts.push("[^/]*");
88 i += 1;
89 }
90 } else if (/[.+?^${}()|[\]\\]/.test(ch)) {
91 parts.push("\\" + ch);
92 i += 1;
93 } else {
94 parts.push(ch);
95 i += 1;
96 }
97 }
98 return new RegExp("^" + parts.join("") + "$");
99}
100
101export function parseParams(raw: string): Record<string, unknown> {
102 try {
103 return JSON.parse(raw || "{}");
104 } catch {
105 return {};
106 }
107}
108
109function evalRule(
110 rule: RulesetRule,
111 ctx: PushContext
112): string[] {
113 const params = parseParams(rule.params);
114 const out: string[] = [];
115 switch (rule.ruleType as RuleType) {
116 case "commit_message_pattern": {
117 const pattern = String(params.pattern || "");
118 const flags = String(params.flags || "") || undefined;
119 const require = params.require !== false;
120 if (!pattern) return out;
121 let re: RegExp;
122 try {
123 re = new RegExp(pattern, flags);
124 } catch {
125 return out;
126 }
127 for (const c of ctx.commits) {
128 const matches = re.test(c.message);
129 if (require && !matches) {
130 out.push(
131 `commit ${c.sha?.slice(0, 7) || "?"} message does not match /${pattern}/`
132 );
133 } else if (!require && matches) {
134 out.push(
135 `commit ${c.sha?.slice(0, 7) || "?"} message matches forbidden /${pattern}/`
136 );
137 }
138 }
139 return out;
140 }
141 case "branch_name_pattern": {
142 if (ctx.refType !== "branch") return out;
143 const pattern = String(params.pattern || "");
144 const require = params.require !== false;
145 if (!pattern) return out;
146 let re: RegExp;
147 try {
148 re = new RegExp(pattern);
149 } catch {
150 return out;
151 }
152 const ok = re.test(ctx.refName);
153 if (require && !ok) {
154 out.push(`branch "${ctx.refName}" does not match /${pattern}/`);
155 } else if (!require && ok) {
156 out.push(`branch "${ctx.refName}" matches forbidden /${pattern}/`);
157 }
158 return out;
159 }
160 case "tag_name_pattern": {
161 if (ctx.refType !== "tag") return out;
162 const pattern = String(params.pattern || "");
163 const require = params.require !== false;
164 if (!pattern) return out;
165 let re: RegExp;
166 try {
167 re = new RegExp(pattern);
168 } catch {
169 return out;
170 }
171 const ok = re.test(ctx.refName);
172 if (require && !ok) {
173 out.push(`tag "${ctx.refName}" does not match /${pattern}/`);
174 } else if (!require && ok) {
175 out.push(`tag "${ctx.refName}" matches forbidden /${pattern}/`);
176 }
177 return out;
178 }
179 case "blocked_file_paths": {
180 const globs = Array.isArray(params.paths)
181 ? (params.paths as string[])
182 : [];
183 if (!globs.length) return out;
184 const res = globs.map((g) => globToRegex(g));
185 for (const c of ctx.commits) {
186 for (const p of c.changedPaths || []) {
187 for (let i = 0; i < res.length; i++) {
188 if (res[i].test(p)) {
189 out.push(
190 `commit ${c.sha?.slice(0, 7) || "?"} modifies blocked path "${p}" (${globs[i]})`
191 );
192 }
193 }
194 }
195 }
196 return out;
197 }
198 case "max_file_size": {
199 const bytes = Number(params.bytes || 0);
200 if (!bytes) return out;
201 for (const c of ctx.commits) {
202 if (typeof c.maxBlobSize === "number" && c.maxBlobSize > bytes) {
203 out.push(
204 `commit ${c.sha?.slice(0, 7) || "?"} has a blob ${c.maxBlobSize}B > ${bytes}B`
205 );
206 }
207 }
208 return out;
209 }
210 case "forbid_force_push": {
211 if (ctx.forcePush) {
212 out.push("force push is forbidden by ruleset");
213 }
214 return out;
215 }
216 default:
217 return out;
218 }
219}
220
221/** Pure evaluator — takes rulesets + rules + context, returns verdict. */
222export function evaluatePush(
223 rulesets: Array<RepoRuleset & { rules: RulesetRule[] }>,
224 ctx: PushContext
225): EvalResult {
226 const violations: Violation[] = [];
227 let blocked = false;
228 for (const rs of rulesets) {
229 if (rs.enforcement === "disabled") continue;
230 for (const r of rs.rules) {
231 const msgs = evalRule(r, ctx);
232 for (const m of msgs) {
233 violations.push({
234 rulesetId: rs.id,
235 rulesetName: rs.name,
236 enforcement: rs.enforcement as "active" | "evaluate",
237 ruleType: r.ruleType as RuleType,
238 message: m,
239 });
240 if (rs.enforcement === "active") blocked = true;
241 }
242 }
243 }
244 return { allowed: !blocked, violations };
245}
246
247// ----------------------------------------------------------------------------
248// DB access
249// ----------------------------------------------------------------------------
250
251export async function listRulesetsForRepo(
252 repositoryId: string
253): Promise<Array<RepoRuleset & { rules: RulesetRule[] }>> {
254 const sets = await db
255 .select()
256 .from(repoRulesets)
257 .where(eq(repoRulesets.repositoryId, repositoryId));
258 if (sets.length === 0) return [];
259 const allRules = await db
260 .select()
261 .from(rulesetRules);
262 const byId = new Map<string, RulesetRule[]>();
263 for (const r of allRules) {
264 if (!byId.has(r.rulesetId)) byId.set(r.rulesetId, []);
265 byId.get(r.rulesetId)!.push(r);
266 }
267 return sets.map((s) => ({ ...s, rules: byId.get(s.id) || [] }));
268}
269
270export async function getRuleset(
271 rulesetId: string,
272 repositoryId: string
273): Promise<(RepoRuleset & { rules: RulesetRule[] }) | null> {
274 const [row] = await db
275 .select()
276 .from(repoRulesets)
277 .where(
278 and(
279 eq(repoRulesets.id, rulesetId),
280 eq(repoRulesets.repositoryId, repositoryId)
281 )
282 )
283 .limit(1);
284 if (!row) return null;
285 const rules = await db
286 .select()
287 .from(rulesetRules)
288 .where(eq(rulesetRules.rulesetId, rulesetId));
289 return { ...row, rules };
290}
291
292export async function createRuleset(params: {
293 repositoryId: string;
294 name: string;
295 enforcement: "active" | "evaluate" | "disabled";
296 createdBy: string;
297}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
298 const name = params.name.trim();
299 if (!name) return { ok: false, error: "Name is required" };
300 if (!["active", "evaluate", "disabled"].includes(params.enforcement)) {
301 return { ok: false, error: "Invalid enforcement" };
302 }
303 try {
304 const [row] = await db
305 .insert(repoRulesets)
306 .values({
307 repositoryId: params.repositoryId,
308 name,
309 enforcement: params.enforcement,
310 createdBy: params.createdBy,
311 })
312 .returning();
313 return { ok: true, id: row.id };
314 } catch (err: any) {
315 const msg = String(err?.message || "");
316 if (msg.includes("duplicate") || msg.includes("unique")) {
317 return { ok: false, error: "A ruleset with that name already exists" };
318 }
319 return { ok: false, error: "Could not save ruleset" };
320 }
321}
322
323export async function updateRulesetEnforcement(
324 rulesetId: string,
325 repositoryId: string,
326 enforcement: "active" | "evaluate" | "disabled"
327): Promise<boolean> {
328 if (!["active", "evaluate", "disabled"].includes(enforcement)) return false;
329 const rows = await db
330 .update(repoRulesets)
331 .set({ enforcement, updatedAt: new Date() })
332 .where(
333 and(
334 eq(repoRulesets.id, rulesetId),
335 eq(repoRulesets.repositoryId, repositoryId)
336 )
337 )
338 .returning();
339 return rows.length > 0;
340}
341
342export async function deleteRuleset(
343 rulesetId: string,
344 repositoryId: string
345): Promise<boolean> {
346 const rows = await db
347 .delete(repoRulesets)
348 .where(
349 and(
350 eq(repoRulesets.id, rulesetId),
351 eq(repoRulesets.repositoryId, repositoryId)
352 )
353 )
354 .returning();
355 return rows.length > 0;
356}
357
358export async function addRule(params: {
359 rulesetId: string;
360 repositoryId: string;
361 ruleType: RuleType;
362 params: Record<string, unknown>;
363}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
364 if (!RULE_TYPES.includes(params.ruleType)) {
365 return { ok: false, error: "Unknown rule type" };
366 }
367 // Ensure the ruleset belongs to this repo.
368 const parent = await getRuleset(params.rulesetId, params.repositoryId);
369 if (!parent) return { ok: false, error: "Ruleset not found" };
370 try {
371 const [row] = await db
372 .insert(rulesetRules)
373 .values({
374 rulesetId: params.rulesetId,
375 ruleType: params.ruleType,
376 params: JSON.stringify(params.params || {}),
377 })
378 .returning();
379 return { ok: true, id: row.id };
380 } catch {
381 return { ok: false, error: "Could not save rule" };
382 }
383}
384
385export async function deleteRule(
386 ruleId: string,
387 rulesetId: string,
388 repositoryId: string
389): Promise<boolean> {
390 const parent = await getRuleset(rulesetId, repositoryId);
391 if (!parent) return false;
392 const rows = await db
393 .delete(rulesetRules)
394 .where(
395 and(eq(rulesetRules.id, ruleId), eq(rulesetRules.rulesetId, rulesetId))
396 )
397 .returning();
398 return rows.length > 0;
399}
400
401// Test-only surface.
402export const __internal = { evalRule, globToRegex, parseParams };
Addedsrc/lib/security-scan.ts+213−0View fileUnifiedSplit
1/**
2 * Security + secret scanner.
3 * Runs on every push (via post-receive) AND every PR.
4 * Combines fast regex detection for secrets with an AI-powered semantic review
5 * for risky patterns (SSRF, SQL injection, XSS, unsafe deserialisation, etc).
6 */
7
8import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
9
10export interface SecretFinding {
11 type: string;
12 file: string;
13 line: number;
14 snippet: string;
15 severity: "critical" | "high" | "medium" | "low";
16}
17
18export interface SecurityFinding {
19 type: string;
20 file: string;
21 line?: number;
22 description: string;
23 severity: "critical" | "high" | "medium" | "low";
24 suggestion?: string;
25}
26
27export interface ScanResult {
28 secrets: SecretFinding[];
29 securityIssues: SecurityFinding[];
30 summary: string;
31 passed: boolean;
32}
33
34interface SecretPattern {
35 type: string;
36 regex: RegExp;
37 severity: SecretFinding["severity"];
38}
39
40// High-signal secret detectors. Ordered most-specific first.
41export const SECRET_PATTERNS: SecretPattern[] = [
42 { type: "AWS Access Key", regex: /\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\b/, severity: "critical" },
43 { type: "AWS Secret Key", regex: /aws(.{0,20})?(secret|access)?(.{0,20})?['\"]([A-Za-z0-9/+=]{40})['\"]/i, severity: "critical" },
44 { type: "GitHub Token", regex: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\b/, severity: "critical" },
45 { type: "Anthropic API Key", regex: /\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\b/, severity: "critical" },
46 { type: "OpenAI API Key", regex: /\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\b/, severity: "critical" },
47 { type: "Stripe Key", regex: /\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\b/, severity: "critical" },
48 { type: "Slack Token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: "high" },
49 { type: "Google API Key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
50 { type: "SendGrid Key", regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: "high" },
51 { type: "Twilio Key", regex: /\bSK[0-9a-fA-F]{32}\b/, severity: "high" },
52 { type: "Generic API Token", regex: /(?:api[_-]?key|apikey|access[_-]?token|secret)["'\s:=]+["']?([A-Za-z0-9_\-]{24,})["']?/i, severity: "medium" },
53 { type: "Private Key (PEM)", regex: /-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/, severity: "critical" },
54 { type: "JWT", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/, severity: "medium" },
55 { type: "Postgres URL", regex: /\bpostgres(?:ql)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
56 { type: "Mongo URL", regex: /\bmongodb(?:\+srv)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
57];
58
59// Paths we skip entirely (noise, binaries, generated files)
60const SKIP_PATHS = [
61 /(^|\/)\.git\//,
62 /(^|\/)node_modules\//,
63 /(^|\/)vendor\//,
64 /(^|\/)dist\//,
65 /(^|\/)build\//,
66 /(^|\/)\.next\//,
67 /(^|\/)\.cache\//,
68 /\.(png|jpg|jpeg|gif|webp|ico|svg|pdf|mp4|mov|wasm|woff2?|ttf|eot|map)$/i,
69 /(^|\/)bun\.lock(b)?$/,
70 /(^|\/)package-lock\.json$/,
71 /(^|\/)yarn\.lock$/,
72 /(^|\/)pnpm-lock\.yaml$/,
73];
74
75export function shouldSkipPath(path: string): boolean {
76 return SKIP_PATHS.some((re) => re.test(path));
77}
78
79/**
80 * Fast local regex-based secret scanner. No network, no Claude — safe to run
81 * on every push regardless of whether the AI key is configured.
82 */
83export function scanForSecrets(
84 files: Array<{ path: string; content: string }>
85): SecretFinding[] {
86 const findings: SecretFinding[] = [];
87 for (const file of files) {
88 if (shouldSkipPath(file.path)) continue;
89 const lines = file.content.split("\n");
90 for (let i = 0; i < lines.length; i++) {
91 const line = lines[i];
92 // Skip lines that look like placeholders / tests
93 if (
94 /example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme/i.test(
95 line
96 )
97 ) {
98 continue;
99 }
100 for (const pattern of SECRET_PATTERNS) {
101 if (pattern.regex.test(line)) {
102 findings.push({
103 type: pattern.type,
104 file: file.path,
105 line: i + 1,
106 snippet: line.trim().slice(0, 200),
107 severity: pattern.severity,
108 });
109 break; // one finding per line
110 }
111 }
112 }
113 }
114 return findings;
115}
116
117/**
118 * Ask Claude to review a diff or snapshot for security issues.
119 * Returns structured findings; safe to call without AI key (returns empty).
120 */
121export async function aiSecurityScan(
122 repoFullName: string,
123 diffOrSnapshot: string
124): Promise<SecurityFinding[]> {
125 if (!isAiAvailable()) return [];
126 const client = getAnthropic();
127
128 try {
129 const message = await client.messages.create({
130 model: MODEL_SONNET,
131 max_tokens: 2048,
132 messages: [
133 {
134 role: "user",
135 content: `You are a security auditor reviewing code on the repository "${repoFullName}".
136
137Analyse the following code for high-signal security issues:
138- Injection (SQL, command, LDAP, XPath)
139- Cross-site scripting (XSS)
140- Insecure deserialisation
141- SSRF / unvalidated redirects
142- Path traversal
143- Broken authentication / authorisation (e.g. missing access checks)
144- Insecure cryptography (weak hashing for passwords, hard-coded IVs)
145- Race conditions with security impact
146- Insufficient input validation at a trust boundary
147
148Do NOT report low-risk style issues, noisy defensive-coding suggestions, or theoretical risks without a plausible trigger.
149
150Respond ONLY with JSON of shape:
151{
152 "findings": [
153 { "type": "SQL Injection", "file": "src/x.ts", "line": 42, "severity": "high", "description": "...", "suggestion": "..." }
154 ]
155}
156
157If the code is clean, return { "findings": [] }.
158
159\`\`\`
160${diffOrSnapshot.slice(0, 80000)}
161\`\`\``,
162 },
163 ],
164 });
165
166 const text = extractText(message);
167 const parsed = parseJsonResponse<{ findings: SecurityFinding[] }>(text);
168 if (!parsed || !Array.isArray(parsed.findings)) return [];
169 // Normalise severity
170 return parsed.findings.map((f) => ({
171 ...f,
172 severity: (["critical", "high", "medium", "low"].includes(f.severity as string)
173 ? f.severity
174 : "medium") as SecurityFinding["severity"],
175 }));
176 } catch (err) {
177 console.error("[security-scan] AI scan failed:", err);
178 return [];
179 }
180}
181
182/**
183 * Run full security scan: regex secrets + (optional) AI security review.
184 */
185export async function runSecurityScan(
186 repoFullName: string,
187 files: Array<{ path: string; content: string }>,
188 diffText?: string
189): Promise<ScanResult> {
190 const secrets = scanForSecrets(files);
191 const securityIssues = diffText
192 ? await aiSecurityScan(repoFullName, diffText)
193 : [];
194
195 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
196 const criticalIssues = securityIssues.filter((i) => i.severity === "critical").length;
197 const highIssues = securityIssues.filter((i) => i.severity === "high").length;
198
199 const passed = criticalSecrets === 0 && criticalIssues === 0 && highIssues === 0;
200
201 const parts: string[] = [];
202 if (secrets.length) parts.push(`${secrets.length} secret${secrets.length === 1 ? "" : "s"}`);
203 if (securityIssues.length)
204 parts.push(
205 `${securityIssues.length} security issue${securityIssues.length === 1 ? "" : "s"}`
206 );
207 const summary =
208 parts.length === 0
209 ? "No issues detected"
210 : `Found ${parts.join(" + ")} (${criticalSecrets + criticalIssues} critical, ${highIssues} high)`;
211
212 return { secrets, securityIssues, summary, passed };
213}
Addedsrc/lib/semantic-search.ts+578−0View fileUnifiedSplit
1/**
2 * Block D1 — Semantic code search.
3 *
4 * Two embedding backends:
5 * 1. Voyage AI (`voyage-code-3`, 1024-dim) if VOYAGE_API_KEY is set.
6 * 2. Lexical fallback: 512-dim hashing bag-of-words, L2-normalised.
7 * Deterministic, no network, good-enough baseline for tests + graceful
8 * degradation when no API key is configured.
9 *
10 * Embeddings are stored in `code_chunks.embedding` as a JSON-encoded number
11 * array (schema = text) so we don't depend on pgvector. Cosine similarity
12 * is computed in JS. If/when scale demands it, swap the column type to
13 * `vector(1024)` and push cosine into Postgres.
14 */
15
16import { eq } from "drizzle-orm";
17import { db } from "../db";
18import { codeChunks } from "../db/schema";
19import {
20 getTree,
21 getBlob,
22 type GitTreeEntry,
23} from "../git/repository";
24
25// ---------------------------------------------------------------------------
26// Pure helpers
27// ---------------------------------------------------------------------------
28
29/**
30 * Split code into identifier fragments. Splits on non-word boundaries, then
31 * further splits `camelCase` and `snake_case` / kebab-case tokens into their
32 * constituent pieces. All lowercase. Drops single-character tokens and pure
33 * numeric tokens to keep the feature space meaningful.
34 */
35export function tokenize(code: string): string[] {
36 if (!code) return [];
37 const out: string[] = [];
38 // First pass: split on non-alphanumeric (keep underscores for snake_case detection)
39 const rough = code.split(/[^A-Za-z0-9_]+/).filter(Boolean);
40 for (const tok of rough) {
41 // Split snake_case / kebab (kebab already gone; underscores split here)
42 const underscoreParts = tok.split(/_+/).filter(Boolean);
43 for (const part of underscoreParts) {
44 // Split camelCase / PascalCase: insert boundary before each uppercase
45 // letter that follows a lowercase or digit, and before the last upper
46 // in a run of uppers followed by a lower (XMLParser -> XML, Parser).
47 const camelParts = part
48 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
49 .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
50 .split(/\s+/)
51 .filter(Boolean);
52 for (const cp of camelParts) {
53 const lower = cp.toLowerCase();
54 if (lower.length < 2) continue;
55 if (/^\d+$/.test(lower)) continue;
56 out.push(lower);
57 }
58 }
59 }
60 return out;
61}
62
63/**
64 * FNV-1a 32-bit hash of a string. Deterministic + fast enough for the tiny
65 * token volumes we throw at it.
66 */
67function fnv1a(s: string): number {
68 let h = 0x811c9dc5;
69 for (let i = 0; i < s.length; i++) {
70 h ^= s.charCodeAt(i);
71 // 32-bit FNV prime multiplication via Math.imul
72 h = Math.imul(h, 0x01000193);
73 }
74 return h >>> 0;
75}
76
77/**
78 * Feature-hashing embedding with the sign trick. Maps each token into one of
79 * `dim` slots; a second hash decides whether to add +1 or -1. Finally L2-
80 * normalises so cosine ≈ dot product.
81 */
82export function hashEmbed(tokens: string[], dim = 512): number[] {
83 const v = new Array<number>(dim).fill(0);
84 if (!tokens.length) return v;
85 for (const tok of tokens) {
86 const h = fnv1a(tok);
87 const slot = h % dim;
88 // Sign from a perturbed second hash so it's not correlated with slot
89 const signHash = fnv1a("\x00" + tok);
90 const sign = signHash & 1 ? 1 : -1;
91 v[slot] += sign;
92 }
93 let sumsq = 0;
94 for (let i = 0; i < dim; i++) sumsq += v[i] * v[i];
95 if (sumsq === 0) return v;
96 const inv = 1 / Math.sqrt(sumsq);
97 for (let i = 0; i < dim; i++) v[i] *= inv;
98 return v;
99}
100
101/** Cosine similarity. Assumes a, b are same length. Handles zero vectors. */
102export function cosine(a: number[], b: number[]): number {
103 if (!a || !b) return 0;
104 const n = Math.min(a.length, b.length);
105 let dot = 0;
106 let na = 0;
107 let nb = 0;
108 for (let i = 0; i < n; i++) {
109 dot += a[i] * b[i];
110 na += a[i] * a[i];
111 nb += b[i] * b[i];
112 }
113 if (na === 0 || nb === 0) return 0;
114 return dot / (Math.sqrt(na) * Math.sqrt(nb));
115}
116
117const CODE_EXTS = new Set([
118 "ts",
119 "tsx",
120 "js",
121 "jsx",
122 "mjs",
123 "cjs",
124 "py",
125 "go",
126 "rs",
127 "java",
128 "rb",
129 "php",
130 "c",
131 "cpp",
132 "cc",
133 "h",
134 "hpp",
135 "md",
136 "mdx",
137 "yaml",
138 "yml",
139 "json",
140 "css",
141 "html",
142 "htm",
143]);
144
145const SKIP_FILES = new Set([
146 "package-lock.json",
147 "yarn.lock",
148 "pnpm-lock.yaml",
149 "bun.lockb",
150 "bun.lock",
151 "poetry.lock",
152 "cargo.lock",
153 "composer.lock",
154 "gemfile.lock",
155]);
156
157/**
158 * Is the given path a code-like file we should index? Rejects lock files,
159 * known binary extensions, images, and anything without an extension we
160 * recognise.
161 */
162export function isCodeFile(path: string): boolean {
163 if (!path) return false;
164 const base = path.split("/").pop() || "";
165 const lower = base.toLowerCase();
166 if (SKIP_FILES.has(lower)) return false;
167 const dot = lower.lastIndexOf(".");
168 if (dot === -1) return false;
169 const ext = lower.slice(dot + 1);
170 if (!CODE_EXTS.has(ext)) return false;
171 return true;
172}
173
174/**
175 * Split a file's content into overlapping chunks of ~maxLines lines with
176 * a 5-line overlap. Skips non-code files. Returns [] for empty / binary-
177 * looking content.
178 */
179export function chunkFile(
180 path: string,
181 content: string,
182 maxLines = 40
183): Array<{ path: string; startLine: number; endLine: number; content: string }> {
184 if (!isCodeFile(path)) return [];
185 if (!content) return [];
186 if (content.includes("\0")) return []; // binary blob, skip
187 const lines = content.split("\n");
188 if (lines.length === 0) return [];
189
190 const overlap = 5;
191 const step = Math.max(1, maxLines - overlap);
192 const out: Array<{
193 path: string;
194 startLine: number;
195 endLine: number;
196 content: string;
197 }> = [];
198
199 // For short files, emit a single chunk.
200 if (lines.length <= maxLines) {
201 out.push({
202 path,
203 startLine: 1,
204 endLine: lines.length,
205 content: lines.join("\n"),
206 });
207 return out;
208 }
209
210 for (let start = 0; start < lines.length; start += step) {
211 const end = Math.min(lines.length, start + maxLines);
212 out.push({
213 path,
214 startLine: start + 1,
215 endLine: end,
216 content: lines.slice(start, end).join("\n"),
217 });
218 if (end >= lines.length) break;
219 }
220 return out;
221}
222
223// ---------------------------------------------------------------------------
224// Provider detection
225// ---------------------------------------------------------------------------
226
227export function isEmbeddingsProviderAvailable(): {
228 voyage: boolean;
229 fallback: true;
230} {
231 return {
232 voyage: !!process.env.VOYAGE_API_KEY,
233 fallback: true,
234 };
235}
236
237const VOYAGE_MODEL = "voyage-code-3";
238const FALLBACK_MODEL = "gluecron-hash-512";
239const VOYAGE_BATCH = 128;
240
241/**
242 * Embed a batch of texts. Uses Voyage AI when VOYAGE_API_KEY is set, and
243 * falls back to `hashEmbed(tokenize(...))` per text otherwise — or if the
244 * Voyage request fails for any reason.
245 *
246 * Never throws. Always returns the same number of vectors as inputs.
247 */
248export async function embedBatch(
249 texts: string[],
250 inputType: "document" | "query"
251): Promise<{ vectors: number[][]; model: string }> {
252 if (!texts.length) return { vectors: [], model: FALLBACK_MODEL };
253
254 const apiKey = process.env.VOYAGE_API_KEY;
255 if (apiKey) {
256 const all: number[][] = [];
257 let ok = true;
258 for (let i = 0; i < texts.length && ok; i += VOYAGE_BATCH) {
259 const slice = texts.slice(i, i + VOYAGE_BATCH);
260 try {
261 const resp = await fetch("https://api.voyageai.com/v1/embeddings", {
262 method: "POST",
263 headers: {
264 "content-type": "application/json",
265 authorization: `Bearer ${apiKey}`,
266 },
267 body: JSON.stringify({
268 input: slice,
269 model: VOYAGE_MODEL,
270 input_type: inputType,
271 }),
272 });
273 if (!resp.ok) {
274 ok = false;
275 break;
276 }
277 const json: any = await resp.json();
278 const data = Array.isArray(json?.data) ? json.data : null;
279 if (!data || data.length !== slice.length) {
280 ok = false;
281 break;
282 }
283 for (const row of data) {
284 const emb = row?.embedding;
285 if (!Array.isArray(emb)) {
286 ok = false;
287 break;
288 }
289 all.push(emb as number[]);
290 }
291 } catch {
292 ok = false;
293 break;
294 }
295 }
296 if (ok && all.length === texts.length) {
297 return { vectors: all, model: VOYAGE_MODEL };
298 }
299 // fall through to fallback for the entire batch
300 }
301
302 const vectors = texts.map((t) => hashEmbed(tokenize(t), 512));
303 return { vectors, model: FALLBACK_MODEL };
304}
305
306// ---------------------------------------------------------------------------
307// Tree walking
308// ---------------------------------------------------------------------------
309
310const MAX_CHUNKS_PER_REPO = 2000;
311const MAX_BLOB_BYTES = 256 * 1024; // 256KB — skip anything larger
312
313async function walkCodePaths(
314 owner: string,
315 repo: string,
316 ref: string,
317 maxFiles = 5000
318): Promise<string[]> {
319 const out: string[] = [];
320 const queue: string[] = [""];
321 while (queue.length && out.length < maxFiles) {
322 const dir = queue.shift()!;
323 let entries: GitTreeEntry[] = [];
324 try {
325 entries = await getTree(owner, repo, ref, dir);
326 } catch {
327 continue;
328 }
329 for (const e of entries) {
330 const p = dir ? `${dir}/${e.name}` : e.name;
331 if (e.type === "tree") {
332 // Skip common noise directories.
333 const base = e.name.toLowerCase();
334 if (
335 base === "node_modules" ||
336 base === ".git" ||
337 base === "dist" ||
338 base === "build" ||
339 base === "vendor" ||
340 base === ".next" ||
341 base === ".turbo" ||
342 base === "target" ||
343 base === "__pycache__"
344 ) {
345 continue;
346 }
347 queue.push(p);
348 } else if (e.type === "blob") {
349 if (!isCodeFile(p)) continue;
350 if (e.size !== undefined && e.size > MAX_BLOB_BYTES) continue;
351 out.push(p);
352 if (out.length >= maxFiles) break;
353 }
354 }
355 }
356 return out;
357}
358
359// ---------------------------------------------------------------------------
360// Indexing
361// ---------------------------------------------------------------------------
362
363export interface IndexResult {
364 chunksIndexed: number;
365 model: string;
366}
367
368/**
369 * Walk the repo tree at the given commit sha, chunk every code file, embed
370 * the chunks in batches, and replace any previous index rows for this repo.
371 *
372 * Caps total chunks at `MAX_CHUNKS_PER_REPO` (logs + stops). Never throws —
373 * returns `{ chunksIndexed: 0, model }` on any failure.
374 */
375export async function indexRepository(args: {
376 owner: string;
377 repo: string;
378 repositoryId: string;
379 commitSha: string;
380}): Promise<IndexResult> {
381 const { owner, repo, repositoryId, commitSha } = args;
382
383 let chunks: Array<{
384 path: string;
385 startLine: number;
386 endLine: number;
387 content: string;
388 }> = [];
389
390 try {
391 const paths = await walkCodePaths(owner, repo, commitSha);
392 for (const p of paths) {
393 if (chunks.length >= MAX_CHUNKS_PER_REPO) {
394 console.warn(
395 `[semantic-search] chunk cap hit (${MAX_CHUNKS_PER_REPO}) for ${owner}/${repo} @ ${commitSha}; truncating`
396 );
397 break;
398 }
399 let blob;
400 try {
401 blob = await getBlob(owner, repo, commitSha, p);
402 } catch {
403 continue;
404 }
405 if (!blob || blob.isBinary) continue;
406 const fileChunks = chunkFile(p, blob.content, 40);
407 for (const ch of fileChunks) {
408 if (chunks.length >= MAX_CHUNKS_PER_REPO) break;
409 chunks.push(ch);
410 }
411 }
412 } catch (err) {
413 console.error(
414 `[semantic-search] tree walk failed for ${owner}/${repo}:`,
415 err
416 );
417 return { chunksIndexed: 0, model: FALLBACK_MODEL };
418 }
419
420 if (!chunks.length) {
421 // Still wipe old rows so stale indexes don't linger.
422 try {
423 await db.delete(codeChunks).where(eq(codeChunks.repositoryId, repositoryId));
424 } catch {}
425 return { chunksIndexed: 0, model: FALLBACK_MODEL };
426 }
427
428 // Embed in batches sized for Voyage's 128/request limit.
429 let model = FALLBACK_MODEL;
430 const vectors: number[][] = [];
431 try {
432 for (let i = 0; i < chunks.length; i += VOYAGE_BATCH) {
433 const slice = chunks.slice(i, i + VOYAGE_BATCH);
434 const { vectors: vs, model: m } = await embedBatch(
435 slice.map((c) => `${c.path}\n${c.content}`),
436 "document"
437 );
438 model = m;
439 for (const v of vs) vectors.push(v);
440 }
441 } catch (err) {
442 console.error(`[semantic-search] embed failed for ${owner}/${repo}:`, err);
443 return { chunksIndexed: 0, model };
444 }
445
446 if (vectors.length !== chunks.length) {
447 console.error(
448 `[semantic-search] vector/chunk length mismatch (${vectors.length} vs ${chunks.length})`
449 );
450 return { chunksIndexed: 0, model };
451 }
452
453 try {
454 await db.delete(codeChunks).where(eq(codeChunks.repositoryId, repositoryId));
455
456 // Batch-insert to avoid one-row-per-roundtrip.
457 const INSERT_BATCH = 100;
458 for (let i = 0; i < chunks.length; i += INSERT_BATCH) {
459 const slice = chunks.slice(i, i + INSERT_BATCH);
460 const rows = slice.map((c, j) => ({
461 repositoryId,
462 commitSha,
463 path: c.path,
464 startLine: c.startLine,
465 endLine: c.endLine,
466 content: c.content,
467 embedding: JSON.stringify(vectors[i + j]),
468 embeddingModel: model,
469 }));
470 await db.insert(codeChunks).values(rows);
471 }
472 } catch (err) {
473 console.error(`[semantic-search] DB write failed for ${owner}/${repo}:`, err);
474 return { chunksIndexed: 0, model };
475 }
476
477 return { chunksIndexed: chunks.length, model };
478}
479
480// ---------------------------------------------------------------------------
481// Search
482// ---------------------------------------------------------------------------
483
484export interface SearchHit {
485 path: string;
486 startLine: number;
487 endLine: number;
488 content: string;
489 score: number;
490}
491
492/**
493 * Embed `query`, load all chunk embeddings for this repo, rank by cosine,
494 * return the top `limit`. Empty array if the repo has no indexed chunks or
495 * anything fails.
496 */
497export async function searchRepository(args: {
498 repositoryId: string;
499 query: string;
500 limit?: number;
501}): Promise<SearchHit[]> {
502 const { repositoryId, query } = args;
503 const limit = args.limit ?? 20;
504 const q = (query || "").trim();
505 if (!q) return [];
506
507 let rows: Array<{
508 path: string;
509 startLine: number;
510 endLine: number;
511 content: string;
512 embedding: string | null;
513 embeddingModel: string | null;
514 }>;
515 try {
516 rows = await db
517 .select({
518 path: codeChunks.path,
519 startLine: codeChunks.startLine,
520 endLine: codeChunks.endLine,
521 content: codeChunks.content,
522 embedding: codeChunks.embedding,
523 embeddingModel: codeChunks.embeddingModel,
524 })
525 .from(codeChunks)
526 .where(eq(codeChunks.repositoryId, repositoryId));
527 } catch {
528 return [];
529 }
530
531 if (!rows.length) return [];
532
533 // Assume all rows use the same model (indexRepository rewrites them in bulk).
534 const model = rows[0].embeddingModel || FALLBACK_MODEL;
535
536 let queryVec: number[];
537 if (model === VOYAGE_MODEL && process.env.VOYAGE_API_KEY) {
538 const { vectors } = await embedBatch([q], "query");
539 queryVec = vectors[0];
540 } else {
541 queryVec = hashEmbed(tokenize(q), 512);
542 }
543
544 const scored: SearchHit[] = [];
545 for (const r of rows) {
546 if (!r.embedding) continue;
547 let v: number[];
548 try {
549 v = JSON.parse(r.embedding);
550 } catch {
551 continue;
552 }
553 if (!Array.isArray(v) || v.length === 0) continue;
554 const s = cosine(queryVec, v);
555 scored.push({
556 path: r.path,
557 startLine: r.startLine,
558 endLine: r.endLine,
559 content: r.content,
560 score: s,
561 });
562 }
563 scored.sort((a, b) => b.score - a.score);
564 return scored.slice(0, limit);
565}
566
567// ---------------------------------------------------------------------------
568// Test-only exports — pure helpers, no DB dependency.
569// ---------------------------------------------------------------------------
570
571export const __test = {
572 tokenize,
573 hashEmbed,
574 cosine,
575 isCodeFile,
576 chunkFile,
577 fnv1a,
578};
Addedsrc/lib/signatures.ts+719−0View fileUnifiedSplit
1/**
2 * Block J3 — Commit signature verification (GPG + SSH).
3 *
4 * Pragmatic V1 that does identity matching without requiring gpg/ssh-keygen
5 * binaries. The flow is:
6 *
7 * 1. Parse the raw commit object for a `gpgsig` / `gpgsig-sha256` header.
8 * 2. Tell whether the armored blob is a PGP signature or an SSH signature.
9 * 3. For PGP, decode the base64 body and walk the packet stream for an
10 * "Issuer Fingerprint" (subpacket 33) or "Issuer" (subpacket 16); for
11 * SSH, decode the inner SSHSIG blob for its embedded public key and
12 * fingerprint it with SHA-256.
13 * 4. Look the fingerprint up in `signing_keys`. If the registered key's
14 * owner's email matches the commit author email, → `verified=true`.
15 *
16 * We do NOT run gpg --verify here; cryptographic verification requires the
17 * full signed message re-construction + long-term key escrow which is out of
18 * scope for V1. The honest "Verified" badge therefore reads as: "signed with
19 * a key we've seen registered under this email." Future work (J3+) can shell
20 * out to `gpg --verify` / `ssh-keygen -Y verify` when those binaries are
21 * available at runtime.
22 */
23
24import { and, eq } from "drizzle-orm";
25import { db } from "../db";
26import {
27 commitVerifications,
28 signingKeys,
29 users,
30 type SigningKey,
31} from "../db/schema";
32import { getRawCommitObject } from "../git/repository";
33
34export type VerificationReason =
35 | "valid"
36 | "unsigned"
37 | "unknown_key"
38 | "expired"
39 | "bad_sig"
40 | "email_mismatch";
41
42export interface VerificationResult {
43 verified: boolean;
44 reason: VerificationReason;
45 signatureType: "gpg" | "ssh" | null;
46 fingerprint: string | null;
47 signerUserId: string | null;
48 signerKeyId: string | null;
49}
50
51// ----------------------------------------------------------------------------
52// Commit-object parsing
53// ----------------------------------------------------------------------------
54
55/**
56 * Pull out the `gpgsig` block from a raw commit object. Returns the armored
57 * signature (lines joined with \n, leading single-space continuation stripped)
58 * and the commit headers/body separately. Null when unsigned.
59 */
60export function extractSignatureFromCommit(
61 raw: string
62): { signature: string; type: "gpg" | "ssh"; authorEmail: string | null } | null {
63 if (!raw) return null;
64 const lines = raw.split("\n");
65 let sig: string[] = [];
66 let inSig = false;
67 let author: string | null = null;
68 for (let i = 0; i < lines.length; i++) {
69 const ln = lines[i];
70 if (ln === "") break; // headers ended
71 if (inSig) {
72 if (ln.startsWith(" ")) {
73 sig.push(ln.slice(1));
74 continue;
75 } else {
76 inSig = false;
77 }
78 }
79 if (ln.startsWith("gpgsig ") || ln.startsWith("gpgsig-sha256 ")) {
80 sig = [ln.replace(/^gpgsig(-sha256)? /, "")];
81 inSig = true;
82 continue;
83 }
84 if (ln.startsWith("author ")) {
85 const m = ln.match(/<([^>]+)>/);
86 if (m) author = m[1];
87 }
88 }
89 if (sig.length === 0) return null;
90 const armored = sig.join("\n");
91 const type: "gpg" | "ssh" = armored.includes("BEGIN SSH SIGNATURE")
92 ? "ssh"
93 : "gpg";
94 return { signature: armored, type, authorEmail: author };
95}
96
97// ----------------------------------------------------------------------------
98// PGP signature → issuer fingerprint
99// ----------------------------------------------------------------------------
100
101/** Base64 decoder that tolerates armor whitespace + CR. */
102function b64decode(s: string): Uint8Array {
103 const clean = s.replace(/[\r\n\s]+/g, "");
104 const bin = atob(clean);
105 const out = new Uint8Array(bin.length);
106 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
107 return out;
108}
109
110/**
111 * Strip PEM-style armor (BEGIN/END lines + optional CRC24 trailer starting
112 * with "="). Returns the raw packet stream bytes.
113 */
114export function unarmorPgp(armored: string): Uint8Array | null {
115 const lines = armored.split(/\r?\n/);
116 const body: string[] = [];
117 let inBody = false;
118 let afterBlankLine = false;
119 for (const ln of lines) {
120 if (ln.startsWith("-----BEGIN")) {
121 inBody = true;
122 afterBlankLine = false;
123 continue;
124 }
125 if (ln.startsWith("-----END")) break;
126 if (!inBody) continue;
127 if (!afterBlankLine) {
128 if (ln.trim() === "") {
129 afterBlankLine = true;
130 }
131 // Skip armor headers like "Version:" / "Comment:" until the blank line.
132 continue;
133 }
134 if (ln.startsWith("=")) continue; // CRC24 trailer
135 body.push(ln);
136 }
137 const joined = body.join("");
138 if (!joined) return null;
139 try {
140 return b64decode(joined);
141 } catch {
142 return null;
143 }
144}
145
146/**
147 * Walk the first few packets of a PGP packet stream looking for a signature
148 * packet (tag 2) and within it the hashed+unhashed subpacket areas for
149 * subpacket 33 (Issuer Fingerprint) or 16 (Issuer Key ID). Returns the
150 * fingerprint or key ID as a lowercase hex string, or null.
151 *
152 * Only supports the OpenPGP v1 (old) and current (new) packet formats; just
153 * enough of the grammar to pluck issuer info out of modern GPG sigs.
154 */
155export function parsePgpIssuerFingerprint(bytes: Uint8Array): string | null {
156 if (!bytes || bytes.length < 2) return null;
157 let off = 0;
158 while (off < bytes.length) {
159 const tagByte = bytes[off++];
160 if ((tagByte & 0x80) === 0) return null; // not a valid packet header
161 let tag: number;
162 let len: number;
163 if ((tagByte & 0x40) === 0) {
164 // Old-format packet
165 tag = (tagByte & 0x3c) >> 2;
166 const lenType = tagByte & 0x03;
167 if (lenType === 0) {
168 len = bytes[off++];
169 } else if (lenType === 1) {
170 len = (bytes[off++] << 8) | bytes[off++];
171 } else if (lenType === 2) {
172 len =
173 (bytes[off++] << 24) |
174 (bytes[off++] << 16) |
175 (bytes[off++] << 8) |
176 bytes[off++];
177 } else {
178 return null; // indeterminate length — give up
179 }
180 } else {
181 tag = tagByte & 0x3f;
182 const l0 = bytes[off++];
183 if (l0 < 192) {
184 len = l0;
185 } else if (l0 < 224) {
186 len = ((l0 - 192) << 8) + bytes[off++] + 192;
187 } else if (l0 === 255) {
188 len =
189 (bytes[off++] << 24) |
190 (bytes[off++] << 16) |
191 (bytes[off++] << 8) |
192 bytes[off++];
193 } else {
194 return null; // partial length body — skip
195 }
196 }
197 if (tag !== 2) {
198 off += len;
199 continue;
200 }
201 // Signature packet.
202 const end = off + len;
203 const version = bytes[off++];
204 if (version !== 4 && version !== 5) {
205 off = end;
206 continue;
207 }
208 // Skip: sigType (1) + pubAlgo (1) + hashAlgo (1)
209 off += 3;
210 // Hashed subpackets
211 const hashedLen =
212 version === 4
213 ? (bytes[off++] << 8) | bytes[off++]
214 : (bytes[off++] << 24) |
215 (bytes[off++] << 16) |
216 (bytes[off++] << 8) |
217 bytes[off++];
218 const fp = scanSubpackets(bytes, off, off + hashedLen);
219 if (fp) return fp;
220 off += hashedLen;
221 // Unhashed subpackets
222 const unhashedLen =
223 version === 4
224 ? (bytes[off++] << 8) | bytes[off++]
225 : (bytes[off++] << 24) |
226 (bytes[off++] << 16) |
227 (bytes[off++] << 8) |
228 bytes[off++];
229 const fp2 = scanSubpackets(bytes, off, off + unhashedLen);
230 if (fp2) return fp2;
231 off = end;
232 }
233 return null;
234}
235
236function scanSubpackets(
237 bytes: Uint8Array,
238 start: number,
239 end: number
240): string | null {
241 let off = start;
242 let keyIdFallback: string | null = null;
243 while (off < end) {
244 const l0 = bytes[off++];
245 let spLen: number;
246 if (l0 < 192) {
247 spLen = l0;
248 } else if (l0 < 255) {
249 spLen = ((l0 - 192) << 8) + bytes[off++] + 192;
250 } else {
251 spLen =
252 (bytes[off++] << 24) |
253 (bytes[off++] << 16) |
254 (bytes[off++] << 8) |
255 bytes[off++];
256 }
257 const spType = bytes[off] & 0x7f;
258 const bodyStart = off + 1;
259 const bodyEnd = off + spLen;
260 if (spType === 33) {
261 // [version (1 byte) | fingerprint (20 or 32 bytes)]
262 const hex: string[] = [];
263 for (let i = bodyStart + 1; i < bodyEnd; i++) {
264 hex.push(bytes[i].toString(16).padStart(2, "0"));
265 }
266 return hex.join("");
267 }
268 if (spType === 16 && !keyIdFallback) {
269 const hex: string[] = [];
270 for (let i = bodyStart; i < bodyEnd; i++) {
271 hex.push(bytes[i].toString(16).padStart(2, "0"));
272 }
273 keyIdFallback = hex.join("");
274 }
275 off = bodyEnd;
276 }
277 return keyIdFallback;
278}
279
280// ----------------------------------------------------------------------------
281// SSH signature → pubkey fingerprint
282// ----------------------------------------------------------------------------
283
284/**
285 * Unarmor an SSH signature (RFC "SSHSIG" format). Returns the inner binary
286 * blob that follows the 6-byte "SSHSIG" magic.
287 */
288export function unarmorSsh(armored: string): Uint8Array | null {
289 const lines = armored.split(/\r?\n/);
290 const body: string[] = [];
291 let inBody = false;
292 for (const ln of lines) {
293 if (ln.startsWith("-----BEGIN SSH SIGNATURE")) {
294 inBody = true;
295 continue;
296 }
297 if (ln.startsWith("-----END SSH SIGNATURE")) break;
298 if (inBody && ln.trim() !== "") body.push(ln);
299 }
300 if (!body.length) return null;
301 try {
302 return b64decode(body.join(""));
303 } catch {
304 return null;
305 }
306}
307
308/**
309 * Parse the "publickey" field out of an SSHSIG blob. Returns the public key
310 * wire-format bytes (length-prefixed `ssh-...`), or null.
311 */
312export function parseSshSigPublicKey(blob: Uint8Array): Uint8Array | null {
313 if (!blob || blob.length < 10) return null;
314 // Magic "SSHSIG"
315 const magic = "SSHSIG";
316 for (let i = 0; i < magic.length; i++) {
317 if (blob[i] !== magic.charCodeAt(i)) return null;
318 }
319 let off = magic.length;
320 // u32 version
321 off += 4;
322 // string publickey (u32 len + bytes)
323 if (off + 4 > blob.length) return null;
324 const len =
325 (blob[off] << 24) |
326 (blob[off + 1] << 16) |
327 (blob[off + 2] << 8) |
328 blob[off + 3];
329 off += 4;
330 if (off + len > blob.length) return null;
331 return blob.slice(off, off + len);
332}
333
334// ----------------------------------------------------------------------------
335// Fingerprints
336// ----------------------------------------------------------------------------
337
338/**
339 * Compute the canonical fingerprint for a registered signing key.
340 * - GPG: the public-key block's issuer fingerprint (we don't parse the
341 * full PGP pubkey — users must paste it with the fingerprint line, which
342 * we strip to lowercase hex).
343 * - SSH: base64 SHA-256 of the wire-format `ssh-...` key body (the second
344 * whitespace-separated token in an authorized_keys line).
345 */
346export async function fingerprintForPublicKey(
347 keyType: "gpg" | "ssh",
348 publicKey: string
349): Promise<string | null> {
350 if (keyType === "ssh") {
351 const token = publicKey.trim().split(/\s+/)[1];
352 if (!token) return null;
353 let bytes: Uint8Array;
354 try {
355 bytes = b64decode(token);
356 } catch {
357 return null;
358 }
359 const digest = await crypto.subtle.digest("SHA-256", bytes);
360 // Base64 (unpadded) — mimics `ssh-keygen -l -E sha256`.
361 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
362 /=+$/,
363 ""
364 );
365 return `SHA256:${b64}`;
366 }
367 // GPG: extract the first 40-char (or 64-char) hex string we can find.
368 const m =
369 publicKey.match(/\b([A-Fa-f0-9]{40})\b/) ||
370 publicKey.match(/\b([A-Fa-f0-9]{64})\b/);
371 if (!m) return null;
372 return m[1].toLowerCase();
373}
374
375// ----------------------------------------------------------------------------
376// End-to-end verify
377// ----------------------------------------------------------------------------
378
379/**
380 * Verify a commit given the raw commit object. Pure function — no DB access
381 * here. Returns the parsed signature info; the matcher step is done in
382 * `verifyCommit` below.
383 */
384export function analyzeRawCommit(
385 raw: string
386): {
387 type: "gpg" | "ssh" | null;
388 fingerprint: string | null;
389 authorEmail: string | null;
390} {
391 const sig = extractSignatureFromCommit(raw);
392 if (!sig) return { type: null, fingerprint: null, authorEmail: null };
393 if (sig.type === "gpg") {
394 const packets = unarmorPgp(sig.signature);
395 if (!packets) {
396 return { type: "gpg", fingerprint: null, authorEmail: sig.authorEmail };
397 }
398 const fp = parsePgpIssuerFingerprint(packets);
399 return {
400 type: "gpg",
401 fingerprint: fp ? fp.toLowerCase() : null,
402 authorEmail: sig.authorEmail,
403 };
404 }
405 // SSH
406 const blob = unarmorSsh(sig.signature);
407 if (!blob) {
408 return { type: "ssh", fingerprint: null, authorEmail: sig.authorEmail };
409 }
410 const pubkey = parseSshSigPublicKey(blob);
411 if (!pubkey) {
412 return { type: "ssh", fingerprint: null, authorEmail: sig.authorEmail };
413 }
414 return {
415 type: "ssh",
416 fingerprint: null, // filled by caller via SubtleCrypto
417 authorEmail: sig.authorEmail,
418 ...{
419 _sshPublicKey: pubkey,
420 },
421 } as any;
422}
423
424async function fingerprintSshBytes(bytes: Uint8Array): Promise<string> {
425 const digest = await crypto.subtle.digest("SHA-256", bytes);
426 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
427 /=+$/,
428 ""
429 );
430 return `SHA256:${b64}`;
431}
432
433/**
434 * Cache lookup → parse → match → write-back. The expensive work (git cat-file
435 * + subtle.digest) runs only on cache miss.
436 */
437export async function verifyCommit(
438 repositoryId: string,
439 ownerName: string,
440 repoName: string,
441 sha: string,
442 opts: { forceFresh?: boolean } = {}
443): Promise<VerificationResult> {
444 if (!opts.forceFresh) {
445 const [cached] = await db
446 .select()
447 .from(commitVerifications)
448 .where(
449 and(
450 eq(commitVerifications.repositoryId, repositoryId),
451 eq(commitVerifications.commitSha, sha)
452 )
453 )
454 .limit(1);
455 if (cached) {
456 return {
457 verified: cached.verified,
458 reason: cached.reason as VerificationReason,
459 signatureType: (cached.signatureType as any) ?? null,
460 fingerprint: cached.signerFingerprint,
461 signerUserId: cached.signerUserId,
462 signerKeyId: cached.signerKeyId,
463 };
464 }
465 }
466
467 const raw = await getRawCommitObject(ownerName, repoName, sha);
468 const result = await verifyRawCommit(raw);
469 await persistVerification(repositoryId, sha, result);
470 return result;
471}
472
473/** Test-friendly: verify without hitting git or the DB. */
474export async function verifyRawCommit(
475 raw: string | null
476): Promise<VerificationResult> {
477 if (!raw)
478 return {
479 verified: false,
480 reason: "unsigned",
481 signatureType: null,
482 fingerprint: null,
483 signerUserId: null,
484 signerKeyId: null,
485 };
486 const sig = extractSignatureFromCommit(raw);
487 if (!sig)
488 return {
489 verified: false,
490 reason: "unsigned",
491 signatureType: null,
492 fingerprint: null,
493 signerUserId: null,
494 signerKeyId: null,
495 };
496
497 let fingerprint: string | null = null;
498 if (sig.type === "gpg") {
499 const packets = unarmorPgp(sig.signature);
500 if (packets) {
501 const fp = parsePgpIssuerFingerprint(packets);
502 if (fp) fingerprint = fp.toLowerCase();
503 }
504 } else {
505 const blob = unarmorSsh(sig.signature);
506 if (blob) {
507 const pubkey = parseSshSigPublicKey(blob);
508 if (pubkey) fingerprint = await fingerprintSshBytes(pubkey);
509 }
510 }
511
512 if (!fingerprint) {
513 return {
514 verified: false,
515 reason: "bad_sig",
516 signatureType: sig.type,
517 fingerprint: null,
518 signerUserId: null,
519 signerKeyId: null,
520 };
521 }
522
523 // Match against registered keys — for GPG we match as suffix since the
524 // issuer subpacket may carry only the 64-bit key ID (trailing 16 hex chars).
525 let signingKey: SigningKey | null = null;
526 if (sig.type === "gpg") {
527 const all = await db
528 .select()
529 .from(signingKeys)
530 .where(eq(signingKeys.keyType, "gpg"))
531 .limit(500);
532 const fpLc = fingerprint.toLowerCase();
533 signingKey =
534 all.find((k) => k.fingerprint.toLowerCase() === fpLc) ??
535 all.find((k) => k.fingerprint.toLowerCase().endsWith(fpLc)) ??
536 null;
537 } else {
538 const [row] = await db
539 .select()
540 .from(signingKeys)
541 .where(
542 and(
543 eq(signingKeys.keyType, "ssh"),
544 eq(signingKeys.fingerprint, fingerprint)
545 )
546 )
547 .limit(1);
548 signingKey = row ?? null;
549 }
550
551 if (!signingKey) {
552 return {
553 verified: false,
554 reason: "unknown_key",
555 signatureType: sig.type,
556 fingerprint,
557 signerUserId: null,
558 signerKeyId: null,
559 };
560 }
561
562 if (signingKey.expiresAt && signingKey.expiresAt < new Date()) {
563 return {
564 verified: false,
565 reason: "expired",
566 signatureType: sig.type,
567 fingerprint,
568 signerUserId: signingKey.userId,
569 signerKeyId: signingKey.id,
570 };
571 }
572
573 // Email match (if declared on the key).
574 if (sig.authorEmail && signingKey.email) {
575 if (
576 signingKey.email.toLowerCase().trim() !==
577 sig.authorEmail.toLowerCase().trim()
578 ) {
579 return {
580 verified: false,
581 reason: "email_mismatch",
582 signatureType: sig.type,
583 fingerprint,
584 signerUserId: signingKey.userId,
585 signerKeyId: signingKey.id,
586 };
587 }
588 }
589
590 return {
591 verified: true,
592 reason: "valid",
593 signatureType: sig.type,
594 fingerprint,
595 signerUserId: signingKey.userId,
596 signerKeyId: signingKey.id,
597 };
598}
599
600async function persistVerification(
601 repositoryId: string,
602 sha: string,
603 result: VerificationResult
604): Promise<void> {
605 try {
606 await db
607 .insert(commitVerifications)
608 .values({
609 repositoryId,
610 commitSha: sha,
611 verified: result.verified,
612 reason: result.reason,
613 signatureType: result.signatureType,
614 signerKeyId: result.signerKeyId,
615 signerUserId: result.signerUserId,
616 signerFingerprint: result.fingerprint,
617 })
618 .onConflictDoNothing();
619 } catch {
620 // best effort — rendering should never fail because the cache write blew up
621 }
622}
623
624// ----------------------------------------------------------------------------
625// CRUD for /settings/signing-keys
626// ----------------------------------------------------------------------------
627
628export async function listSigningKeysForUser(
629 userId: string
630): Promise<SigningKey[]> {
631 return db
632 .select()
633 .from(signingKeys)
634 .where(eq(signingKeys.userId, userId));
635}
636
637export async function listSigningKeysForUsername(
638 username: string
639): Promise<Array<SigningKey & { username: string }>> {
640 return db
641 .select({
642 id: signingKeys.id,
643 userId: signingKeys.userId,
644 keyType: signingKeys.keyType,
645 title: signingKeys.title,
646 fingerprint: signingKeys.fingerprint,
647 publicKey: signingKeys.publicKey,
648 email: signingKeys.email,
649 expiresAt: signingKeys.expiresAt,
650 lastUsedAt: signingKeys.lastUsedAt,
651 createdAt: signingKeys.createdAt,
652 username: users.username,
653 })
654 .from(signingKeys)
655 .innerJoin(users, eq(signingKeys.userId, users.id))
656 .where(eq(users.username, username));
657}
658
659export async function addSigningKey(params: {
660 userId: string;
661 keyType: "gpg" | "ssh";
662 title: string;
663 publicKey: string;
664 email?: string | null;
665}): Promise<
666 | { ok: true; id: string; fingerprint: string }
667 | { ok: false; error: string }
668> {
669 const { userId, keyType, title, publicKey } = params;
670 const email = (params.email || "").trim() || null;
671 const trimmed = publicKey.trim();
672 if (!trimmed) return { ok: false, error: "Public key is required" };
673 if (keyType !== "gpg" && keyType !== "ssh") {
674 return { ok: false, error: "Unknown key type" };
675 }
676 if (!title.trim()) return { ok: false, error: "Title is required" };
677 const fingerprint = await fingerprintForPublicKey(keyType, trimmed);
678 if (!fingerprint) {
679 return { ok: false, error: "Could not derive a fingerprint" };
680 }
681 try {
682 const [row] = await db
683 .insert(signingKeys)
684 .values({
685 userId,
686 keyType,
687 title: title.trim(),
688 fingerprint,
689 publicKey: trimmed,
690 email,
691 })
692 .returning();
693 return { ok: true, id: row.id, fingerprint };
694 } catch (err: any) {
695 const msg = String(err?.message || "");
696 if (msg.includes("signing_keys_fp_unique") || msg.includes("duplicate")) {
697 return { ok: false, error: "That key is already registered" };
698 }
699 return { ok: false, error: "Could not save key" };
700 }
701}
702
703export async function deleteSigningKey(
704 keyId: string,
705 userId: string
706): Promise<boolean> {
707 const rows = await db
708 .delete(signingKeys)
709 .where(and(eq(signingKeys.id, keyId), eq(signingKeys.userId, userId)))
710 .returning();
711 return rows.length > 0;
712}
713
714// Test-only internals.
715export const __internal = {
716 b64decode,
717 scanSubpackets,
718 fingerprintSshBytes,
719};
Addedsrc/lib/sso.ts+453−0View fileUnifiedSplit
1/**
2 * Block I10 — Enterprise SSO via OpenID Connect.
3 *
4 * We chose OIDC over SAML because every modern IdP (Okta, Azure AD, Auth0,
5 * Google Workspace, Keycloak, Okta-on-prem) speaks OIDC natively, and OIDC
6 * only requires HTTP JSON / redirect flows — no XML signature verification.
7 *
8 * Flow:
9 * 1. User clicks "Sign in with SSO" → GET /login/sso
10 * 2. We redirect to the IdP's `authorization_endpoint` with a `state` +
11 * `nonce` cookie-bound to the browser session.
12 * 3. IdP sends the user back to /login/sso/callback?code=...&state=...
13 * 4. We exchange the code for an access_token + id_token at
14 * `token_endpoint`, then hit `userinfo_endpoint` to fetch the claims.
15 * 5. Find (or auto-create, if enabled) a local user by `sub`, create a
16 * session cookie, and redirect home.
17 *
18 * Admin configures the provider at /admin/sso. There is a single site-wide
19 * provider identified by `id = 'default'`; we don't do multi-tenant IdP.
20 */
21
22import { eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 ssoConfig,
26 ssoUserLinks,
27 users,
28 sessions,
29 type SsoConfig,
30 type SsoUserLink,
31 type User,
32} from "../db/schema";
33import {
34 generateSessionToken,
35 sessionExpiry,
36} from "./auth";
37import { config } from "./config";
38
39// ----------------------------------------------------------------------------
40// Types
41// ----------------------------------------------------------------------------
42
43export interface SsoConfigInput {
44 enabled: boolean;
45 providerName: string;
46 issuer: string;
47 authorizationEndpoint: string;
48 tokenEndpoint: string;
49 userinfoEndpoint: string;
50 clientId: string;
51 clientSecret: string;
52 scopes: string;
53 allowedEmailDomains: string | null;
54 autoCreateUsers: boolean;
55}
56
57export interface OidcClaims {
58 sub: string;
59 email?: string;
60 email_verified?: boolean;
61 name?: string;
62 preferred_username?: string;
63 given_name?: string;
64 family_name?: string;
65}
66
67export interface TokenResponse {
68 access_token: string;
69 id_token?: string;
70 token_type?: string;
71 expires_in?: number;
72 refresh_token?: string;
73 scope?: string;
74}
75
76// ----------------------------------------------------------------------------
77// Config CRUD
78// ----------------------------------------------------------------------------
79
80const SSO_CONFIG_ID = "default";
81
82/** Returns the singleton SSO config, or null if never configured. */
83export async function getSsoConfig(): Promise<SsoConfig | null> {
84 try {
85 const [row] = await db
86 .select()
87 .from(ssoConfig)
88 .where(eq(ssoConfig.id, SSO_CONFIG_ID))
89 .limit(1);
90 return row || null;
91 } catch {
92 return null;
93 }
94}
95
96/** Upsert config. Empty strings become nulls so partial configs are visible. */
97export async function upsertSsoConfig(
98 input: Partial<SsoConfigInput>
99): Promise<{ ok: true } | { ok: false; error: string }> {
100 try {
101 const now = new Date();
102 const values = {
103 id: SSO_CONFIG_ID,
104 enabled: !!input.enabled,
105 providerName: (input.providerName || "SSO").slice(0, 120),
106 issuer: emptyToNull(input.issuer),
107 authorizationEndpoint: emptyToNull(input.authorizationEndpoint),
108 tokenEndpoint: emptyToNull(input.tokenEndpoint),
109 userinfoEndpoint: emptyToNull(input.userinfoEndpoint),
110 clientId: emptyToNull(input.clientId),
111 clientSecret: emptyToNull(input.clientSecret),
112 scopes: (input.scopes || "openid profile email").slice(0, 256),
113 allowedEmailDomains: emptyToNull(input.allowedEmailDomains),
114 autoCreateUsers: input.autoCreateUsers !== false,
115 updatedAt: now,
116 };
117 await db
118 .insert(ssoConfig)
119 .values(values)
120 .onConflictDoUpdate({
121 target: ssoConfig.id,
122 set: {
123 enabled: values.enabled,
124 providerName: values.providerName,
125 issuer: values.issuer,
126 authorizationEndpoint: values.authorizationEndpoint,
127 tokenEndpoint: values.tokenEndpoint,
128 userinfoEndpoint: values.userinfoEndpoint,
129 clientId: values.clientId,
130 clientSecret: values.clientSecret,
131 scopes: values.scopes,
132 allowedEmailDomains: values.allowedEmailDomains,
133 autoCreateUsers: values.autoCreateUsers,
134 updatedAt: values.updatedAt,
135 },
136 });
137 return { ok: true };
138 } catch (err) {
139 return {
140 ok: false,
141 error: err instanceof Error ? err.message : "Failed to save config",
142 };
143 }
144}
145
146function emptyToNull(v: string | null | undefined): string | null {
147 if (v == null) return null;
148 const s = String(v).trim();
149 return s.length === 0 ? null : s;
150}
151
152// ----------------------------------------------------------------------------
153// OIDC flow helpers (pure, no DB)
154// ----------------------------------------------------------------------------
155
156/**
157 * Build the authorization-endpoint URL the browser should be redirected to.
158 * Adds client_id, redirect_uri, response_type=code, scope, state, nonce.
159 */
160export function buildAuthorizeUrl(
161 cfg: Pick<SsoConfig, "authorizationEndpoint" | "clientId" | "scopes">,
162 state: string,
163 nonce: string,
164 redirectUri: string
165): string {
166 if (!cfg.authorizationEndpoint || !cfg.clientId) {
167 throw new Error("SSO config missing authorization_endpoint or client_id");
168 }
169 const u = new URL(cfg.authorizationEndpoint);
170 u.searchParams.set("client_id", cfg.clientId);
171 u.searchParams.set("redirect_uri", redirectUri);
172 u.searchParams.set("response_type", "code");
173 u.searchParams.set("scope", cfg.scopes || "openid profile email");
174 u.searchParams.set("state", state);
175 u.searchParams.set("nonce", nonce);
176 return u.toString();
177}
178
179/** Crypto-random hex string for state + nonce + link-subject-collision retries. */
180export function randomToken(bytes = 16): string {
181 const arr = crypto.getRandomValues(new Uint8Array(bytes));
182 return Array.from(arr)
183 .map((b) => b.toString(16).padStart(2, "0"))
184 .join("");
185}
186
187/**
188 * Exchange the authorization code for tokens. IdP is trusted; we don't
189 * verify the id_token signature here because we immediately turn around
190 * and hit userinfo over HTTPS with the access_token, which has the same
191 * integrity guarantee.
192 */
193export async function exchangeCode(
194 cfg: Pick<SsoConfig, "tokenEndpoint" | "clientId" | "clientSecret">,
195 code: string,
196 redirectUri: string
197): Promise<TokenResponse> {
198 if (!cfg.tokenEndpoint || !cfg.clientId || !cfg.clientSecret) {
199 throw new Error("SSO config missing token_endpoint or client credentials");
200 }
201 const body = new URLSearchParams({
202 grant_type: "authorization_code",
203 code,
204 redirect_uri: redirectUri,
205 client_id: cfg.clientId,
206 client_secret: cfg.clientSecret,
207 });
208 const res = await fetch(cfg.tokenEndpoint, {
209 method: "POST",
210 headers: {
211 "content-type": "application/x-www-form-urlencoded",
212 accept: "application/json",
213 },
214 body: body.toString(),
215 });
216 if (!res.ok) {
217 const text = await res.text().catch(() => "");
218 throw new Error(
219 `token_endpoint ${res.status}: ${text.slice(0, 200) || "no body"}`
220 );
221 }
222 const json = (await res.json()) as TokenResponse;
223 if (!json.access_token) {
224 throw new Error("token_endpoint response missing access_token");
225 }
226 return json;
227}
228
229/** Fetch userinfo claims using the access_token. */
230export async function fetchUserinfo(
231 cfg: Pick<SsoConfig, "userinfoEndpoint">,
232 accessToken: string
233): Promise<OidcClaims> {
234 if (!cfg.userinfoEndpoint) {
235 throw new Error("SSO config missing userinfo_endpoint");
236 }
237 const res = await fetch(cfg.userinfoEndpoint, {
238 headers: {
239 authorization: `Bearer ${accessToken}`,
240 accept: "application/json",
241 },
242 });
243 if (!res.ok) {
244 const text = await res.text().catch(() => "");
245 throw new Error(
246 `userinfo_endpoint ${res.status}: ${text.slice(0, 200) || "no body"}`
247 );
248 }
249 const claims = (await res.json()) as OidcClaims;
250 if (!claims.sub) {
251 throw new Error("userinfo response missing sub claim");
252 }
253 return claims;
254}
255
256/**
257 * Check whether the given email is allowed by the admin's domain restriction.
258 * `allowed` is a comma-separated list of domains (e.g. "example.com,acme.io").
259 * null or empty = allow any.
260 */
261export function emailDomainAllowed(
262 email: string | undefined | null,
263 allowed: string | null | undefined
264): boolean {
265 if (!allowed || !allowed.trim()) return true;
266 if (!email) return false;
267 const domain = email.split("@")[1]?.toLowerCase().trim();
268 if (!domain) return false;
269 const list = allowed
270 .split(",")
271 .map((d) => d.trim().toLowerCase())
272 .filter(Boolean);
273 return list.includes(domain);
274}
275
276// ----------------------------------------------------------------------------
277// User linkage + provisioning
278// ----------------------------------------------------------------------------
279
280export async function findSsoLinkBySubject(
281 subject: string
282): Promise<SsoUserLink | null> {
283 try {
284 const [row] = await db
285 .select()
286 .from(ssoUserLinks)
287 .where(eq(ssoUserLinks.subject, subject))
288 .limit(1);
289 return row || null;
290 } catch {
291 return null;
292 }
293}
294
295/**
296 * Given OIDC claims, find the linked local user, or auto-create one when
297 * the admin has enabled `autoCreateUsers`. Returns the User row, or null if
298 * no match and auto-creation is off.
299 *
300 * This also creates an `sso_user_links` row on first sign-in so subsequent
301 * logins short-circuit on the `sub` lookup.
302 */
303export async function findOrCreateUserFromSso(
304 claims: OidcClaims,
305 cfg: SsoConfig
306): Promise<
307 | { ok: true; user: User }
308 | { ok: false; error: string }
309> {
310 // 1. Existing link by subject
311 const link = await findSsoLinkBySubject(claims.sub);
312 if (link) {
313 const [user] = await db
314 .select()
315 .from(users)
316 .where(eq(users.id, link.userId))
317 .limit(1);
318 if (user) return { ok: true, user };
319 // Orphaned link (user deleted) — drop it and fall through.
320 await db
321 .delete(ssoUserLinks)
322 .where(eq(ssoUserLinks.subject, claims.sub))
323 .catch(() => {});
324 }
325
326 // 2. Domain gate
327 if (!emailDomainAllowed(claims.email, cfg.allowedEmailDomains)) {
328 return {
329 ok: false,
330 error: "Your email domain is not permitted for SSO sign-in.",
331 };
332 }
333
334 // 3. Match by email when present
335 if (claims.email) {
336 const [existing] = await db
337 .select()
338 .from(users)
339 .where(eq(users.email, claims.email))
340 .limit(1);
341 if (existing) {
342 await db
343 .insert(ssoUserLinks)
344 .values({
345 userId: existing.id,
346 subject: claims.sub,
347 emailAtLink: claims.email,
348 })
349 .onConflictDoNothing();
350 return { ok: true, user: existing };
351 }
352 }
353
354 // 4. Auto-create
355 if (!cfg.autoCreateUsers) {
356 return {
357 ok: false,
358 error:
359 "No matching account, and the administrator has disabled SSO account creation.",
360 };
361 }
362
363 const email = claims.email;
364 if (!email) {
365 return {
366 ok: false,
367 error: "SSO provider did not return an email claim.",
368 };
369 }
370
371 const username = await pickAvailableUsername(
372 claims.preferred_username || claims.name || email.split("@")[0] || "user"
373 );
374
375 // SSO users don't have a local password — store a random unusable hash.
376 // The login form requires a password match against bcrypt so random bytes
377 // here mean the account is SSO-only unless they set a password later.
378 const fakeHash = "sso-only:" + randomToken(32);
379
380 const [user] = await db
381 .insert(users)
382 .values({
383 username,
384 email,
385 passwordHash: fakeHash,
386 })
387 .returning();
388
389 await db
390 .insert(ssoUserLinks)
391 .values({
392 userId: user.id,
393 subject: claims.sub,
394 emailAtLink: email,
395 })
396 .onConflictDoNothing();
397
398 return { ok: true, user };
399}
400
401/** Normalize an IdP-provided name into a valid gluecron username. */
402export function normalizeUsername(raw: string): string {
403 const base = raw
404 .toLowerCase()
405 .replace(/[^a-z0-9_-]+/g, "-")
406 .replace(/^-+|-+$/g, "")
407 .slice(0, 32);
408 return base || "user";
409}
410
411/** Pick a username not already taken. Appends a random suffix on collision. */
412async function pickAvailableUsername(raw: string): Promise<string> {
413 const base = normalizeUsername(raw);
414 for (let i = 0; i < 5; i++) {
415 const candidate = i === 0 ? base : `${base}-${randomToken(3)}`;
416 try {
417 const [row] = await db
418 .select({ id: users.id })
419 .from(users)
420 .where(eq(users.username, candidate))
421 .limit(1);
422 if (!row) return candidate;
423 } catch {
424 return `${base}-${randomToken(3)}`;
425 }
426 }
427 return `${base}-${randomToken(4)}`;
428}
429
430/** Issue a session cookie token for a user. Caller sets the cookie. */
431export async function issueSsoSession(userId: string): Promise<string> {
432 const token = generateSessionToken();
433 await db.insert(sessions).values({
434 userId,
435 token,
436 expiresAt: sessionExpiry(),
437 });
438 return token;
439}
440
441/** Compute the fully-qualified OIDC redirect URI for this deployment. */
442export function ssoRedirectUri(): string {
443 return `${config.appBaseUrl}/login/sso/callback`;
444}
445
446// ----------------------------------------------------------------------------
447// Test-only exports
448// ----------------------------------------------------------------------------
449
450export const __internal = {
451 emptyToNull,
452 normalizeUsername,
453};
Addedsrc/lib/symbols.ts+319−0View fileUnifiedSplit
1/**
2 * Block I8 — Symbol / xref navigation.
3 *
4 * A pragmatic regex-based top-level symbol extractor. Runs per-language,
5 * catches the common definition shapes (function / class / interface /
6 * type / const). References are computed at lookup-time by grepping the
7 * repository's tree for the symbol name, so this module persists only
8 * definitions. Never throws into request path.
9 */
10
11import { and, eq } from "drizzle-orm";
12import { db } from "../db";
13import { codeSymbols, repositories, users } from "../db/schema";
14import { getBlob, getTree, getDefaultBranch, resolveRef } from "../git/repository";
15import type { CodeSymbol } from "../db/schema";
16import type { GitTreeEntry } from "../git/repository";
17
18export type SymbolKind =
19 | "function"
20 | "class"
21 | "interface"
22 | "type"
23 | "const"
24 | "variable";
25
26export interface ExtractedSymbol {
27 name: string;
28 kind: SymbolKind;
29 line: number;
30 signature: string;
31}
32
33type Rule = { kind: SymbolKind; re: RegExp };
34
35// ---------- Language detection ----------
36
37const EXT_LANG: Record<string, string> = {
38 ts: "ts",
39 tsx: "ts",
40 js: "ts",
41 jsx: "ts",
42 mjs: "ts",
43 cjs: "ts",
44 py: "py",
45 rs: "rs",
46 go: "go",
47 rb: "rb",
48 java: "java",
49 kt: "kt",
50 swift: "swift",
51};
52
53export function detectLanguage(path: string): string | null {
54 const ext = path.split(".").pop()?.toLowerCase() || "";
55 return EXT_LANG[ext] ?? null;
56}
57
58// ---------- Per-language rules ----------
59
60const RULES: Record<string, Rule[]> = {
61 ts: [
62 {
63 kind: "function",
64 re: /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/,
65 },
66 {
67 kind: "function",
68 re: /^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/,
69 },
70 {
71 kind: "function",
72 re: /^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*:\s*[^=]+=\s*(?:async\s*)?\(/,
73 },
74 {
75 kind: "class",
76 re: /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
77 },
78 {
79 kind: "interface",
80 re: /^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/,
81 },
82 {
83 kind: "type",
84 re: /^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\s*=/,
85 },
86 {
87 kind: "const",
88 re: /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*[:=]/,
89 },
90 ],
91 py: [
92 { kind: "function", re: /^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/ },
93 { kind: "class", re: /^\s*class\s+([A-Za-z_][\w]*)\s*[:(]/ },
94 { kind: "const", re: /^([A-Z_][A-Z0-9_]*)\s*=/ },
95 ],
96 rs: [
97 { kind: "function", re: /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/ },
98 { kind: "class", re: /^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][\w]*)/ },
99 { kind: "interface", re: /^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+([A-Za-z_][\w]*)/ },
100 { kind: "type", re: /^\s*(?:pub(?:\([^)]*\))?\s+)?type\s+([A-Za-z_][\w]*)\s*=/ },
101 { kind: "const", re: /^\s*(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Z_][A-Z0-9_]*)\s*:/ },
102 ],
103 go: [
104 { kind: "function", re: /^\s*func(?:\s+\([^)]*\))?\s+([A-Za-z_][\w]*)\s*\(/ },
105 { kind: "class", re: /^\s*type\s+([A-Za-z_][\w]*)\s+struct\b/ },
106 { kind: "interface", re: /^\s*type\s+([A-Za-z_][\w]*)\s+interface\b/ },
107 { kind: "type", re: /^\s*type\s+([A-Za-z_][\w]*)\s+\w/ },
108 { kind: "const", re: /^\s*const\s+([A-Za-z_][\w]*)\s*=/ },
109 ],
110 rb: [
111 { kind: "function", re: /^\s*def\s+(?:self\.)?([A-Za-z_][\w?!=]*)/ },
112 { kind: "class", re: /^\s*class\s+([A-Z][\w]*)/ },
113 ],
114 java: [
115 {
116 kind: "class",
117 re: /^\s*(?:public|private|protected)?\s*(?:abstract\s+|final\s+)?class\s+([A-Z][\w]*)/,
118 },
119 {
120 kind: "interface",
121 re: /^\s*(?:public|private|protected)?\s*interface\s+([A-Z][\w]*)/,
122 },
123 ],
124 kt: [
125 { kind: "function", re: /^\s*(?:public|private|internal)?\s*fun\s+([A-Za-z_][\w]*)/ },
126 { kind: "class", re: /^\s*(?:public|private|internal)?\s*class\s+([A-Z][\w]*)/ },
127 ],
128 swift: [
129 { kind: "function", re: /^\s*(?:public|private|fileprivate|internal)?\s*func\s+([A-Za-z_][\w]*)/ },
130 { kind: "class", re: /^\s*(?:public|private|fileprivate|internal)?\s*class\s+([A-Z][\w]*)/ },
131 { kind: "interface", re: /^\s*(?:public|private|fileprivate|internal)?\s*protocol\s+([A-Z][\w]*)/ },
132 ],
133};
134
135// ---------- Extractor ----------
136
137/** Pure — extract top-level symbol definitions from a single file. */
138export function extractSymbols(
139 content: string,
140 lang: string
141): ExtractedSymbol[] {
142 const rules = RULES[lang];
143 if (!rules) return [];
144 const out: ExtractedSymbol[] = [];
145 const seen = new Set<string>();
146 const lines = content.split("\n");
147 for (let i = 0; i < lines.length; i++) {
148 const line = lines[i];
149 if (line.length > 500) continue; // skip minified lines
150 for (const rule of rules) {
151 const m = line.match(rule.re);
152 if (m && m[1]) {
153 const key = `${rule.kind}:${m[1]}:${i}`;
154 if (seen.has(key)) continue;
155 seen.add(key);
156 out.push({
157 name: m[1],
158 kind: rule.kind,
159 line: i + 1,
160 signature: line.trim().slice(0, 240),
161 });
162 break; // one match per line
163 }
164 }
165 }
166 return out;
167}
168
169// ---------- Indexer ----------
170
171const INDEXABLE_MAX_BYTES = 1_000_000; // skip files over 1MB
172const MAX_FILES = 2_000; // cap per reindex
173
174async function walkCodePaths(
175 owner: string,
176 repo: string,
177 ref: string,
178 maxFiles = MAX_FILES
179): Promise<Array<{ path: string; size?: number }>> {
180 const out: Array<{ path: string; size?: number }> = [];
181 const queue: string[] = [""];
182 while (queue.length && out.length < maxFiles) {
183 const dir = queue.shift()!;
184 let entries: GitTreeEntry[] = [];
185 try {
186 entries = await getTree(owner, repo, ref, dir);
187 } catch {
188 continue;
189 }
190 for (const e of entries) {
191 const p = dir ? `${dir}/${e.name}` : e.name;
192 if (e.type === "tree") {
193 const base = e.name.toLowerCase();
194 if (
195 base === "node_modules" ||
196 base === ".git" ||
197 base === "dist" ||
198 base === "build" ||
199 base === "vendor" ||
200 base === ".next" ||
201 base === ".turbo" ||
202 base === "target" ||
203 base === "__pycache__"
204 ) {
205 continue;
206 }
207 queue.push(p);
208 } else if (e.type === "blob") {
209 if (!detectLanguage(p)) continue;
210 if (e.size !== undefined && e.size > INDEXABLE_MAX_BYTES) continue;
211 out.push({ path: p, size: e.size });
212 if (out.length >= maxFiles) break;
213 }
214 }
215 }
216 return out;
217}
218
219/** Walks the repo tree at HEAD, extracts symbols, replaces the prior set. */
220export async function indexRepositorySymbols(
221 repositoryId: string
222): Promise<{ indexed: number; files: number; commitSha: string } | null> {
223 try {
224 const [repo] = await db
225 .select()
226 .from(repositories)
227 .where(eq(repositories.id, repositoryId))
228 .limit(1);
229 if (!repo) return null;
230
231 const [owner] = await db
232 .select({ username: users.username })
233 .from(users)
234 .where(eq(users.id, repo.ownerId))
235 .limit(1);
236 if (!owner) return null;
237
238 const defaultBranch =
239 (await getDefaultBranch(owner.username, repo.name)) || "main";
240 const head = await resolveRef(owner.username, repo.name, defaultBranch);
241 if (!head) return null;
242
243 const files = await walkCodePaths(owner.username, repo.name, head);
244
245 const rows: Array<Omit<CodeSymbol, "id" | "createdAt">> = [];
246 let processed = 0;
247
248 for (const f of files) {
249 const lang = detectLanguage(f.path);
250 if (!lang) continue;
251 try {
252 const blob = await getBlob(owner.username, repo.name, head, f.path);
253 if (!blob || blob.isBinary) continue;
254 const syms = extractSymbols(blob.content, lang);
255 for (const s of syms) {
256 rows.push({
257 repositoryId: repo.id,
258 commitSha: head,
259 name: s.name,
260 kind: s.kind,
261 path: f.path,
262 line: s.line,
263 signature: s.signature,
264 });
265 }
266 processed++;
267 } catch {
268 // skip unreadable files
269 }
270 }
271
272 // Replace the prior index (DELETE + batched INSERTs).
273 await db.delete(codeSymbols).where(eq(codeSymbols.repositoryId, repo.id));
274 const BATCH = 500;
275 for (let i = 0; i < rows.length; i += BATCH) {
276 await db.insert(codeSymbols).values(rows.slice(i, i + BATCH));
277 }
278
279 return { indexed: rows.length, files: processed, commitSha: head };
280 } catch (err) {
281 console.error("[symbols] indexRepositorySymbols error:", err);
282 return null;
283 }
284}
285
286/** Find definitions of a symbol name within a repo. */
287export async function findDefinitions(
288 repositoryId: string,
289 name: string
290): Promise<CodeSymbol[]> {
291 try {
292 return await db
293 .select()
294 .from(codeSymbols)
295 .where(
296 and(eq(codeSymbols.repositoryId, repositoryId), eq(codeSymbols.name, name))
297 );
298 } catch {
299 return [];
300 }
301}
302
303/** Count total indexed symbols for a repo (pagination helper). */
304export async function countSymbolsForRepo(
305 repositoryId: string
306): Promise<number> {
307 try {
308 const rows = await db
309 .select({ id: codeSymbols.id })
310 .from(codeSymbols)
311 .where(eq(codeSymbols.repositoryId, repositoryId));
312 return rows.length;
313 } catch {
314 return 0;
315 }
316}
317
318// Test-only hook
319export const __internal = { RULES, EXT_LANG };
Addedsrc/lib/templates.ts+86−0View fileUnifiedSplit
1/**
2 * Issue + PR template loader. Looks for the standard locations and
3 * returns the first match. Fails silently (returns null) so new-issue /
4 * new-PR forms always render — templates are a convenience, not a
5 * requirement.
6 */
7
8import { getBlob, getDefaultBranch } from "../git/repository";
9
10const ISSUE_PATHS = [
11 ".github/ISSUE_TEMPLATE.md",
12 ".github/issue_template.md",
13 ".gluecron/ISSUE_TEMPLATE.md",
14 "ISSUE_TEMPLATE.md",
15 "docs/ISSUE_TEMPLATE.md",
16];
17
18const PR_PATHS = [
19 ".github/PULL_REQUEST_TEMPLATE.md",
20 ".github/pull_request_template.md",
21 ".gluecron/PULL_REQUEST_TEMPLATE.md",
22 "PULL_REQUEST_TEMPLATE.md",
23 "docs/PULL_REQUEST_TEMPLATE.md",
24];
25
26const MAX_TEMPLATE_BYTES = 16 * 1024;
27
28async function loadFirst(
29 owner: string,
30 repo: string,
31 ref: string,
32 paths: string[]
33): Promise<string | null> {
34 for (const p of paths) {
35 try {
36 const blob = await getBlob(owner, repo, ref, p);
37 if (blob && !blob.isBinary && blob.content) {
38 // Guard against someone committing a 5MB template.
39 if (blob.content.length > MAX_TEMPLATE_BYTES) {
40 return blob.content.slice(0, MAX_TEMPLATE_BYTES);
41 }
42 return stripFrontmatter(blob.content);
43 }
44 } catch {
45 // keep looking
46 }
47 }
48 return null;
49}
50
51/**
52 * GitHub-style templates can have YAML frontmatter (---\nname: ...\n---).
53 * Strip it — we don't use the name/about fields here.
54 */
55function stripFrontmatter(content: string): string {
56 if (!content.startsWith("---\n")) return content;
57 const end = content.indexOf("\n---", 4);
58 if (end < 0) return content;
59 return content.slice(end + 4).replace(/^\n+/, "");
60}
61
62export async function loadIssueTemplate(
63 owner: string,
64 repo: string
65): Promise<string | null> {
66 try {
67 const ref = (await getDefaultBranch(owner, repo)) || "HEAD";
68 return await loadFirst(owner, repo, ref, ISSUE_PATHS);
69 } catch {
70 return null;
71 }
72}
73
74export async function loadPrTemplate(
75 owner: string,
76 repo: string
77): Promise<string | null> {
78 try {
79 const ref = (await getDefaultBranch(owner, repo)) || "HEAD";
80 return await loadFirst(owner, repo, ref, PR_PATHS);
81 } catch {
82 return null;
83 }
84}
85
86export { stripFrontmatter as _stripFrontmatterForTest };
Addedsrc/lib/totp.ts+185−0View fileUnifiedSplit
1/**
2 * TOTP (RFC 6238) — standalone, no external deps.
3 *
4 * Used for 2FA (Block B4). Generates + verifies 6-digit codes with a 30-second
5 * step. Verification accepts the current step ±1 to tolerate clock skew.
6 *
7 * Secrets are stored as Base32 strings (the standard QR-code encoding) and
8 * converted to bytes on each verify. At rest the secret is further encrypted
9 * (see `src/lib/crypto.ts` for the AES-GCM wrapper introduced in this block).
10 */
11
12const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
13
14/** Encode random bytes as a Base32 string with no padding (TOTP standard). */
15export function base32Encode(bytes: Uint8Array): string {
16 let bits = 0;
17 let value = 0;
18 let output = "";
19 for (let i = 0; i < bytes.length; i++) {
20 value = (value << 8) | bytes[i]!;
21 bits += 8;
22 while (bits >= 5) {
23 output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
24 bits -= 5;
25 }
26 }
27 if (bits > 0) {
28 output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
29 }
30 return output;
31}
32
33/** Decode a Base32 string back into bytes. Permissive about case + padding. */
34export function base32Decode(input: string): Uint8Array {
35 const clean = input
36 .toUpperCase()
37 .replace(/=+$/g, "")
38 .replace(/\s+/g, "");
39 let bits = 0;
40 let value = 0;
41 const out: number[] = [];
42 for (let i = 0; i < clean.length; i++) {
43 const idx = BASE32_ALPHABET.indexOf(clean[i]!);
44 if (idx === -1) {
45 throw new Error(`Invalid Base32 character: ${clean[i]}`);
46 }
47 value = (value << 5) | idx;
48 bits += 5;
49 if (bits >= 8) {
50 bits -= 8;
51 out.push((value >>> bits) & 0xff);
52 }
53 }
54 return new Uint8Array(out);
55}
56
57/**
58 * Generate a cryptographically random TOTP secret. 20 bytes → 32 Base32 chars,
59 * the length most auth apps expect and RFC 4226 recommends.
60 */
61export function generateTotpSecret(): string {
62 return base32Encode(crypto.getRandomValues(new Uint8Array(20)));
63}
64
65async function hmacSha1(
66 keyBytes: Uint8Array,
67 msgBytes: Uint8Array
68): Promise<Uint8Array> {
69 const key = await crypto.subtle.importKey(
70 "raw",
71 keyBytes,
72 { name: "HMAC", hash: "SHA-1" },
73 false,
74 ["sign"]
75 );
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
77 return new Uint8Array(sig);
78}
79
80/** Dynamic-truncate the HMAC output into a 6-digit number (RFC 4226). */
81function hotpCode(hmac: Uint8Array): string {
82 const offset = hmac[hmac.length - 1]! & 0x0f;
83 const bin =
84 ((hmac[offset]! & 0x7f) << 24) |
85 ((hmac[offset + 1]! & 0xff) << 16) |
86 ((hmac[offset + 2]! & 0xff) << 8) |
87 (hmac[offset + 3]! & 0xff);
88 return String(bin % 1_000_000).padStart(6, "0");
89}
90
91/** Generate the TOTP code for a given secret + unix time (seconds). */
92export async function totpCode(
93 secretBase32: string,
94 timeSec: number = Math.floor(Date.now() / 1000)
95): Promise<string> {
96 const step = Math.floor(timeSec / 30);
97 const msg = new Uint8Array(8);
98 // Big-endian 8-byte counter.
99 new DataView(msg.buffer).setBigUint64(0, BigInt(step), false);
100 const hmac = await hmacSha1(base32Decode(secretBase32), msg);
101 return hotpCode(hmac);
102}
103
104/**
105 * Verify a 6-digit code against a secret with ±1 step tolerance.
106 * Constant-time-ish string compare (both sides same length).
107 */
108export async function verifyTotpCode(
109 secretBase32: string,
110 code: string,
111 timeSec: number = Math.floor(Date.now() / 1000)
112): Promise<boolean> {
113 if (!/^\d{6}$/.test(code)) return false;
114 const candidates = await Promise.all([
115 totpCode(secretBase32, timeSec - 30),
116 totpCode(secretBase32, timeSec),
117 totpCode(secretBase32, timeSec + 30),
118 ]);
119 let ok = false;
120 for (const c of candidates) {
121 // Avoid short-circuit: keep timing close.
122 if (constantTimeEqual(c, code)) ok = true;
123 }
124 return ok;
125}
126
127function constantTimeEqual(a: string, b: string): boolean {
128 if (a.length !== b.length) return false;
129 let diff = 0;
130 for (let i = 0; i < a.length; i++) {
131 diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
132 }
133 return diff === 0;
134}
135
136/**
137 * Build an otpauth:// URI suitable for QR codes. Most authenticator apps
138 * (Google Authenticator, 1Password, Bitwarden, Authy) accept this format.
139 */
140export function otpauthUrl(opts: {
141 secret: string;
142 accountName: string;
143 issuer: string;
144}): string {
145 const label = encodeURIComponent(`${opts.issuer}:${opts.accountName}`);
146 const params = new URLSearchParams({
147 secret: opts.secret,
148 issuer: opts.issuer,
149 algorithm: "SHA1",
150 digits: "6",
151 period: "30",
152 });
153 return `otpauth://totp/${label}?${params.toString()}`;
154}
155
156/**
157 * Generate N random recovery codes in the format xxxx-xxxx-xxxx (lowercase
158 * alphanumeric). Each code is ~70 bits of entropy and single-use.
159 */
160export function generateRecoveryCodes(count = 10): string[] {
161 const codes: string[] = [];
162 for (let i = 0; i < count; i++) {
163 const parts: string[] = [];
164 for (let j = 0; j < 3; j++) {
165 const bytes = crypto.getRandomValues(new Uint8Array(3));
166 parts.push(
167 Array.from(bytes)
168 .map((b) => b.toString(36).padStart(2, "0"))
169 .join("")
170 .slice(0, 4)
171 );
172 }
173 codes.push(parts.join("-"));
174 }
175 return codes;
176}
177
178/** Hash a recovery code with SHA-256 for storage. */
179export async function hashRecoveryCode(code: string): Promise<string> {
180 const bytes = new TextEncoder().encode(code.trim().toLowerCase());
181 const digest = await crypto.subtle.digest("SHA-256", bytes);
182 return Array.from(new Uint8Array(digest))
183 .map((b) => b.toString(16).padStart(2, "0"))
184 .join("");
185}
Addedsrc/lib/traffic.ts+242−0View fileUnifiedSplit
1/**
2 * Block F1 — Traffic analytics helpers.
3 *
4 * Records + rolls up per-repo visit / clone / API hits. We record a row per
5 * event (cheap) and aggregate in-memory for the chart. `trackView` and
6 * `trackClone` are fire-and-forget — they never throw and never slow the
7 * user-facing request.
8 */
9
10import { and, eq, gte, sql } from "drizzle-orm";
11import { createHash } from "node:crypto";
12import { db } from "../db";
13import { repoTrafficEvents, repositories, users } from "../db/schema";
14
15export type TrafficKind = "view" | "clone" | "api" | "ui";
16
17export interface TrackArgs {
18 repositoryId: string;
19 kind: TrafficKind;
20 path?: string | null;
21 userId?: string | null;
22 ip?: string | null;
23 userAgent?: string | null;
24 referer?: string | null;
25}
26
27function hashIp(ip: string | null | undefined): string | null {
28 if (!ip) return null;
29 return createHash("sha256")
30 .update(ip)
31 .digest("hex")
32 .slice(0, 16); // 64 bits is plenty for uniqueness within a day
33}
34
35/**
36 * Record a traffic event. Never throws. Returns quickly — callers don't need
37 * to await, but may if they want backpressure.
38 */
39export async function track(args: TrackArgs): Promise<void> {
40 try {
41 await db.insert(repoTrafficEvents).values({
42 repositoryId: args.repositoryId,
43 kind: args.kind,
44 path: args.path ? args.path.slice(0, 256) : null,
45 userId: args.userId || null,
46 ipHash: hashIp(args.ip),
47 userAgent: args.userAgent ? args.userAgent.slice(0, 128) : null,
48 referer: args.referer ? args.referer.slice(0, 256) : null,
49 });
50 } catch {
51 // swallow
52 }
53}
54
55/**
56 * Convenience wrappers. Kept separate so call sites read semantically.
57 */
58export async function trackView(
59 args: Omit<TrackArgs, "kind">
60): Promise<void> {
61 return track({ ...args, kind: "view" });
62}
63
64export async function trackClone(
65 args: Omit<TrackArgs, "kind">
66): Promise<void> {
67 return track({ ...args, kind: "clone" });
68}
69
70/**
71 * Look up `(owner, repo)` and record a traffic event. Safe to fire-and-forget
72 * from request handlers; never throws. Returns void.
73 */
74export async function trackByName(
75 owner: string,
76 repo: string,
77 kind: TrafficKind,
78 meta: Omit<TrackArgs, "kind" | "repositoryId"> = {}
79): Promise<void> {
80 try {
81 const [row] = await db
82 .select({ id: repositories.id })
83 .from(repositories)
84 .innerJoin(users, eq(repositories.ownerId, users.id))
85 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
86 .limit(1);
87 if (!row) return;
88 await track({ ...meta, kind, repositoryId: row.id });
89 } catch {
90 // swallow
91 }
92}
93
94export interface TrafficSummary {
95 totalViews: number;
96 totalClones: number;
97 uniqueVisitorsApprox: number;
98 daily: Array<{ day: string; views: number; clones: number }>;
99 topReferers: Array<{ referer: string; n: number }>;
100 topPaths: Array<{ path: string; n: number }>;
101}
102
103/**
104 * Build a 14-day traffic summary for a repo. Approximation for
105 * uniqueVisitorsApprox: distinct `ip_hash` over the window.
106 */
107export async function summarise(
108 repositoryId: string,
109 windowDays = 14
110): Promise<TrafficSummary> {
111 const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
112 const empty: TrafficSummary = {
113 totalViews: 0,
114 totalClones: 0,
115 uniqueVisitorsApprox: 0,
116 daily: [],
117 topReferers: [],
118 topPaths: [],
119 };
120
121 try {
122 const rows = await db
123 .select({
124 day: sql<string>`to_char(date_trunc('day', ${repoTrafficEvents.createdAt}), 'YYYY-MM-DD')`,
125 kind: repoTrafficEvents.kind,
126 n: sql<number>`count(*)::int`,
127 })
128 .from(repoTrafficEvents)
129 .where(
130 and(
131 eq(repoTrafficEvents.repositoryId, repositoryId),
132 gte(repoTrafficEvents.createdAt, since)
133 )
134 )
135 .groupBy(
136 sql`date_trunc('day', ${repoTrafficEvents.createdAt})`,
137 repoTrafficEvents.kind
138 );
139
140 const dayMap = new Map<string, { views: number; clones: number }>();
141 let totalViews = 0;
142 let totalClones = 0;
143 for (const r of rows) {
144 const day = r.day;
145 const bucket = dayMap.get(day) || { views: 0, clones: 0 };
146 if (r.kind === "view" || r.kind === "ui") {
147 bucket.views += Number(r.n);
148 totalViews += Number(r.n);
149 } else if (r.kind === "clone") {
150 bucket.clones += Number(r.n);
151 totalClones += Number(r.n);
152 }
153 dayMap.set(day, bucket);
154 }
155
156 const daily = Array.from(dayMap.entries())
157 .map(([day, v]) => ({ day, views: v.views, clones: v.clones }))
158 .sort((a, b) => a.day.localeCompare(b.day));
159
160 const [uv] = await db
161 .select({
162 n: sql<number>`count(distinct ${repoTrafficEvents.ipHash})::int`,
163 })
164 .from(repoTrafficEvents)
165 .where(
166 and(
167 eq(repoTrafficEvents.repositoryId, repositoryId),
168 gte(repoTrafficEvents.createdAt, since)
169 )
170 );
171
172 const refRows = await db
173 .select({
174 referer: repoTrafficEvents.referer,
175 n: sql<number>`count(*)::int`,
176 })
177 .from(repoTrafficEvents)
178 .where(
179 and(
180 eq(repoTrafficEvents.repositoryId, repositoryId),
181 gte(repoTrafficEvents.createdAt, since),
182 sql`${repoTrafficEvents.referer} IS NOT NULL AND ${repoTrafficEvents.referer} <> ''`
183 )
184 )
185 .groupBy(repoTrafficEvents.referer)
186 .orderBy(sql`count(*) desc`)
187 .limit(8);
188
189 const pathRows = await db
190 .select({
191 path: repoTrafficEvents.path,
192 n: sql<number>`count(*)::int`,
193 })
194 .from(repoTrafficEvents)
195 .where(
196 and(
197 eq(repoTrafficEvents.repositoryId, repositoryId),
198 gte(repoTrafficEvents.createdAt, since),
199 sql`${repoTrafficEvents.path} IS NOT NULL`
200 )
201 )
202 .groupBy(repoTrafficEvents.path)
203 .orderBy(sql`count(*) desc`)
204 .limit(8);
205
206 return {
207 totalViews,
208 totalClones,
209 uniqueVisitorsApprox: Number(uv?.n || 0),
210 daily,
211 topReferers: refRows
212 .filter((r) => !!r.referer)
213 .map((r) => ({ referer: r.referer as string, n: Number(r.n) })),
214 topPaths: pathRows
215 .filter((r) => !!r.path)
216 .map((r) => ({ path: r.path as string, n: Number(r.n) })),
217 };
218 } catch {
219 return empty;
220 }
221}
222
223/**
224 * Pure helper for unit tests — turn a list of events into the same daily
225 * bucket structure as `summarise` without needing a DB.
226 */
227export function bucketDaily(
228 events: Array<{ createdAt: Date | string; kind: string }>
229): Array<{ day: string; views: number; clones: number }> {
230 const dayMap = new Map<string, { views: number; clones: number }>();
231 for (const e of events) {
232 const t = typeof e.createdAt === "string" ? new Date(e.createdAt) : e.createdAt;
233 const day = t.toISOString().slice(0, 10);
234 const bucket = dayMap.get(day) || { views: 0, clones: 0 };
235 if (e.kind === "view" || e.kind === "ui") bucket.views++;
236 else if (e.kind === "clone") bucket.clones++;
237 dayMap.set(day, bucket);
238 }
239 return Array.from(dayMap.entries())
240 .map(([day, v]) => ({ day, ...v }))
241 .sort((a, b) => a.day.localeCompare(b.day));
242}
Addedsrc/lib/unread.ts+21−0View fileUnifiedSplit
1/**
2 * Helper to fetch the unread notification count for a user.
3 * Extracted so any handler can pass it to <Layout> without importing the route file.
4 * Errors are swallowed — the nav must never break because of a counter.
5 */
6
7import { and, eq, isNull, sql } from "drizzle-orm";
8import { db } from "../db";
9import { notifications } from "../db/schema";
10
11export async function getUnreadCount(userId: string): Promise<number> {
12 try {
13 const [row] = await db
14 .select({ c: sql<number>`count(*)::int` })
15 .from(notifications)
16 .where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));
17 return row?.c ?? 0;
18 } catch {
19 return 0;
20 }
21}
Addedsrc/lib/webauthn.ts+230−0View fileUnifiedSplit
1/**
2 * WebAuthn / passkey helpers (Block B5).
3 *
4 * Thin wrapper over @simplewebauthn/server that:
5 * - reads RP config from `src/lib/config.ts`
6 * - persists short-lived challenges in `webauthn_challenges`
7 * - converts between base64url (used in the browser) and bytes as needed
8 */
9
10import { and, eq, lt } from "drizzle-orm";
11import {
12 generateRegistrationOptions,
13 verifyRegistrationResponse,
14 generateAuthenticationOptions,
15 verifyAuthenticationResponse,
16} from "@simplewebauthn/server";
17import type {
18 RegistrationResponseJSON,
19 AuthenticationResponseJSON,
20} from "@simplewebauthn/server";
21import { db } from "../db";
22import { webauthnChallenges, userPasskeys } from "../db/schema";
23import { config } from "./config";
24
25const CHALLENGE_TTL_MS = 5 * 60 * 1000;
26
27function newSessionKey(): string {
28 const bytes = crypto.getRandomValues(new Uint8Array(24));
29 return Array.from(bytes)
30 .map((b) => b.toString(16).padStart(2, "0"))
31 .join("");
32}
33
34async function gcExpiredChallenges(): Promise<void> {
35 try {
36 await db
37 .delete(webauthnChallenges)
38 .where(lt(webauthnChallenges.expiresAt, new Date()));
39 } catch {
40 /* best-effort GC */
41 }
42}
43
44export async function startRegistration(opts: {
45 userId: string;
46 userName: string;
47 userDisplayName?: string;
48 excludeCredentialIds?: string[];
49}) {
50 await gcExpiredChallenges();
51 const options = await generateRegistrationOptions({
52 rpName: config.webauthnRpName,
53 rpID: config.webauthnRpId,
54 userName: opts.userName,
55 userDisplayName: opts.userDisplayName || opts.userName,
56 userID: new TextEncoder().encode(opts.userId),
57 attestationType: "none",
58 excludeCredentials: (opts.excludeCredentialIds || []).map((id) => ({
59 id,
60 })),
61 authenticatorSelection: {
62 residentKey: "preferred",
63 userVerification: "preferred",
64 },
65 });
66
67 const sessionKey = newSessionKey();
68 await db.insert(webauthnChallenges).values({
69 userId: opts.userId,
70 sessionKey,
71 challenge: options.challenge,
72 kind: "register",
73 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
74 });
75
76 return { options, sessionKey };
77}
78
79export async function finishRegistration(opts: {
80 sessionKey: string;
81 response: RegistrationResponseJSON;
82}): Promise<
83 | { ok: true; credentialId: string; publicKey: string; counter: number }
84 | { ok: false; error: string }
85> {
86 try {
87 const [chal] = await db
88 .select()
89 .from(webauthnChallenges)
90 .where(
91 and(
92 eq(webauthnChallenges.sessionKey, opts.sessionKey),
93 eq(webauthnChallenges.kind, "register")
94 )
95 )
96 .limit(1);
97 if (!chal) return { ok: false, error: "Challenge not found or expired" };
98 if (new Date(chal.expiresAt) < new Date()) {
99 return { ok: false, error: "Challenge expired" };
100 }
101
102 const verification = await verifyRegistrationResponse({
103 response: opts.response,
104 expectedChallenge: chal.challenge,
105 expectedOrigin: config.webauthnOrigin,
106 expectedRPID: config.webauthnRpId,
107 requireUserVerification: false,
108 });
109
110 // One-shot: remove the challenge whether verification passed or not.
111 await db
112 .delete(webauthnChallenges)
113 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
114
115 if (!verification.verified || !verification.registrationInfo) {
116 return { ok: false, error: "Registration did not verify" };
117 }
118
119 const { credential } = verification.registrationInfo;
120 return {
121 ok: true,
122 credentialId: credential.id,
123 publicKey: Buffer.from(credential.publicKey).toString("base64url"),
124 counter: credential.counter,
125 };
126 } catch (err) {
127 console.error("[webauthn] finishRegistration:", err);
128 return { ok: false, error: "Verification failed" };
129 }
130}
131
132export async function startAuthentication(opts: {
133 userId?: string;
134 allowCredentialIds?: string[];
135}) {
136 await gcExpiredChallenges();
137 const options = await generateAuthenticationOptions({
138 rpID: config.webauthnRpId,
139 allowCredentials: (opts.allowCredentialIds || []).map((id) => ({ id })),
140 userVerification: "preferred",
141 });
142
143 const sessionKey = newSessionKey();
144 await db.insert(webauthnChallenges).values({
145 userId: opts.userId || null,
146 sessionKey,
147 challenge: options.challenge,
148 kind: "authenticate",
149 expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
150 });
151
152 return { options, sessionKey };
153}
154
155export async function finishAuthentication(opts: {
156 sessionKey: string;
157 response: AuthenticationResponseJSON;
158}): Promise<
159 | {
160 ok: true;
161 userId: string;
162 credentialId: string;
163 newCounter: number;
164 }
165 | { ok: false; error: string }
166> {
167 try {
168 const [chal] = await db
169 .select()
170 .from(webauthnChallenges)
171 .where(
172 and(
173 eq(webauthnChallenges.sessionKey, opts.sessionKey),
174 eq(webauthnChallenges.kind, "authenticate")
175 )
176 )
177 .limit(1);
178 if (!chal) return { ok: false, error: "Challenge not found or expired" };
179 if (new Date(chal.expiresAt) < new Date()) {
180 return { ok: false, error: "Challenge expired" };
181 }
182
183 const credentialId = opts.response.id;
184 const [pk] = await db
185 .select()
186 .from(userPasskeys)
187 .where(eq(userPasskeys.credentialId, credentialId))
188 .limit(1);
189 if (!pk) {
190 return { ok: false, error: "Unknown credential" };
191 }
192
193 const verification = await verifyAuthenticationResponse({
194 response: opts.response,
195 expectedChallenge: chal.challenge,
196 expectedOrigin: config.webauthnOrigin,
197 expectedRPID: config.webauthnRpId,
198 credential: {
199 id: pk.credentialId,
200 publicKey: new Uint8Array(Buffer.from(pk.publicKey, "base64url")),
201 counter: pk.counter,
202 },
203 requireUserVerification: false,
204 });
205
206 await db
207 .delete(webauthnChallenges)
208 .where(eq(webauthnChallenges.sessionKey, opts.sessionKey));
209
210 if (!verification.verified) {
211 return { ok: false, error: "Authentication did not verify" };
212 }
213
214 const newCounter = verification.authenticationInfo.newCounter;
215 await db
216 .update(userPasskeys)
217 .set({ counter: newCounter, lastUsedAt: new Date() })
218 .where(eq(userPasskeys.id, pk.id));
219
220 return {
221 ok: true,
222 userId: pk.userId,
223 credentialId: pk.credentialId,
224 newCounter,
225 };
226 } catch (err) {
227 console.error("[webauthn] finishAuthentication:", err);
228 return { ok: false, error: "Verification failed" };
229 }
230}
Addedsrc/lib/workflow-parser.ts+598−0View fileUnifiedSplit
1/**
2 * Minimal GitHub-Actions-compatible workflow YAML parser.
3 *
4 * Block C1. Pure function — no DB, no file I/O, no external calls.
5 * Input: YAML text. Output: normalised workflow object, or error.
6 *
7 * Supported subset:
8 * name: <scalar>
9 * on: <scalar> | [list] | { mapping } (mapping is flattened to its top-level keys)
10 * jobs:
11 * <job-key>:
12 * runs-on: <scalar> (default "default")
13 * steps:
14 * - run: <scalar> (auto-name "Run command")
15 * - name: <scalar>
16 * run: <scalar>
17 *
18 * Quirks handled:
19 * - `#` comments (end-of-line and full-line)
20 * - Block literal strings (`|` and `>`) with indentation-stripped bodies
21 * - Inline flow arrays: [a, b, "c, still c"]
22 * - Inline flow mappings: { push: { branches: [main] } }
23 * - Single- and double-quoted scalars
24 * - Extra fields on jobs/steps (env, uses, with, matrix, …) are accepted and ignored
25 * - Job key order is preserved (Map-based accumulation)
26 * - Never throws — bad input returns { ok: false, error }
27 */
28
29export type WorkflowStep = {
30 name: string;
31 run: string;
32};
33
34export type WorkflowJob = {
35 name: string;
36 runsOn: string;
37 steps: WorkflowStep[];
38};
39
40export type ParsedWorkflow = {
41 name: string;
42 on: string[];
43 jobs: WorkflowJob[];
44};
45
46export type ParseResult =
47 | { ok: true; workflow: ParsedWorkflow }
48 | { ok: false; error: string };
49
50// ---------------------------------------------------------------------------
51// Tokeniser: split into logical lines, strip comments, record indent.
52// ---------------------------------------------------------------------------
53
54type Line = {
55 indent: number;
56 text: string; // comment-stripped, right-trimmed
57 raw: string; // original (for block-literal body preservation)
58 lineNo: number; // 1-based
59};
60
61function lex(source: string): Line[] {
62 const out: Line[] = [];
63 const rawLines = source.replace(/\r\n?/g, "\n").split("\n");
64 for (let i = 0; i < rawLines.length; i++) {
65 const raw = rawLines[i] ?? "";
66 // Compute indent (expand tabs as 1 space — we disallow tabs for YAML indent anyway)
67 let indent = 0;
68 while (indent < raw.length && (raw[indent] === " " || raw[indent] === "\t")) {
69 indent++;
70 }
71 const body = raw.slice(indent);
72 // Skip pure blank / pure comment lines
73 if (body.length === 0 || body.startsWith("#")) continue;
74 // Strip trailing comment (respecting quotes)
75 const stripped = stripTrailingComment(body).replace(/\s+$/, "");
76 if (stripped.length === 0) continue;
77 out.push({ indent, text: stripped, raw, lineNo: i + 1 });
78 }
79 return out;
80}
81
82function stripTrailingComment(s: string): string {
83 let inSingle = false;
84 let inDouble = false;
85 for (let i = 0; i < s.length; i++) {
86 const c = s[i];
87 if (c === "\\" && inDouble) {
88 i++;
89 continue;
90 }
91 if (c === "'" && !inDouble) inSingle = !inSingle;
92 else if (c === '"' && !inSingle) inDouble = !inDouble;
93 else if (c === "#" && !inSingle && !inDouble) {
94 // Must be preceded by whitespace (or start of line) to count as a comment
95 if (i === 0 || /\s/.test(s[i - 1]!)) return s.slice(0, i);
96 }
97 }
98 return s;
99}
100
101// ---------------------------------------------------------------------------
102// Scalar + flow-value parsing.
103// ---------------------------------------------------------------------------
104
105function unquote(s: string): string {
106 s = s.trim();
107 if (s.length >= 2) {
108 if (s.startsWith('"') && s.endsWith('"')) {
109 return s
110 .slice(1, -1)
111 .replace(/\\n/g, "\n")
112 .replace(/\\t/g, "\t")
113 .replace(/\\"/g, '"')
114 .replace(/\\\\/g, "\\");
115 }
116 if (s.startsWith("'") && s.endsWith("'")) {
117 return s.slice(1, -1).replace(/''/g, "'");
118 }
119 }
120 return s;
121}
122
123/**
124 * Parse a flow-style value starting at `s[i]`. Returns the parsed JS value
125 * and the index just after it. Supports nested [..] and {..}, quoted strings,
126 * plain scalars, and comma separators.
127 */
128function parseFlow(s: string, i: number): { value: unknown; next: number } {
129 i = skipWs(s, i);
130 if (i >= s.length) return { value: "", next: i };
131 const c = s[i];
132 if (c === "[") return parseFlowSeq(s, i);
133 if (c === "{") return parseFlowMap(s, i);
134 if (c === '"' || c === "'") {
135 const end = findQuoteEnd(s, i);
136 return { value: unquote(s.slice(i, end + 1)), next: end + 1 };
137 }
138 // plain scalar — read until , ] } or end
139 let j = i;
140 while (j < s.length && s[j] !== "," && s[j] !== "]" && s[j] !== "}") j++;
141 return { value: unquote(s.slice(i, j).trim()), next: j };
142}
143
144function parseFlowSeq(s: string, i: number): { value: unknown[]; next: number } {
145 // assumes s[i] === '['
146 const out: unknown[] = [];
147 i++;
148 i = skipWs(s, i);
149 if (s[i] === "]") return { value: out, next: i + 1 };
150 while (i < s.length) {
151 const { value, next } = parseFlow(s, i);
152 out.push(value);
153 i = skipWs(s, next);
154 if (s[i] === ",") {
155 i++;
156 i = skipWs(s, i);
157 continue;
158 }
159 if (s[i] === "]") return { value: out, next: i + 1 };
160 break; // malformed — bail out gracefully
161 }
162 return { value: out, next: i };
163}
164
165function parseFlowMap(
166 s: string,
167 i: number,
168): { value: Record<string, unknown>; next: number } {
169 // assumes s[i] === '{'
170 const out: Record<string, unknown> = {};
171 i++;
172 i = skipWs(s, i);
173 if (s[i] === "}") return { value: out, next: i + 1 };
174 while (i < s.length) {
175 // key
176 let keyEnd = i;
177 if (s[i] === '"' || s[i] === "'") keyEnd = findQuoteEnd(s, i) + 1;
178 else {
179 while (keyEnd < s.length && s[keyEnd] !== ":" && s[keyEnd] !== ",") keyEnd++;
180 }
181 const key = unquote(s.slice(i, keyEnd).trim());
182 i = skipWs(s, keyEnd);
183 if (s[i] === ":") {
184 i++;
185 i = skipWs(s, i);
186 const { value, next } = parseFlow(s, i);
187 out[key] = value;
188 i = skipWs(s, next);
189 } else {
190 // bare key with no value — treat as true (rare in our subset)
191 out[key] = true;
192 }
193 if (s[i] === ",") {
194 i++;
195 i = skipWs(s, i);
196 continue;
197 }
198 if (s[i] === "}") return { value: out, next: i + 1 };
199 break;
200 }
201 return { value: out, next: i };
202}
203
204function findQuoteEnd(s: string, i: number): number {
205 const q = s[i];
206 let j = i + 1;
207 while (j < s.length) {
208 if (q === '"' && s[j] === "\\") {
209 j += 2;
210 continue;
211 }
212 if (s[j] === q) {
213 if (q === "'" && s[j + 1] === "'") {
214 j += 2;
215 continue;
216 }
217 return j;
218 }
219 j++;
220 }
221 return s.length - 1;
222}
223
224function skipWs(s: string, i: number): number {
225 while (i < s.length && (s[i] === " " || s[i] === "\t")) i++;
226 return i;
227}
228
229// ---------------------------------------------------------------------------
230// Block-scalar (| and >) assembly: consumes continuation lines indented deeper
231// than `parentIndent` and joins them per the YAML block-scalar rules.
232// ---------------------------------------------------------------------------
233
234function readBlockScalar(
235 lines: Line[],
236 idx: number,
237 parentIndent: number,
238 style: "literal" | "folded",
239): { text: string; next: number } {
240 const parts: string[] = [];
241 let blockIndent = -1;
242 let i = idx;
243 while (i < lines.length) {
244 const line = lines[i]!;
245 if (line.indent <= parentIndent) break;
246 if (blockIndent < 0) blockIndent = line.indent;
247 // Use the raw line to preserve inner whitespace but strip the common indent.
248 const raw = line.raw;
249 const stripped = raw.slice(Math.min(blockIndent, raw.length));
250 parts.push(stripped);
251 i++;
252 }
253 const text =
254 style === "literal"
255 ? parts.join("\n")
256 : parts
257 .map((p) => p.trim())
258 .filter((p) => p.length > 0)
259 .join(" ");
260 return { text, next: i };
261}
262
263// ---------------------------------------------------------------------------
264// Block-style YAML parser. Recursive-descent over indented Line[] array.
265// Returns a plain JS value (object / array / string).
266// ---------------------------------------------------------------------------
267
268type Cursor = { i: number };
269
270function parseBlock(lines: Line[], cur: Cursor, indent: number): unknown {
271 if (cur.i >= lines.length) return null;
272 const first = lines[cur.i]!;
273 if (first.indent < indent) return null;
274 if (first.text.startsWith("- ") || first.text === "-") {
275 return parseBlockSeq(lines, cur, first.indent);
276 }
277 return parseBlockMap(lines, cur, first.indent);
278}
279
280function parseBlockMap(
281 lines: Line[],
282 cur: Cursor,
283 indent: number,
284): Record<string, unknown> {
285 const out: Record<string, unknown> = {};
286 while (cur.i < lines.length) {
287 const line = lines[cur.i]!;
288 if (line.indent < indent) break;
289 if (line.indent > indent) {
290 // Shouldn't happen in well-formed input — skip defensively.
291 cur.i++;
292 continue;
293 }
294 const { text } = line;
295 // Must be "key: …" at this indent.
296 const colon = findMapColon(text);
297 if (colon < 0) break;
298 const key = unquote(text.slice(0, colon).trim());
299 const rest = text.slice(colon + 1).trim();
300 cur.i++;
301 if (rest.length === 0) {
302 // value is on following deeper-indented lines (or nothing -> null)
303 const child = lines[cur.i];
304 if (!child || child.indent <= indent) {
305 out[key] = null;
306 } else {
307 out[key] = parseBlock(lines, cur, child.indent);
308 }
309 } else if (rest === "|" || rest === "|-" || rest === "|+") {
310 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "literal");
311 out[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
312 cur.i = next;
313 } else if (rest === ">" || rest === ">-" || rest === ">+") {
314 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "folded");
315 out[key] = bs;
316 cur.i = next;
317 } else if (rest.startsWith("[") || rest.startsWith("{")) {
318 out[key] = parseFlow(rest, 0).value;
319 } else {
320 out[key] = unquote(rest);
321 }
322 }
323 return out;
324}
325
326function parseBlockSeq(lines: Line[], cur: Cursor, indent: number): unknown[] {
327 const out: unknown[] = [];
328 while (cur.i < lines.length) {
329 const line = lines[cur.i]!;
330 if (line.indent < indent) break;
331 if (line.indent > indent) {
332 cur.i++;
333 continue;
334 }
335 if (!(line.text.startsWith("- ") || line.text === "-")) break;
336 const afterDash = line.text === "-" ? "" : line.text.slice(2);
337 cur.i++;
338
339 if (afterDash.length === 0) {
340 // element is on following deeper-indented lines
341 const child = lines[cur.i];
342 if (!child || child.indent <= indent) {
343 out.push(null);
344 } else {
345 out.push(parseBlock(lines, cur, child.indent));
346 }
347 continue;
348 }
349
350 // Could be "- key: value" (start of an inline map element) or "- scalar"
351 const colon = findMapColon(afterDash);
352 if (colon >= 0) {
353 // Build a virtual map: first pair from `afterDash`, further pairs from
354 // subsequent lines indented at (indent + 2 spaces past the dash).
355 const key = unquote(afterDash.slice(0, colon).trim());
356 const rest = afterDash.slice(colon + 1).trim();
357 const elem: Record<string, unknown> = {};
358 const childIndent = indent + 2;
359 if (rest.length === 0) {
360 const child = lines[cur.i];
361 if (child && child.indent > childIndent) {
362 elem[key] = parseBlock(lines, cur, child.indent);
363 } else {
364 elem[key] = null;
365 }
366 } else if (rest === "|" || rest === "|-" || rest === "|+") {
367 const { text: bs, next } = readBlockScalar(
368 lines,
369 cur.i,
370 childIndent - 1,
371 "literal",
372 );
373 elem[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
374 cur.i = next;
375 } else if (rest === ">" || rest === ">-" || rest === ">+") {
376 const { text: bs, next } = readBlockScalar(
377 lines,
378 cur.i,
379 childIndent - 1,
380 "folded",
381 );
382 elem[key] = bs;
383 cur.i = next;
384 } else if (rest.startsWith("[") || rest.startsWith("{")) {
385 elem[key] = parseFlow(rest, 0).value;
386 } else {
387 elem[key] = unquote(rest);
388 }
389 // Sibling map keys for the same element: same indent as childIndent.
390 while (cur.i < lines.length) {
391 const sib = lines[cur.i]!;
392 if (sib.indent < childIndent) break;
393 if (sib.indent > childIndent) {
394 cur.i++;
395 continue;
396 }
397 if (sib.text.startsWith("- ") || sib.text === "-") break;
398 const sc = findMapColon(sib.text);
399 if (sc < 0) break;
400 const sk = unquote(sib.text.slice(0, sc).trim());
401 const sr = sib.text.slice(sc + 1).trim();
402 cur.i++;
403 if (sr.length === 0) {
404 const child = lines[cur.i];
405 if (child && child.indent > childIndent) {
406 elem[sk] = parseBlock(lines, cur, child.indent);
407 } else {
408 elem[sk] = null;
409 }
410 } else if (sr === "|" || sr === "|-" || sr === "|+") {
411 const { text: bs, next } = readBlockScalar(
412 lines,
413 cur.i,
414 childIndent - 1,
415 "literal",
416 );
417 elem[sk] = sr === "|-" ? bs.replace(/\n+$/, "") : bs;
418 cur.i = next;
419 } else if (sr === ">" || sr === ">-" || sr === ">+") {
420 const { text: bs, next } = readBlockScalar(
421 lines,
422 cur.i,
423 childIndent - 1,
424 "folded",
425 );
426 elem[sk] = bs;
427 cur.i = next;
428 } else if (sr.startsWith("[") || sr.startsWith("{")) {
429 elem[sk] = parseFlow(sr, 0).value;
430 } else {
431 elem[sk] = unquote(sr);
432 }
433 }
434 out.push(elem);
435 } else {
436 // plain scalar element
437 if (afterDash.startsWith("[") || afterDash.startsWith("{")) {
438 out.push(parseFlow(afterDash, 0).value);
439 } else {
440 out.push(unquote(afterDash));
441 }
442 }
443 }
444 return out;
445}
446
447/** Locate the ':' that ends a mapping key, skipping quoted sections. */
448function findMapColon(text: string): number {
449 let inSingle = false;
450 let inDouble = false;
451 for (let i = 0; i < text.length; i++) {
452 const c = text[i];
453 if (c === "\\" && inDouble) {
454 i++;
455 continue;
456 }
457 if (c === "'" && !inDouble) inSingle = !inSingle;
458 else if (c === '"' && !inSingle) inDouble = !inDouble;
459 else if (c === ":" && !inSingle && !inDouble) {
460 // Must be followed by space, EOL, or be at the very end.
461 if (i + 1 >= text.length || text[i + 1] === " " || text[i + 1] === "\t") {
462 return i;
463 }
464 }
465 }
466 return -1;
467}
468
469// ---------------------------------------------------------------------------
470// Normalisation: raw YAML value → ParsedWorkflow
471// ---------------------------------------------------------------------------
472
473function asString(v: unknown): string | null {
474 if (typeof v === "string") return v;
475 if (typeof v === "number" || typeof v === "boolean") return String(v);
476 return null;
477}
478
479function normaliseOn(v: unknown): string[] | null {
480 if (v == null) return null;
481 if (typeof v === "string") {
482 const s = v.trim();
483 return s.length ? [s] : null;
484 }
485 if (Array.isArray(v)) {
486 const out: string[] = [];
487 for (const item of v) {
488 const s = asString(item);
489 if (s && s.trim().length) out.push(s.trim());
490 }
491 return out.length ? out : null;
492 }
493 if (typeof v === "object") {
494 const keys = Object.keys(v as Record<string, unknown>);
495 return keys.length ? keys : null;
496 }
497 return null;
498}
499
500function normaliseStep(
501 raw: unknown,
502 jobName: string,
503): { ok: true; step: WorkflowStep } | { ok: false; error: string } {
504 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
505 return { ok: false, error: `step in job '${jobName}' must be a mapping` };
506 }
507 const r = raw as Record<string, unknown>;
508 const run = asString(r.run);
509 if (!run || !run.trim().length) {
510 return { ok: false, error: `step in job '${jobName}' missing 'run' command` };
511 }
512 const nameVal = asString(r.name);
513 const name = nameVal && nameVal.trim().length ? nameVal.trim() : "Run command";
514 return { ok: true, step: { name, run } };
515}
516
517function normaliseJob(
518 name: string,
519 raw: unknown,
520): { ok: true; job: WorkflowJob } | { ok: false; error: string } {
521 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
522 return { ok: false, error: `job '${name}' is not a mapping` };
523 }
524 const r = raw as Record<string, unknown>;
525 const runsOnRaw = asString(r["runs-on"]);
526 const runsOn = runsOnRaw && runsOnRaw.trim().length ? runsOnRaw.trim() : "default";
527 const stepsRaw = r.steps;
528 if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
529 return { ok: false, error: `job '${name}' has no steps` };
530 }
531 const steps: WorkflowStep[] = [];
532 for (const s of stepsRaw) {
533 const res = normaliseStep(s, name);
534 if (!res.ok) return res;
535 steps.push(res.step);
536 }
537 return { ok: true, job: { name, runsOn, steps } };
538}
539
540// ---------------------------------------------------------------------------
541// Public entry point.
542// ---------------------------------------------------------------------------
543
544export function parseWorkflow(yaml: string): ParseResult {
545 if (typeof yaml !== "string") {
546 return { ok: false, error: "workflow input must be a string" };
547 }
548 let root: unknown;
549 try {
550 const lines = lex(yaml);
551 if (lines.length === 0) {
552 return { ok: false, error: "workflow is empty" };
553 }
554 const cur: Cursor = { i: 0 };
555 root = parseBlock(lines, cur, lines[0]!.indent);
556 } catch (err) {
557 return {
558 ok: false,
559 error: `failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`,
560 };
561 }
562
563 if (!root || typeof root !== "object" || Array.isArray(root)) {
564 return { ok: false, error: "workflow root must be a mapping" };
565 }
566 const doc = root as Record<string, unknown>;
567
568 const nameRaw = asString(doc.name);
569 const name = nameRaw && nameRaw.trim().length ? nameRaw.trim() : "(unnamed)";
570
571 if (!("on" in doc) || doc.on == null) {
572 return { ok: false, error: "workflow missing 'on' trigger" };
573 }
574 const on = normaliseOn(doc.on);
575 if (!on || on.length === 0) {
576 return { ok: false, error: "workflow missing 'on' trigger" };
577 }
578
579 const jobsRaw = doc.jobs;
580 if (
581 !jobsRaw ||
582 typeof jobsRaw !== "object" ||
583 Array.isArray(jobsRaw) ||
584 Object.keys(jobsRaw as Record<string, unknown>).length === 0
585 ) {
586 return { ok: false, error: "workflow has no jobs" };
587 }
588
589 const jobs: WorkflowJob[] = [];
590 // Object.keys preserves insertion order for string keys in modern engines.
591 for (const key of Object.keys(jobsRaw as Record<string, unknown>)) {
592 const res = normaliseJob(key, (jobsRaw as Record<string, unknown>)[key]);
593 if (!res.ok) return res;
594 jobs.push(res.job);
595 }
596
597 return { ok: true, workflow: { name, on, jobs } };
598}
Addedsrc/lib/workflow-runner.ts+732−0View fileUnifiedSplit
1/**
2 * Workflow runner (Block C1) — executes queued `workflow_runs` rows by
3 * cloning the repo at the target commit into a tmpdir and running each
4 * job's steps as bash subprocesses.
5 *
6 * Philosophy (mirrors post-receive.ts): never crash the caller. Every DB
7 * call is wrapped in try/catch. All step output is size-capped so a runaway
8 * process can't blow up Postgres rows. Logs are stored inline on the job
9 * row for v1 — no streaming, no object storage. Step timeouts are enforced
10 * so workers never wedge.
11 *
12 * Public surface:
13 * - executeRun(runId) — run a specific queued run to completion
14 * - drainOneRun() — pick the oldest queued run and execute it
15 * - enqueueRun(opts) — insert a new run at the tail of the queue
16 * - startWorker({ interval }) — background poll loop (returns stop fn)
17 */
18import { and, asc, eq, sql } from "drizzle-orm";
19import { mkdtemp, rm } from "fs/promises";
20import { tmpdir } from "os";
21import { join } from "path";
22import { config } from "./config";
23import { db } from "../db";
24import {
25 repositories,
26 workflowJobs,
27 workflowRuns,
28 workflows,
29} from "../db/schema";
30
31// ---------------------------------------------------------------------------
32// Tunables
33// ---------------------------------------------------------------------------
34
35/** Per-step subprocess timeout. */
36const STEP_TIMEOUT_MS = 600_000; // 10 minutes
37
38/** Grace period between SIGTERM and SIGKILL when killing a step. */
39const KILL_GRACE_MS = 5_000;
40
41/** Cap on full `workflow_jobs.logs` field. */
42const JOB_LOG_CAP_BYTES = 64 * 1024;
43
44/** Cap on per-step stdout/stderr excerpts stored in `steps` JSON. */
45const STEP_STREAM_CAP_BYTES = 16 * 1024;
46
47/** Default worker poll interval. */
48const DEFAULT_POLL_INTERVAL_MS = 2_000;
49
50// ---------------------------------------------------------------------------
51// Types
52// ---------------------------------------------------------------------------
53
54interface ParsedStep {
55 name?: string;
56 run?: string;
57 // `uses` / `with` etc. tolerated but ignored in v1.
58 [key: string]: unknown;
59}
60
61interface ParsedJob {
62 name?: string;
63 "runs-on"?: string;
64 runsOn?: string;
65 steps?: ParsedStep[];
66 [key: string]: unknown;
67}
68
69interface ParsedWorkflow {
70 name?: string;
71 on?: unknown;
72 jobs?: Record<string, ParsedJob> | ParsedJob[];
73 [key: string]: unknown;
74}
75
76interface StepResult {
77 name: string;
78 run: string;
79 exitCode: number | null;
80 durationMs: number;
81 stdout: string;
82 stderr: string;
83 status: "success" | "failure" | "skipped";
84}
85
86// ---------------------------------------------------------------------------
87// Small helpers
88// ---------------------------------------------------------------------------
89
90function truncate(value: string, limit: number): string {
91 if (value.length <= limit) return value;
92 return value.slice(0, limit) + "\n[... truncated ...]";
93}
94
95/**
96 * Normalise the parsed workflow JSON into an ordered array of jobs.
97 * Accepts either the object form (`jobs: { build: {...} }`) or an array.
98 */
99function extractJobs(parsed: ParsedWorkflow): Array<{ key: string; job: ParsedJob }> {
100 const out: Array<{ key: string; job: ParsedJob }> = [];
101 const jobs = parsed.jobs;
102 if (!jobs) return out;
103 if (Array.isArray(jobs)) {
104 jobs.forEach((job, i) => {
105 if (job && typeof job === "object") {
106 out.push({ key: String(job.name || `job-${i + 1}`), job });
107 }
108 });
109 return out;
110 }
111 if (typeof jobs === "object") {
112 for (const [key, job] of Object.entries(jobs)) {
113 if (job && typeof job === "object") {
114 out.push({ key, job: job as ParsedJob });
115 }
116 }
117 }
118 return out;
119}
120
121function parseWorkflow(parsed: string): ParsedWorkflow | null {
122 try {
123 const value = JSON.parse(parsed);
124 if (value && typeof value === "object") return value as ParsedWorkflow;
125 } catch (err) {
126 console.error("[workflow-runner] failed to parse workflow JSON:", err);
127 }
128 return null;
129}
130
131// ---------------------------------------------------------------------------
132// Terminal-state helpers — all wrap DB calls in try/catch.
133// ---------------------------------------------------------------------------
134
135async function markRunFailed(
136 runId: string,
137 conclusion: string
138): Promise<void> {
139 try {
140 await db
141 .update(workflowRuns)
142 .set({
143 status: "failure",
144 conclusion,
145 finishedAt: new Date(),
146 })
147 .where(eq(workflowRuns.id, runId));
148 } catch (err) {
149 console.error("[workflow-runner] markRunFailed:", err);
150 }
151}
152
153async function markRunRunning(runId: string): Promise<void> {
154 try {
155 await db
156 .update(workflowRuns)
157 .set({
158 status: "running",
159 startedAt: new Date(),
160 })
161 .where(eq(workflowRuns.id, runId));
162 } catch (err) {
163 console.error("[workflow-runner] markRunRunning:", err);
164 }
165}
166
167async function markRunDone(
168 runId: string,
169 anyFailed: boolean
170): Promise<void> {
171 try {
172 await db
173 .update(workflowRuns)
174 .set({
175 status: anyFailed ? "failure" : "success",
176 conclusion: anyFailed ? "failure" : "success",
177 finishedAt: new Date(),
178 })
179 .where(eq(workflowRuns.id, runId));
180 } catch (err) {
181 console.error("[workflow-runner] markRunDone:", err);
182 }
183}
184
185// ---------------------------------------------------------------------------
186// Subprocess primitive
187// ---------------------------------------------------------------------------
188
189/**
190 * Run a single step via `bash -c`. Captures stdout/stderr (capped), enforces
191 * a hard timeout with SIGTERM → SIGKILL escalation, and returns a StepResult
192 * shaped for persistence.
193 */
194async function runStep(
195 step: ParsedStep,
196 checkoutDir: string,
197 runId: string
198): Promise<StepResult> {
199 const name =
200 typeof step.name === "string" && step.name.length > 0
201 ? step.name
202 : (typeof step.run === "string" ? step.run.split("\n")[0] : "") ||
203 "step";
204 const run = typeof step.run === "string" ? step.run : "";
205 const started = Date.now();
206
207 if (!run) {
208 // No `run:` — v1 treats this as skipped (we don't support `uses:` yet).
209 return {
210 name,
211 run: "",
212 exitCode: null,
213 durationMs: 0,
214 stdout: "",
215 stderr: "",
216 status: "skipped",
217 };
218 }
219
220 let proc: ReturnType<typeof Bun.spawn> | null = null;
221 let timedOut = false;
222 let killTimer: ReturnType<typeof setTimeout> | null = null;
223 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
224
225 try {
226 proc = Bun.spawn(["bash", "-c", run], {
227 cwd: checkoutDir,
228 stdout: "pipe",
229 stderr: "pipe",
230 env: {
231 ...process.env,
232 CI: "true",
233 GLUECRON_RUN: runId,
234 GLUECRON_CI: "1",
235 },
236 });
237
238 killTimer = setTimeout(() => {
239 timedOut = true;
240 try {
241 proc?.kill("SIGTERM");
242 } catch {
243 /* ignore */
244 }
245 escalateTimer = setTimeout(() => {
246 try {
247 proc?.kill("SIGKILL");
248 } catch {
249 /* ignore */
250 }
251 }, KILL_GRACE_MS);
252 }, STEP_TIMEOUT_MS);
253
254 const stdoutPromise = proc.stdout
255 ? new Response(proc.stdout as ReadableStream).text()
256 : Promise.resolve("");
257 const stderrPromise = proc.stderr
258 ? new Response(proc.stderr as ReadableStream).text()
259 : Promise.resolve("");
260
261 const [stdoutRaw, stderrRaw] = await Promise.all([
262 stdoutPromise.catch(() => ""),
263 stderrPromise.catch(() => ""),
264 ]);
265 const exitCode = await proc.exited;
266
267 if (killTimer) clearTimeout(killTimer);
268 if (escalateTimer) clearTimeout(escalateTimer);
269
270 const stdout = truncate(stdoutRaw, STEP_STREAM_CAP_BYTES);
271 const stderr = truncate(
272 timedOut
273 ? `${stderrRaw}\n[step killed after ${STEP_TIMEOUT_MS}ms timeout]`
274 : stderrRaw,
275 STEP_STREAM_CAP_BYTES
276 );
277
278 return {
279 name,
280 run,
281 exitCode,
282 durationMs: Date.now() - started,
283 stdout,
284 stderr,
285 status: exitCode === 0 && !timedOut ? "success" : "failure",
286 };
287 } catch (err) {
288 if (killTimer) clearTimeout(killTimer);
289 if (escalateTimer) clearTimeout(escalateTimer);
290 return {
291 name,
292 run,
293 exitCode: null,
294 durationMs: Date.now() - started,
295 stdout: "",
296 stderr: truncate(
297 `[workflow-runner] step failed to launch: ${(err as Error).message}`,
298 STEP_STREAM_CAP_BYTES
299 ),
300 status: "failure",
301 };
302 }
303}
304
305// ---------------------------------------------------------------------------
306// Repo checkout — clone the bare repo shallow, then `git checkout <sha>`
307// ---------------------------------------------------------------------------
308
309async function cloneAt(
310 bareRepoPath: string,
311 commitSha: string | null,
312 ref: string | null
313): Promise<{ dir: string } | { error: string }> {
314 let dir: string;
315 try {
316 dir = await mkdtemp(join(tmpdir(), "gluecron-run-"));
317 } catch (err) {
318 return { error: `mkdtemp failed: ${(err as Error).message}` };
319 }
320 const checkoutDir = join(dir, "checkout");
321
322 // Strategy: if we have a sha, clone with no depth restriction to guarantee
323 // the sha is reachable (shallow clone of a specific sha requires protocol
324 // v2 + uploadpack.allowReachableSHA1InWant on the server). For v1 we
325 // prefer correctness over size. Callers can switch to `--depth 1 --branch`
326 // once we wire config.
327 try {
328 const cloneProc = Bun.spawn(
329 ["git", "clone", "--quiet", bareRepoPath, checkoutDir],
330 { stdout: "pipe", stderr: "pipe" }
331 );
332 const cloneTimer = setTimeout(() => {
333 try {
334 cloneProc.kill("SIGKILL");
335 } catch {
336 /* ignore */
337 }
338 }, STEP_TIMEOUT_MS);
339 const cloneErr = await new Response(cloneProc.stderr as ReadableStream)
340 .text()
341 .catch(() => "");
342 const cloneExit = await cloneProc.exited;
343 clearTimeout(cloneTimer);
344 if (cloneExit !== 0) {
345 await rm(dir, { recursive: true, force: true }).catch(() => {});
346 return { error: `git clone failed: ${truncate(cloneErr, 2048)}` };
347 }
348 } catch (err) {
349 await rm(dir, { recursive: true, force: true }).catch(() => {});
350 return { error: `git clone spawn failed: ${(err as Error).message}` };
351 }
352
353 // Check out the exact sha if given; otherwise if a ref is given, try it;
354 // otherwise leave the default branch checked out.
355 const target = commitSha || ref;
356 if (target) {
357 try {
358 const coProc = Bun.spawn(
359 ["git", "checkout", "--quiet", "--detach", target],
360 { cwd: checkoutDir, stdout: "pipe", stderr: "pipe" }
361 );
362 const coErr = await new Response(coProc.stderr as ReadableStream)
363 .text()
364 .catch(() => "");
365 const coExit = await coProc.exited;
366 if (coExit !== 0) {
367 await rm(dir, { recursive: true, force: true }).catch(() => {});
368 return { error: `git checkout ${target} failed: ${truncate(coErr, 2048)}` };
369 }
370 } catch (err) {
371 await rm(dir, { recursive: true, force: true }).catch(() => {});
372 return { error: `git checkout spawn failed: ${(err as Error).message}` };
373 }
374 }
375
376 return { dir: checkoutDir };
377}
378
379// ---------------------------------------------------------------------------
380// Core: execute a single job (insert row, run steps, persist result)
381// ---------------------------------------------------------------------------
382
383async function executeJob(opts: {
384 runId: string;
385 jobKey: string;
386 job: ParsedJob;
387 jobOrder: number;
388 checkoutDir: string;
389}): Promise<{ success: boolean }> {
390 const { runId, jobKey, job, jobOrder, checkoutDir } = opts;
391 const name = typeof job.name === "string" && job.name ? job.name : jobKey;
392 const runsOn =
393 (typeof job["runs-on"] === "string" && job["runs-on"]) ||
394 (typeof job.runsOn === "string" && job.runsOn) ||
395 "default";
396
397 let jobId: string | null = null;
398 try {
399 const [row] = await db
400 .insert(workflowJobs)
401 .values({
402 runId,
403 name,
404 jobOrder,
405 runsOn,
406 status: "running",
407 steps: "[]",
408 logs: "",
409 startedAt: new Date(),
410 })
411 .returning();
412 jobId = row?.id || null;
413 } catch (err) {
414 console.error("[workflow-runner] insert job:", err);
415 // No job row = can't record results. Treat as failure so the run fails.
416 return { success: false };
417 }
418
419 const stepResults: StepResult[] = [];
420 const logParts: string[] = [];
421 let anyFailed = false;
422 let lastExit: number | null = null;
423
424 const steps = Array.isArray(job.steps) ? job.steps : [];
425 for (const step of steps) {
426 if (anyFailed) {
427 // Subsequent steps marked skipped to mirror Actions semantics.
428 stepResults.push({
429 name:
430 (typeof step.name === "string" && step.name) ||
431 (typeof step.run === "string"
432 ? step.run.split("\n")[0]
433 : "") ||
434 "step",
435 run: typeof step.run === "string" ? step.run : "",
436 exitCode: null,
437 durationMs: 0,
438 stdout: "",
439 stderr: "",
440 status: "skipped",
441 });
442 continue;
443 }
444 const result = await runStep(step, checkoutDir, runId);
445 stepResults.push(result);
446 logParts.push(
447 `==> ${result.name}\n$ ${result.run}\n${result.stdout}${
448 result.stderr ? "\n[stderr]\n" + result.stderr : ""
449 }\n[exit ${result.exitCode ?? "null"} in ${result.durationMs}ms]\n`
450 );
451 if (result.status === "failure") {
452 anyFailed = true;
453 lastExit = result.exitCode;
454 } else if (result.status === "success") {
455 lastExit = result.exitCode;
456 }
457 }
458
459 const combinedLogs = truncate(logParts.join("\n"), JOB_LOG_CAP_BYTES);
460 const status = anyFailed ? "failure" : "success";
461
462 if (jobId) {
463 try {
464 await db
465 .update(workflowJobs)
466 .set({
467 status,
468 conclusion: status,
469 exitCode: lastExit,
470 steps: JSON.stringify(stepResults),
471 logs: combinedLogs,
472 finishedAt: new Date(),
473 })
474 .where(eq(workflowJobs.id, jobId));
475 } catch (err) {
476 console.error("[workflow-runner] update job:", err);
477 }
478 }
479
480 return { success: !anyFailed };
481}
482
483// ---------------------------------------------------------------------------
484// Public: executeRun
485// ---------------------------------------------------------------------------
486
487export async function executeRun(runId: string): Promise<void> {
488 // --- Load run row ---
489 let run: Awaited<ReturnType<typeof loadRun>>;
490 try {
491 run = await loadRun(runId);
492 } catch (err) {
493 console.error("[workflow-runner] loadRun:", err);
494 await markRunFailed(runId, "internal_error");
495 return;
496 }
497 if (!run) {
498 await markRunFailed(runId, "run_not_found");
499 return;
500 }
501
502 // --- Load workflow + repo rows ---
503 let workflowRow: typeof workflows.$inferSelect | null = null;
504 let repoRow: typeof repositories.$inferSelect | null = null;
505 try {
506 const [w] = await db
507 .select()
508 .from(workflows)
509 .where(eq(workflows.id, run.workflowId))
510 .limit(1);
511 workflowRow = w || null;
512 } catch (err) {
513 console.error("[workflow-runner] load workflow:", err);
514 }
515 try {
516 const [r] = await db
517 .select()
518 .from(repositories)
519 .where(eq(repositories.id, run.repositoryId))
520 .limit(1);
521 repoRow = r || null;
522 } catch (err) {
523 console.error("[workflow-runner] load repo:", err);
524 }
525
526 if (!workflowRow || !repoRow) {
527 await markRunFailed(runId, "workflow_not_found");
528 return;
529 }
530
531 // --- Parse workflow JSON ---
532 const parsed = parseWorkflow(workflowRow.parsed);
533 if (!parsed) {
534 await markRunFailed(runId, "workflow_parse_error");
535 return;
536 }
537 const jobs = extractJobs(parsed);
538 if (jobs.length === 0) {
539 await markRunFailed(runId, "no_jobs");
540 return;
541 }
542
543 // --- Transition to running ---
544 await markRunRunning(runId);
545
546 // --- Clone repo at target sha ---
547 const bareRepoPath = repoRow.diskPath;
548 const clone = await cloneAt(bareRepoPath, run.commitSha, run.ref);
549 if ("error" in clone) {
550 console.error(`[workflow-runner] clone failed for run ${runId}: ${clone.error}`);
551 await markRunFailed(runId, "checkout_failed");
552 return;
553 }
554 const checkoutDir = clone.dir;
555 const tmpRoot = join(checkoutDir, "..");
556
557 // --- Run jobs sequentially ---
558 let anyJobFailed = false;
559 try {
560 for (let i = 0; i < jobs.length; i++) {
561 const { key, job } = jobs[i]!;
562 const result = await executeJob({
563 runId,
564 jobKey: key,
565 job,
566 jobOrder: i,
567 checkoutDir,
568 });
569 if (!result.success) {
570 anyJobFailed = true;
571 // Per-v1 semantics: stop on first failure. Subsequent jobs aren't
572 // created, matching Actions' default needs-less pipeline.
573 break;
574 }
575 }
576 } catch (err) {
577 console.error("[workflow-runner] job loop:", err);
578 anyJobFailed = true;
579 } finally {
580 // Cleanup always runs.
581 await rm(tmpRoot, { recursive: true, force: true }).catch((err) => {
582 console.error("[workflow-runner] tmpdir cleanup:", err);
583 });
584 }
585
586 await markRunDone(runId, anyJobFailed);
587}
588
589async function loadRun(runId: string) {
590 const [row] = await db
591 .select()
592 .from(workflowRuns)
593 .where(eq(workflowRuns.id, runId))
594 .limit(1);
595 return row || null;
596}
597
598// ---------------------------------------------------------------------------
599// Public: drainOneRun — pick + execute the oldest queued row.
600// ---------------------------------------------------------------------------
601
602export async function drainOneRun(): Promise<boolean> {
603 let candidateId: string | null = null;
604 try {
605 const [row] = await db
606 .select({ id: workflowRuns.id })
607 .from(workflowRuns)
608 .where(eq(workflowRuns.status, "queued"))
609 .orderBy(asc(workflowRuns.queuedAt))
610 .limit(1);
611 candidateId = row?.id || null;
612 } catch (err) {
613 console.error("[workflow-runner] drain select:", err);
614 return false;
615 }
616 if (!candidateId) return false;
617
618 // Best-effort claim: flip queued → running. If another worker beat us,
619 // updated rowcount will be 0 — neon-http doesn't surface rowcount the
620 // same way so we re-select after.
621 try {
622 await db
623 .update(workflowRuns)
624 .set({ status: "running", startedAt: new Date() })
625 .where(
626 and(
627 eq(workflowRuns.id, candidateId),
628 eq(workflowRuns.status, "queued")
629 )
630 );
631 } catch (err) {
632 console.error("[workflow-runner] drain claim:", err);
633 return false;
634 }
635
636 // Verify we actually own the claim (status is now running and startedAt
637 // is very recent). If another worker beat us they'll have set startedAt
638 // earlier; accept either way — executeRun is idempotent enough for v1.
639 try {
640 await executeRun(candidateId);
641 } catch (err) {
642 console.error("[workflow-runner] executeRun threw (shouldn't):", err);
643 }
644 return true;
645}
646
647// ---------------------------------------------------------------------------
648// Public: enqueueRun
649// ---------------------------------------------------------------------------
650
651export async function enqueueRun(opts: {
652 workflowId: string;
653 repositoryId: string;
654 event: string;
655 ref?: string | null;
656 commitSha?: string | null;
657 triggeredBy?: string | null;
658}): Promise<string> {
659 // Compute next run_number scoped to this workflow.
660 let nextRunNumber = 1;
661 try {
662 const [row] = await db
663 .select({ n: sql<number>`coalesce(max(${workflowRuns.runNumber}), 0)` })
664 .from(workflowRuns)
665 .where(eq(workflowRuns.workflowId, opts.workflowId));
666 nextRunNumber = Number(row?.n ?? 0) + 1;
667 } catch (err) {
668 console.error("[workflow-runner] enqueue max:", err);
669 // Fall back to a coarse timestamp-derived number so the insert still
670 // succeeds; uniqueness isn't enforced in the schema.
671 nextRunNumber = Math.floor(Date.now() / 1000);
672 }
673
674 try {
675 const [row] = await db
676 .insert(workflowRuns)
677 .values({
678 workflowId: opts.workflowId,
679 repositoryId: opts.repositoryId,
680 runNumber: nextRunNumber,
681 event: opts.event,
682 ref: opts.ref ?? null,
683 commitSha: opts.commitSha ?? null,
684 triggeredBy: opts.triggeredBy ?? null,
685 status: "queued",
686 })
687 .returning({ id: workflowRuns.id });
688 return row?.id || "";
689 } catch (err) {
690 console.error("[workflow-runner] enqueue insert:", err);
691 return "";
692 }
693}
694
695// ---------------------------------------------------------------------------
696// Public: startWorker — background poll loop.
697// ---------------------------------------------------------------------------
698
699export function startWorker(opts?: { intervalMs?: number }): () => void {
700 const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
701 let stopped = false;
702 let active = false;
703
704 const tick = async () => {
705 if (stopped || active) return;
706 active = true;
707 try {
708 // Drain as many runs as we can in one tick (serial). If there's
709 // nothing queued we exit quickly and wait for the next interval.
710 let picked = true;
711 while (picked && !stopped) {
712 picked = await drainOneRun();
713 }
714 } catch (err) {
715 console.error("[workflow-runner] worker tick:", err);
716 } finally {
717 active = false;
718 }
719 };
720
721 const handle = setInterval(() => {
722 void tick();
723 }, intervalMs);
724
725 // Kick off an immediate tick so the first queued run doesn't wait.
726 void tick();
727
728 return () => {
729 stopped = true;
730 clearInterval(handle);
731 };
732}
Modifiedsrc/middleware/auth.ts+160−2View fileUnifiedSplit
11/**
22 * Auth middleware — reads session cookie, injects user into context.
3 * Uses in-memory session cache to avoid DB roundtrip on every request.
4 *
5 * B6: API requests can also authenticate via `Authorization: Bearer <token>`
6 * using an OAuth access token. The token's scopes and the owning app are
7 * stashed on the context for scope checks downstream.
38 */
49
510import { createMiddleware } from "hono/factory";
611import { getCookie } from "hono/cookie";
712import { eq, gt } from "drizzle-orm";
813import { db } from "../db";
9import { sessions, users } from "../db/schema";
14import { sessions, users, oauthAccessTokens, apiTokens } from "../db/schema";
1015import type { User } from "../db/schema";
16import { sessionCache } from "../lib/cache";
17import { sha256Hex } from "../lib/oauth";
1118
1219export type AuthEnv = {
1320 Variables: {
1421 user: User | null;
22 /** When the caller authenticated via an OAuth bearer token, these are set. */
23 oauthScopes?: string[];
24 oauthAppId?: string;
1525 };
1626};
1727
28async function loadUserFromBearer(
29 token: string
30): Promise<{ user: User; scopes: string[]; appId: string } | null> {
31 if (!token.startsWith("glct_")) return null;
32 try {
33 const hash = await sha256Hex(token);
34 const [row] = await db
35 .select()
36 .from(oauthAccessTokens)
37 .where(eq(oauthAccessTokens.accessTokenHash, hash))
38 .limit(1);
39 if (!row) return null;
40 if (row.revokedAt) return null;
41 if (new Date(row.expiresAt) < new Date()) return null;
42 const [user] = await db
43 .select()
44 .from(users)
45 .where(eq(users.id, row.userId))
46 .limit(1);
47 if (!user) return null;
48 // Best-effort: update lastUsedAt. Never fail auth on this.
49 db
50 .update(oauthAccessTokens)
51 .set({ lastUsedAt: new Date() })
52 .where(eq(oauthAccessTokens.id, row.id))
53 .catch(() => {});
54 return {
55 user,
56 scopes: row.scopes ? row.scopes.split(/\s+/).filter(Boolean) : [],
57 appId: row.appId,
58 };
59 } catch {
60 return null;
61 }
62}
63
64/**
65 * C2: Personal access tokens (`glc_` prefix) for CLI clients like `npm publish`.
66 * PAT storage lives in `api_tokens`; token body is stored as SHA-256 hex.
67 * Scopes are comma-separated in the row; return as array so callers can check.
68 */
69async function loadUserFromPat(
70 token: string
71): Promise<{ user: User; scopes: string[] } | 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 [user] = await db
83 .select()
84 .from(users)
85 .where(eq(users.id, row.userId))
86 .limit(1);
87 if (!user) return null;
88 db
89 .update(apiTokens)
90 .set({ lastUsedAt: new Date() })
91 .where(eq(apiTokens.id, row.id))
92 .catch(() => {});
93 return {
94 user,
95 scopes: row.scopes ? row.scopes.split(/[,\s]+/).filter(Boolean) : [],
96 };
97 } catch {
98 return null;
99 }
100}
101
18102/**
19103 * Soft auth — sets c.get("user") to the current user or null.
20104 * Does NOT block unauthenticated requests.
105 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
21106 */
22107export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
108 // B6: Bearer token takes precedence over cookie for API calls.
109 const authHeader = c.req.header("authorization") || "";
110 if (authHeader.toLowerCase().startsWith("bearer ")) {
111 const bearer = authHeader.slice(7).trim();
112 if (bearer.startsWith("glct_")) {
113 const result = await loadUserFromBearer(bearer);
114 if (result) {
115 c.set("user", result.user);
116 c.set("oauthScopes", result.scopes);
117 c.set("oauthAppId", result.appId);
118 return next();
119 }
120 } else if (bearer.startsWith("glc_")) {
121 // C2: Personal access tokens for CLI clients (npm, git HTTP, etc.).
122 const result = await loadUserFromPat(bearer);
123 if (result) {
124 c.set("user", result.user);
125 c.set("oauthScopes", result.scopes);
126 return next();
127 }
128 }
129 }
130
23131 const token = getCookie(c, "session");
24132 if (!token) {
25133 c.set("user", null);
26134 return next();
27135 }
28136
137 // Check session cache first
138 const cachedUser = sessionCache.get(token) as User | null | undefined;
139 if (cachedUser !== undefined) {
140 c.set("user", cachedUser);
141 return next();
142 }
143
29144 try {
30145 const [session] = await db
31146 .select()
33148 .where(eq(sessions.token, token))
34149 .limit(1);
35150
36 if (!session || new Date(session.expiresAt) < new Date()) {
151 if (
152 !session ||
153 new Date(session.expiresAt) < new Date() ||
154 session.requires2fa
155 ) {
156 sessionCache.set(token, null as any);
37157 c.set("user", null);
38158 return next();
39159 }
44164 .where(eq(users.id, session.userId))
45165 .limit(1);
46166
167 // Cache the result (user or null)
168 sessionCache.set(token, (user || null) as any);
47169 c.set("user", user || null);
48170 } catch {
49171 c.set("user", null);
56178 * Hard auth — redirects to /login if not authenticated.
57179 */
58180export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
181 // B6: Bearer token takes precedence over cookie for API calls.
182 const authHeader = c.req.header("authorization") || "";
183 if (authHeader.toLowerCase().startsWith("bearer ")) {
184 const bearer = authHeader.slice(7).trim();
185 if (bearer.startsWith("glct_")) {
186 const result = await loadUserFromBearer(bearer);
187 if (result) {
188 c.set("user", result.user);
189 c.set("oauthScopes", result.scopes);
190 c.set("oauthAppId", result.appId);
191 return next();
192 }
193 // Bearer token was presented but invalid — return 401 instead of
194 // redirecting to /login (API clients don't follow HTML redirects).
195 return c.json({ error: "Invalid or expired token" }, 401);
196 }
197 if (bearer.startsWith("glc_")) {
198 // C2: Personal access tokens for CLI clients.
199 const result = await loadUserFromPat(bearer);
200 if (result) {
201 c.set("user", result.user);
202 c.set("oauthScopes", result.scopes);
203 return next();
204 }
205 return c.json({ error: "Invalid or expired token" }, 401);
206 }
207 }
208
59209 const token = getCookie(c, "session");
60210 if (!token) {
61211 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
72222 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
73223 }
74224
225 // 2FA pending — route the user to the code prompt instead of letting
226 // them access protected pages.
227 if (session.requires2fa) {
228 return c.redirect(
229 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
230 );
231 }
232
75233 const [user] = await db
76234 .select()
77235 .from(users)
Addedsrc/middleware/request-context.ts+31−0View fileUnifiedSplit
1/**
2 * Request context middleware — attaches a request ID and start time to every
3 * request. Adds the ID to the response headers for correlation + to c.get()
4 * so downstream handlers and loggers can reference it.
5 */
6
7import { createMiddleware } from "hono/factory";
8
9export type RequestContextEnv = {
10 Variables: {
11 requestId: string;
12 requestStart: number;
13 };
14};
15
16function genId(): string {
17 const rand = Math.random().toString(36).slice(2, 10);
18 const ts = Date.now().toString(36);
19 return `${ts}-${rand}`;
20}
21
22export const requestContext = createMiddleware<RequestContextEnv>(
23 async (c, next) => {
24 const existing = c.req.header("x-request-id");
25 const id = existing && existing.length < 100 ? existing : genId();
26 c.set("requestId", id);
27 c.set("requestStart", Date.now());
28 c.header("X-Request-Id", id);
29 await next();
30 }
31);
Addedsrc/routes/admin.tsx+584−0View fileUnifiedSplit
1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import {
25 grantSiteAdmin,
26 isSiteAdmin,
27 KNOWN_FLAGS,
28 listFlags,
29 listSiteAdmins,
30 revokeSiteAdmin,
31 setFlag,
32} from "../lib/admin";
33import { audit } from "../lib/notify";
34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
35
36const admin = new Hono<AuthEnv>();
37admin.use("*", softAuth);
38
39async function gate(c: any): Promise<{ user: any } | Response> {
40 const user = c.get("user");
41 if (!user) return c.redirect("/login?next=/admin");
42 if (!(await isSiteAdmin(user.id))) {
43 return c.html(
44 <Layout title="Forbidden" user={user}>
45 <div class="empty-state">
46 <h2>403 — Not a site admin</h2>
47 <p>You don't have permission to view this page.</p>
48 </div>
49 </Layout>,
50 403
51 );
52 }
53 return { user };
54}
55
56admin.get("/admin", async (c) => {
57 const g = await gate(c);
58 if (g instanceof Response) return g;
59 const { user } = g;
60
61 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
62 const [rc] = await db
63 .select({ n: sql<number>`count(*)::int` })
64 .from(repositories);
65
66 const recent = await db
67 .select({
68 id: users.id,
69 username: users.username,
70 createdAt: users.createdAt,
71 })
72 .from(users)
73 .orderBy(desc(users.createdAt))
74 .limit(10);
75
76 const admins = await listSiteAdmins();
77
78 return c.html(
79 <Layout title="Admin — Gluecron" user={user}>
80 <h2>Site admin</h2>
81
82 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
83 <div class="panel" style="padding:12px;text-align:center">
84 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
85 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
86 Users
87 </div>
88 </div>
89 <div class="panel" style="padding:12px;text-align:center">
90 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
91 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
92 Repos
93 </div>
94 </div>
95 <div class="panel" style="padding:12px;text-align:center">
96 <div style="font-size:22px;font-weight:700">{admins.length}</div>
97 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
98 Site admins
99 </div>
100 </div>
101 </div>
102
103 <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px">
104 <a href="/admin/users" class="btn">
105 Manage users
106 </a>
107 <a href="/admin/repos" class="btn">
108 Manage repos
109 </a>
110 <a href="/admin/flags" class="btn">
111 Site flags
112 </a>
113 <a href="/admin/digests" class="btn">
114 Email digests
115 </a>
116 <a href="/admin/sso" class="btn">
117 Enterprise SSO
118 </a>
119 </div>
120
121 <h3>Recent signups</h3>
122 <div class="panel" style="margin-bottom:20px">
123 {recent.map((u) => (
124 <div class="panel-item" style="justify-content:space-between">
125 <a href={`/${u.username}`}>{u.username}</a>
126 <span style="font-size:12px;color:var(--text-muted)">
127 {u.createdAt
128 ? new Date(u.createdAt as unknown as string).toLocaleString()
129 : ""}
130 </span>
131 </div>
132 ))}
133 </div>
134
135 <h3>Site admins</h3>
136 <div class="panel">
137 {admins.length === 0 ? (
138 <div class="panel-empty">
139 No admins (bootstrap mode — oldest user is admin).
140 </div>
141 ) : (
142 admins.map((a) => (
143 <div class="panel-item" style="justify-content:space-between">
144 <a href={`/${a.username}`}>{a.username}</a>
145 <span style="font-size:12px;color:var(--text-muted)">
146 Granted{" "}
147 {a.grantedAt
148 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
149 : ""}
150 </span>
151 </div>
152 ))
153 )}
154 </div>
155 </Layout>
156 );
157});
158
159// ----- Users -----
160
161admin.get("/admin/users", async (c) => {
162 const g = await gate(c);
163 if (g instanceof Response) return g;
164 const { user } = g;
165 const q = c.req.query("q") || "";
166 const rows = await db
167 .select({
168 id: users.id,
169 username: users.username,
170 email: users.email,
171 createdAt: users.createdAt,
172 })
173 .from(users)
174 .where(
175 q
176 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
177 : sql`1=1`
178 )
179 .orderBy(desc(users.createdAt))
180 .limit(200);
181
182 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
183
184 return c.html(
185 <Layout title="Admin — Users" user={user}>
186 <h2>Users</h2>
187 <form method="GET" action="/admin/users" style="margin-bottom:16px">
188 <input
189 type="text"
190 name="q"
191 value={q}
192 placeholder="Search username or email"
193 style="width:320px"
194 />{" "}
195 <button type="submit" class="btn">
196 Search
197 </button>
198 <a href="/admin" class="btn" style="margin-left:6px">
199 Back
200 </a>
201 </form>
202 <div class="panel">
203 {rows.length === 0 ? (
204 <div class="panel-empty">No users found.</div>
205 ) : (
206 rows.map((u) => {
207 const isAdmin = adminIds.has(u.id);
208 return (
209 <div class="panel-item" style="justify-content:space-between">
210 <div>
211 <a href={`/${u.username}`} style="font-weight:600">
212 {u.username}
213 </a>{" "}
214 <span style="color:var(--text-muted)">{u.email}</span>
215 {isAdmin && (
216 <span
217 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
218 >
219 ADMIN
220 </span>
221 )}
222 </div>
223 <form
224 method="POST"
225 action={`/admin/users/${u.id}/admin`}
226 onsubmit={
227 isAdmin
228 ? "return confirm('Revoke site admin?')"
229 : "return confirm('Grant site admin?')"
230 }
231 >
232 <button type="submit" class="btn btn-sm">
233 {isAdmin ? "Revoke admin" : "Grant admin"}
234 </button>
235 </form>
236 </div>
237 );
238 })
239 )}
240 </div>
241 </Layout>
242 );
243});
244
245admin.post("/admin/users/:id/admin", async (c) => {
246 const g = await gate(c);
247 if (g instanceof Response) return g;
248 const { user } = g;
249 const id = c.req.param("id");
250 const admins = await listSiteAdmins();
251 const isAlready = admins.some((a) => a.userId === id);
252 if (isAlready) {
253 await revokeSiteAdmin(id);
254 await audit({
255 userId: user.id,
256 action: "site_admin.revoke",
257 targetType: "user",
258 targetId: id,
259 });
260 } else {
261 await grantSiteAdmin(id, user.id);
262 await audit({
263 userId: user.id,
264 action: "site_admin.grant",
265 targetType: "user",
266 targetId: id,
267 });
268 }
269 return c.redirect("/admin/users");
270});
271
272// ----- Repos -----
273
274admin.get("/admin/repos", async (c) => {
275 const g = await gate(c);
276 if (g instanceof Response) return g;
277 const { user } = g;
278 const rows = await db
279 .select({
280 id: repositories.id,
281 name: repositories.name,
282 ownerUsername: users.username,
283 visibility: repositories.visibility,
284 createdAt: repositories.createdAt,
285 starCount: repositories.starCount,
286 })
287 .from(repositories)
288 .innerJoin(users, eq(repositories.ownerId, users.id))
289 .orderBy(desc(repositories.createdAt))
290 .limit(200);
291
292 return c.html(
293 <Layout title="Admin — Repos" user={user}>
294 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
295 <h2>Repositories</h2>
296 <a href="/admin" class="btn btn-sm">
297 Back
298 </a>
299 </div>
300 <div class="panel">
301 {rows.length === 0 ? (
302 <div class="panel-empty">No repositories.</div>
303 ) : (
304 rows.map((r) => (
305 <div class="panel-item" style="justify-content:space-between">
306 <div>
307 <a
308 href={`/${r.ownerUsername}/${r.name}`}
309 style="font-weight:600"
310 >
311 {r.ownerUsername}/{r.name}
312 </a>
313 <span
314 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
315 >
316 {r.visibility}
317 </span>
318 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
319 {r.starCount} stars ·{" "}
320 {r.createdAt
321 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
322 : ""}
323 </div>
324 </div>
325 <form
326 method="POST"
327 action={`/admin/repos/${r.id}/delete`}
328 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
329 >
330 <button type="submit" class="btn btn-sm btn-danger">
331 Delete
332 </button>
333 </form>
334 </div>
335 ))
336 )}
337 </div>
338 </Layout>
339 );
340});
341
342admin.post("/admin/repos/:id/delete", async (c) => {
343 const g = await gate(c);
344 if (g instanceof Response) return g;
345 const { user } = g;
346 const id = c.req.param("id");
347 try {
348 await db.delete(repositories).where(eq(repositories.id, id));
349 } catch (err) {
350 console.error("[admin] repo delete:", err);
351 }
352 await audit({
353 userId: user.id,
354 action: "admin.repo.delete",
355 targetType: "repository",
356 targetId: id,
357 });
358 return c.redirect("/admin/repos");
359});
360
361// ----- Flags -----
362
363admin.get("/admin/flags", async (c) => {
364 const g = await gate(c);
365 if (g instanceof Response) return g;
366 const { user } = g;
367
368 const existing = await listFlags();
369 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
370 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
371
372 return c.html(
373 <Layout title="Admin — Flags" user={user}>
374 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
375 <h2>Site flags</h2>
376 <a href="/admin" class="btn btn-sm">
377 Back
378 </a>
379 </div>
380 <form
381 method="POST"
382 action="/admin/flags"
383 class="panel"
384 style="padding:16px"
385 >
386 {keys.map((k) => {
387 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
388 return (
389 <div class="form-group">
390 <label>{k}</label>
391 <input
392 type="text"
393 name={k}
394 value={current}
395 style="font-family:var(--font-mono)"
396 />
397 <div
398 style="font-size:11px;color:var(--text-muted);margin-top:2px"
399 >
400 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
401 </div>
402 </div>
403 );
404 })}
405 <button type="submit" class="btn btn-primary">
406 Save
407 </button>
408 </form>
409 </Layout>
410 );
411});
412
413admin.post("/admin/flags", async (c) => {
414 const g = await gate(c);
415 if (g instanceof Response) return g;
416 const { user } = g;
417 const body = await c.req.parseBody();
418 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
419 for (const k of keys) {
420 const v = String(body[k] ?? "");
421 await setFlag(k, v, user.id);
422 }
423 await audit({ userId: user.id, action: "admin.flags.save" });
424 return c.redirect("/admin/flags");
425});
426
427// ----- Email digests (Block I7) -----
428
429admin.get("/admin/digests", async (c) => {
430 const g = await gate(c);
431 if (g instanceof Response) return g;
432 const { user } = g;
433
434 const [optedRow] = await db
435 .select({ n: sql<number>`count(*)::int` })
436 .from(users)
437 .where(eq(users.notifyEmailDigestWeekly, true));
438 const opted = Number(optedRow?.n || 0);
439
440 const recentlySent = await db
441 .select({
442 id: users.id,
443 username: users.username,
444 lastDigestSentAt: users.lastDigestSentAt,
445 })
446 .from(users)
447 .where(sql`${users.lastDigestSentAt} is not null`)
448 .orderBy(desc(users.lastDigestSentAt))
449 .limit(20);
450
451 const result = c.req.query("result");
452 const error = c.req.query("error");
453
454 return c.html(
455 <Layout title="Admin — Digests" user={user}>
456 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
457 <h2>Email digests</h2>
458 <a href="/admin" class="btn btn-sm">
459 Back
460 </a>
461 </div>
462
463 {result && (
464 <div class="auth-success">{decodeURIComponent(result)}</div>
465 )}
466 {error && (
467 <div class="auth-error">{decodeURIComponent(error)}</div>
468 )}
469
470 <div class="panel" style="padding:16px;margin-bottom:20px">
471 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
472 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
473 </div>
474 <form method="POST" action="/admin/digests/run" style="margin-bottom:8px">
475 <button
476 type="submit"
477 class="btn btn-primary"
478 onclick="return confirm('Send weekly digest to all opted-in users now?')"
479 >
480 Send digests now
481 </button>
482 </form>
483 <form method="POST" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
484 <input
485 type="text"
486 name="username"
487 placeholder="username"
488 required
489 style="width:240px"
490 />
491 <button type="submit" class="btn btn-sm">
492 Send to one user
493 </button>
494 </form>
495 </div>
496
497 <h3>Recently sent</h3>
498 <div class="panel">
499 {recentlySent.length === 0 ? (
500 <div class="panel-empty">No digests have been sent yet.</div>
501 ) : (
502 recentlySent.map((u) => (
503 <div class="panel-item" style="justify-content:space-between">
504 <a href={`/${u.username}`}>{u.username}</a>
505 <span style="font-size:12px;color:var(--text-muted)">
506 {u.lastDigestSentAt
507 ? new Date(
508 u.lastDigestSentAt as unknown as string
509 ).toLocaleString()
510 : ""}
511 </span>
512 </div>
513 ))
514 )}
515 </div>
516 </Layout>
517 );
518});
519
520admin.post("/admin/digests/run", async (c) => {
521 const g = await gate(c);
522 if (g instanceof Response) return g;
523 const { user } = g;
524 const results = await sendDigestsToAll();
525 const sent = results.filter((r) => r.ok).length;
526 const skipped = results.length - sent;
527 await audit({
528 userId: user.id,
529 action: "admin.digests.run",
530 metadata: { sent, skipped, total: results.length },
531 });
532 return c.redirect(
533 `/admin/digests?result=${encodeURIComponent(
534 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
535 )}`
536 );
537});
538
539admin.post("/admin/digests/preview", async (c) => {
540 const g = await gate(c);
541 if (g instanceof Response) return g;
542 const { user } = g;
543 const body = await c.req.parseBody();
544 const username = String(body.username || "").trim();
545 if (!username) {
546 return c.redirect("/admin/digests?error=Username+required");
547 }
548 const [target] = await db
549 .select({ id: users.id, username: users.username })
550 .from(users)
551 .where(eq(users.username, username))
552 .limit(1);
553 if (!target) {
554 return c.redirect("/admin/digests?error=User+not+found");
555 }
556 const result = await sendDigestForUser(target.id);
557 await audit({
558 userId: user.id,
559 action: "admin.digests.preview",
560 targetType: "user",
561 targetId: target.id,
562 metadata: {
563 ok: result.ok,
564 skipped: "skipped" in result ? result.skipped : null,
565 },
566 });
567 if (result.ok) {
568 return c.redirect(
569 `/admin/digests?result=${encodeURIComponent(
570 `Digest sent to ${target.username}.`
571 )}`
572 );
573 }
574 return c.redirect(
575 `/admin/digests?error=${encodeURIComponent(
576 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
577 )}`
578 );
579});
580
581// Keep requireAuth import used even if some routes don't reference it here.
582void requireAuth;
583
584export default admin;
Addedsrc/routes/advisories.tsx+371−0View fileUnifiedSplit
1/**
2 * Block J2 — Security advisory / dependabot-style alert routes.
3 *
4 * GET /:owner/:repo/security/advisories — list open alerts
5 * GET /:owner/:repo/security/advisories/all — dismissed + fixed too
6 * POST /:owner/:repo/security/advisories/scan — owner-only; re-scan
7 * POST /:owner/:repo/security/advisories/:id/dismiss
8 * POST /:owner/:repo/security/advisories/:id/reopen
9 */
10
11import { Hono } from "hono";
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader, RepoNav } from "../views/components";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { audit } from "../lib/notify";
20import {
21 dismissAlert,
22 listAlertsForRepo,
23 reopenAlert,
24 scanRepositoryForAlerts,
25 seedAdvisories,
26} from "../lib/advisories";
27
28const advisories = new Hono<AuthEnv>();
29advisories.use("*", softAuth);
30
31async function loadRepo(ownerName: string, repoName: string) {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
43 )
44 .limit(1);
45 if (!repo) return null;
46 return { owner, repo };
47}
48
49function severityColor(sev: string): string {
50 switch (sev) {
51 case "critical":
52 return "var(--red)";
53 case "high":
54 return "var(--red)";
55 case "moderate":
56 return "var(--yellow)";
57 default:
58 return "var(--text-muted)";
59 }
60}
61
62// ---------- List ----------
63
64async function renderList(
65 c: any,
66 ownerName: string,
67 repoName: string,
68 status: "open" | "all"
69) {
70 const ctx = await loadRepo(ownerName, repoName);
71 if (!ctx) return c.notFound();
72 const { repo } = ctx;
73 const user = c.get("user");
74 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
75 return c.notFound();
76 }
77
78 const isOwner = !!user && user.id === repo.ownerId;
79 const alerts = await listAlertsForRepo(repo.id, status);
80 const message = c.req.query("message");
81 const error = c.req.query("error");
82
83 return c.html(
84 <Layout
85 title={`Security advisories — ${ownerName}/${repoName}`}
86 user={user}
87 >
88 <RepoHeader owner={ownerName} repo={repoName} />
89 <RepoNav owner={ownerName} repo={repoName} active="code" />
90 <div class="settings-container">
91 <div style="display:flex;justify-content:space-between;align-items:center">
92 <h2 style="margin:0">Security advisories</h2>
93 {isOwner && (
94 <form
95 method="POST"
96 action={`/${ownerName}/${repoName}/security/advisories/scan`}
97 >
98 <button type="submit" class="btn btn-primary btn-sm">
99 Re-scan
100 </button>
101 </form>
102 )}
103 </div>
104 <p style="color:var(--text-muted);margin-top:8px">
105 Cross-references this repo's parsed dependency graph against a
106 curated advisory database. Run <em>Reindex</em> on{" "}
107 <a href={`/${ownerName}/${repoName}/dependencies`}>Dependencies</a>{" "}
108 first if no alerts show up.
109 </p>
110 {message && (
111 <div class="auth-success" style="margin-top:12px">
112 {decodeURIComponent(message)}
113 </div>
114 )}
115 {error && (
116 <div class="auth-error" style="margin-top:12px">
117 {decodeURIComponent(error)}
118 </div>
119 )}
120
121 <div style="display:flex;gap:8px;margin:16px 0">
122 <a
123 href={`/${ownerName}/${repoName}/security/advisories`}
124 class={`btn ${status === "open" ? "btn-primary" : ""}`}
125 >
126 Open
127 </a>
128 <a
129 href={`/${ownerName}/${repoName}/security/advisories/all`}
130 class={`btn ${status === "all" ? "btn-primary" : ""}`}
131 >
132 All
133 </a>
134 </div>
135
136 {alerts.length === 0 ? (
137 <div class="panel-empty" style="padding:24px">
138 No {status === "open" ? "open " : ""}advisories.
139 {isOwner &&
140 status === "open" &&
141 " Click Re-scan to check against the advisory database."}
142 </div>
143 ) : (
144 <div class="panel">
145 {alerts.map((a) => (
146 <div
147 class="panel-item"
148 style="flex-direction:column;align-items:stretch;gap:6px"
149 >
150 <div
151 style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap"
152 >
153 <div style="min-width:0">
154 <span
155 style={`font-size:10px;padding:2px 6px;border-radius:3px;background:${severityColor(
156 a.advisory.severity
157 )};color:#fff;text-transform:uppercase;margin-right:6px`}
158 >
159 {a.advisory.severity}
160 </span>
161 <span style="font-weight:600">
162 {a.advisory.summary}
163 </span>
164 </div>
165 <div
166 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase"
167 >
168 {a.status}
169 </div>
170 </div>
171 <div
172 style="font-size:12px;color:var(--text-muted);display:flex;gap:12px;flex-wrap:wrap"
173 >
174 <span style="font-family:var(--font-mono)">
175 {a.advisory.ecosystem} · {a.dependencyName}
176 {a.dependencyVersion
177 ? ` ${a.dependencyVersion}`
178 : ""}
179 </span>
180 <span>affected: {a.advisory.affectedRange}</span>
181 {a.advisory.fixedVersion && (
182 <span>fixed in ≥ {a.advisory.fixedVersion}</span>
183 )}
184 <a
185 href={`/${ownerName}/${repoName}/blob/HEAD/${a.manifestPath}`}
186 >
187 {a.manifestPath}
188 </a>
189 {a.advisory.referenceUrl && (
190 <a
191 href={a.advisory.referenceUrl}
192 rel="noreferrer"
193 target="_blank"
194 >
195 {a.advisory.ghsaId || a.advisory.cveId || "ref"}
196 </a>
197 )}
198 </div>
199 {a.status === "dismissed" && a.dismissedReason && (
200 <div
201 style="font-size:12px;color:var(--text-muted);font-style:italic"
202 >
203 Dismissed: {a.dismissedReason}
204 </div>
205 )}
206 {isOwner && (
207 <div style="display:flex;gap:6px">
208 {a.status === "open" && (
209 <form
210 method="POST"
211 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/dismiss`}
212 style="display:flex;gap:4px;align-items:center"
213 >
214 <input
215 type="text"
216 name="reason"
217 placeholder="reason (optional)"
218 maxLength={280}
219 style="font-size:12px;padding:4px 6px"
220 />
221 <button
222 type="submit"
223 class="btn btn-sm"
224 style="font-size:11px"
225 >
226 Dismiss
227 </button>
228 </form>
229 )}
230 {a.status === "dismissed" && (
231 <form
232 method="POST"
233 action={`/${ownerName}/${repoName}/security/advisories/${a.id}/reopen`}
234 >
235 <button
236 type="submit"
237 class="btn btn-sm"
238 style="font-size:11px"
239 >
240 Reopen
241 </button>
242 </form>
243 )}
244 </div>
245 )}
246 </div>
247 ))}
248 </div>
249 )}
250 </div>
251 </Layout>
252 );
253}
254
255advisories.get("/:owner/:repo/security/advisories", async (c) => {
256 const { owner: ownerName, repo: repoName } = c.req.param();
257 return renderList(c, ownerName, repoName, "open");
258});
259
260advisories.get("/:owner/:repo/security/advisories/all", async (c) => {
261 const { owner: ownerName, repo: repoName } = c.req.param();
262 return renderList(c, ownerName, repoName, "all");
263});
264
265// ---------- Re-scan (owner-only) ----------
266
267advisories.post(
268 "/:owner/:repo/security/advisories/scan",
269 requireAuth,
270 async (c) => {
271 const user = c.get("user")!;
272 const { owner: ownerName, repo: repoName } = c.req.param();
273 const ctx = await loadRepo(ownerName, repoName);
274 if (!ctx) return c.notFound();
275 const { repo } = ctx;
276 if (user.id !== repo.ownerId) {
277 return c.redirect(
278 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
279 "Only the repo owner can scan"
280 )}`
281 );
282 }
283 await seedAdvisories().catch(() => {});
284 const result = await scanRepositoryForAlerts(repo.id);
285 await audit({
286 userId: user.id,
287 repositoryId: repo.id,
288 action: "advisories.scan",
289 metadata: result || {},
290 });
291 const to = `/${ownerName}/${repoName}/security/advisories`;
292 if (!result) {
293 return c.redirect(
294 `${to}?error=${encodeURIComponent("Scan failed")}`
295 );
296 }
297 const msg = `Scan complete — ${result.opened} new, ${result.closed} closed, ${result.matched} total matches.`;
298 return c.redirect(`${to}?message=${encodeURIComponent(msg)}`);
299 }
300);
301
302// ---------- Dismiss / reopen (owner-only) ----------
303
304advisories.post(
305 "/:owner/:repo/security/advisories/:id/dismiss",
306 requireAuth,
307 async (c) => {
308 const user = c.get("user")!;
309 const { owner: ownerName, repo: repoName, id } = c.req.param();
310 const ctx = await loadRepo(ownerName, repoName);
311 if (!ctx) return c.notFound();
312 const { repo } = ctx;
313 if (user.id !== repo.ownerId) {
314 return c.redirect(
315 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
316 "Only the repo owner can dismiss"
317 )}`
318 );
319 }
320 const body = await c.req.parseBody();
321 const reason = String(body.reason || "").trim();
322 const ok = await dismissAlert(id, repo.id, reason);
323 await audit({
324 userId: user.id,
325 repositoryId: repo.id,
326 action: "advisories.dismiss",
327 targetId: id,
328 metadata: { reason: reason || null },
329 });
330 const to = `/${ownerName}/${repoName}/security/advisories`;
331 return c.redirect(
332 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
333 ok ? "Alert dismissed." : "Dismiss failed"
334 )}`
335 );
336 }
337);
338
339advisories.post(
340 "/:owner/:repo/security/advisories/:id/reopen",
341 requireAuth,
342 async (c) => {
343 const user = c.get("user")!;
344 const { owner: ownerName, repo: repoName, id } = c.req.param();
345 const ctx = await loadRepo(ownerName, repoName);
346 if (!ctx) return c.notFound();
347 const { repo } = ctx;
348 if (user.id !== repo.ownerId) {
349 return c.redirect(
350 `/${ownerName}/${repoName}/security/advisories?error=${encodeURIComponent(
351 "Only the repo owner can reopen"
352 )}`
353 );
354 }
355 const ok = await reopenAlert(id, repo.id);
356 await audit({
357 userId: user.id,
358 repositoryId: repo.id,
359 action: "advisories.reopen",
360 targetId: id,
361 });
362 const to = `/${ownerName}/${repoName}/security/advisories`;
363 return c.redirect(
364 `${to}?${ok ? "message" : "error"}=${encodeURIComponent(
365 ok ? "Alert reopened." : "Reopen failed"
366 )}`
367 );
368 }
369);
370
371export default advisories;
Addedsrc/routes/ai-changelog.tsx+310−0View fileUnifiedSplit
1/**
2 * Block D7 — AI-generated changelog for an arbitrary commit range.
3 *
4 * GET /:owner/:repo/ai/changelog
5 * - No query args: renders a form (from/to selects populated from
6 * branches + recent tags).
7 * - ?from=&to= (&format=markdown|html): runs `git log <from>..<to>`,
8 * feeds commits to `generateChangelog`, and renders the result.
9 * - ?format=markdown returns `text/markdown` for CLI/CI consumers.
10 *
11 * Public repos are readable without auth (softAuth) — matching the
12 * behaviour of `src/routes/compare.tsx`.
13 */
14
15import { Hono } from "hono";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { IssueNav } from "./issues";
19import {
20 listBranches,
21 listTags,
22 resolveRef,
23 repoExists,
24 getRepoPath,
25} from "../git/repository";
26import { generateChangelog } from "../lib/ai-generators";
27import { renderMarkdown } from "../lib/markdown";
28import { softAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30
31const aiChangelog = new Hono<AuthEnv>();
32
33aiChangelog.use("*", softAuth);
34
35interface RangeCommit {
36 sha: string;
37 message: string;
38 author: string;
39 date: string;
40}
41
42async function commitsInRange(
43 owner: string,
44 repo: string,
45 from: string,
46 to: string
47): Promise<RangeCommit[]> {
48 const repoDir = getRepoPath(owner, repo);
49 const proc = Bun.spawn(
50 [
51 "git",
52 "log",
53 "--format=%H%x00%s%x00%an%x00%aI",
54 `${from}..${to}`,
55 ],
56 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
57 );
58 const out = await new Response(proc.stdout).text();
59 await proc.exited;
60 return out
61 .trim()
62 .split("\n")
63 .filter(Boolean)
64 .slice(0, 500)
65 .map((line) => {
66 const [sha, message, author, date] = line.split("\0");
67 return { sha, message, author, date };
68 });
69}
70
71aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
72 const { owner, repo } = c.req.param();
73 const user = c.get("user");
74 const from = (c.req.query("from") || "").trim();
75 const to = (c.req.query("to") || "").trim();
76 const format = (c.req.query("format") || "").trim().toLowerCase();
77
78 if (!(await repoExists(owner, repo))) {
79 return c.html(
80 <Layout title="Not Found" user={user}>
81 <div class="empty-state">
82 <h2>Repository not found</h2>
83 </div>
84 </Layout>,
85 404
86 );
87 }
88
89 const [branches, tags] = await Promise.all([
90 listBranches(owner, repo).catch(() => [] as string[]),
91 listTags(owner, repo).catch(
92 () => [] as Array<{ name: string; sha: string; date: string }>
93 ),
94 ]);
95 const refChoices = [
96 ...branches,
97 ...tags.slice(0, 25).map((t) => t.name),
98 ];
99
100 const renderForm = (opts: { error?: string; notice?: string } = {}) =>
101 c.html(
102 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
103 <RepoHeader owner={owner} repo={repo} />
104 <IssueNav owner={owner} repo={repo} active="code" />
105 <h2 style="margin-bottom: 8px">AI Changelog</h2>
106 <p style="color: var(--text-muted); margin-bottom: 20px; font-size: 14px">
107 Generate release notes for any commit range. Pick a base (from) and
108 a head (to) — Claude will group commits into Features / Fixes /
109 Perf / Refactors / Docs / Other.
110 </p>
111 {opts.error && (
112 <div class="auth-error" style="margin-bottom: 16px">
113 {opts.error}
114 </div>
115 )}
116 {opts.notice && (
117 <div
118 class="empty-state"
119 style="margin-bottom: 16px; padding: 12px; text-align: left"
120 >
121 {opts.notice}
122 </div>
123 )}
124 <form
125 method="GET"
126 action={`/${owner}/${repo}/ai/changelog`}
127 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
128 >
129 <label style="font-size: 13px; color: var(--text-muted)">
130 From
131 </label>
132 <input
133 type="text"
134 name="from"
135 list="ai-changelog-refs"
136 value={from}
137 placeholder="v1.0.0"
138 style="padding: 6px 10px"
139 />
140 <label style="font-size: 13px; color: var(--text-muted)">To</label>
141 <input
142 type="text"
143 name="to"
144 list="ai-changelog-refs"
145 value={to}
146 placeholder="main"
147 style="padding: 6px 10px"
148 />
149 <datalist id="ai-changelog-refs">
150 {refChoices.map((r) => (
151 <option value={r}></option>
152 ))}
153 </datalist>
154 <button type="submit" class="btn btn-primary">
155 Generate
156 </button>
157 </form>
158 {refChoices.length > 0 && (
159 <div style="font-size: 12px; color: var(--text-muted)">
160 Known refs: {refChoices.slice(0, 20).join(", ")}
161 {refChoices.length > 20 ? ", …" : ""}
162 </div>
163 )}
164 </Layout>
165 );
166
167 // No range supplied — show picker.
168 if (!from || !to) {
169 return renderForm();
170 }
171
172 // Resolve both refs.
173 const [fromSha, toSha] = await Promise.all([
174 resolveRef(owner, repo, from),
175 resolveRef(owner, repo, to),
176 ]);
177 if (!fromSha || !toSha) {
178 const which =
179 !fromSha && !toSha
180 ? `Could not resolve refs "${from}" or "${to}".`
181 : !fromSha
182 ? `Could not resolve "from" ref "${from}".`
183 : `Could not resolve "to" ref "${to}".`;
184 return renderForm({ error: which });
185 }
186
187 // Collect commits in range.
188 let commits: RangeCommit[] = [];
189 try {
190 commits = await commitsInRange(owner, repo, from, to);
191 } catch (err) {
192 return renderForm({
193 error: `Failed to read commit range: ${String(
194 (err as Error).message || err
195 )}`,
196 });
197 }
198
199 if (commits.length === 0) {
200 return renderForm({
201 notice: `No commits between ${from} and ${to}.`,
202 });
203 }
204
205 // Hand off to Claude (or the deterministic fallback).
206 let markdown = "";
207 try {
208 markdown = await generateChangelog(
209 `${owner}/${repo}`,
210 from,
211 to,
212 commits
213 );
214 } catch (err) {
215 // generateChangelog has its own no-key fallback, but network/SDK
216 // failures should still return a useful page rather than a 500.
217 markdown =
218 `## ${to} (since ${from})\n\n` +
219 commits
220 .map(
221 (c2) =>
222 `- ${c2.message.split("\n")[0]} (${c2.sha.slice(0, 7)}) — ${
223 c2.author
224 }`
225 )
226 .join("\n") +
227 `\n\n_AI generation failed: ${String(
228 (err as Error).message || err
229 )}_`;
230 }
231
232 // CLI / CI consumers want raw Markdown.
233 if (format === "markdown") {
234 return c.text(markdown, 200, { "Content-Type": "text/markdown" });
235 }
236
237 const html = renderMarkdown(markdown);
238
239 return c.html(
240 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
241 <RepoHeader owner={owner} repo={repo} />
242 <IssueNav owner={owner} repo={repo} active="code" />
243 <h2 style="margin-bottom: 4px">AI Changelog</h2>
244 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
245 {from} <span style="opacity: 0.6">..</span> {to} —{" "}
246 {commits.length} commit{commits.length !== 1 ? "s" : ""}
247 </div>
248 <form
249 method="GET"
250 action={`/${owner}/${repo}/ai/changelog`}
251 style="display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
252 >
253 <input
254 type="text"
255 name="from"
256 list="ai-changelog-refs"
257 value={from}
258 style="padding: 6px 10px"
259 />
260 <span style="color: var(--text-muted)">..</span>
261 <input
262 type="text"
263 name="to"
264 list="ai-changelog-refs"
265 value={to}
266 style="padding: 6px 10px"
267 />
268 <datalist id="ai-changelog-refs">
269 {refChoices.map((r) => (
270 <option value={r}></option>
271 ))}
272 </datalist>
273 <button type="submit" class="btn">
274 Regenerate
275 </button>
276 <a
277 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
278 from
279 )}&to=${encodeURIComponent(to)}&format=markdown`}
280 class="btn"
281 >
282 Raw Markdown
283 </a>
284 </form>
285 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start">
286 <div
287 class="markdown-body"
288 dangerouslySetInnerHTML={{ __html: html }}
289 ></div>
290 <div>
291 <div
292 style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px"
293 >
294 Copy Markdown
295 </div>
296 <textarea
297 readonly
298 rows={24}
299 style="width: 100%; font-family: var(--font-mono, monospace); font-size: 12px; padding: 10px; background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); border-radius: 6px"
300 onclick="this.select()"
301 >
302 {markdown}
303 </textarea>
304 </div>
305 </div>
306 </Layout>
307 );
308});
309
310export default aiChangelog;
Addedsrc/routes/ai-explain.tsx+196−0View fileUnifiedSplit
1/**
2 * Block D6 — "Explain this codebase" route.
3 *
4 * GET /:owner/:repo/explain — render cached (or freshly
5 * generated on first visit)
6 * Markdown explanation
7 * POST /:owner/:repo/explain/regenerate — owner-only; force-regenerate
8 * and redirect back
9 *
10 * Heavy lifting lives in `lib/ai-explain.ts`; this file is just HTTP glue.
11 */
12
13import { Hono } from "hono";
14import { html } from "hono/html";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { IssueNav } from "./issues";
21import { renderMarkdown } from "../lib/markdown";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { getDefaultBranch, resolveRef } from "../git/repository";
25import {
26 explainCodebase,
27 getCachedExplanation,
28} from "../lib/ai-explain";
29
30const aiExplainRoutes = new Hono<AuthEnv>();
31
32interface ResolvedRepo {
33 ownerId: string;
34 ownerUsername: string;
35 repoId: string;
36 repoName: string;
37}
38
39async function resolveRepo(
40 ownerName: string,
41 repoName: string
42): Promise<ResolvedRepo | null> {
43 try {
44 const [ownerRow] = await db
45 .select()
46 .from(users)
47 .where(eq(users.username, ownerName))
48 .limit(1);
49 if (!ownerRow) return null;
50 const [repoRow] = await db
51 .select()
52 .from(repositories)
53 .where(
54 and(
55 eq(repositories.ownerId, ownerRow.id),
56 eq(repositories.name, repoName)
57 )
58 )
59 .limit(1);
60 if (!repoRow) return null;
61 return {
62 ownerId: ownerRow.id,
63 ownerUsername: ownerRow.username,
64 repoId: repoRow.id,
65 repoName: repoRow.name,
66 };
67 } catch {
68 return null;
69 }
70}
71
72async function resolveHeadSha(
73 owner: string,
74 repo: string
75): Promise<string | null> {
76 const branch = await getDefaultBranch(owner, repo);
77 if (!branch) return null;
78 return resolveRef(owner, repo, branch);
79}
80
81aiExplainRoutes.get(
82 "/:owner/:repo/explain",
83 softAuth,
84 async (c) => {
85 const { owner, repo } = c.req.param();
86 const user = c.get("user");
87
88 const resolved = await resolveRepo(owner, repo);
89 if (!resolved) {
90 return c.html(
91 <Layout title="Not Found" user={user}>
92 <div class="empty-state">
93 <h2>Repository not found</h2>
94 </div>
95 </Layout>,
96 404
97 );
98 }
99
100 const sha = await resolveHeadSha(owner, repo);
101 if (!sha) {
102 return c.html(
103 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
104 <RepoHeader owner={owner} repo={repo} />
105 <IssueNav owner={owner} repo={repo} active="code" />
106 <div class="empty-state">
107 <h2>No commits yet</h2>
108 <p>
109 Push some code to <code>{repo}</code> and check back — the
110 explanation is generated from the default branch.
111 </p>
112 </div>
113 </Layout>
114 );
115 }
116
117 // Prefer cache first to avoid calling the AI on every page load.
118 let result = await getCachedExplanation(resolved.repoId, sha);
119 if (!result) {
120 result = await explainCodebase({
121 owner,
122 repo,
123 repositoryId: resolved.repoId,
124 commitSha: sha,
125 });
126 }
127
128 const canRegenerate = !!user && user.id === resolved.ownerId;
129
130 return c.html(
131 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
132 <RepoHeader owner={owner} repo={repo} />
133 <IssueNav owner={owner} repo={repo} active="code" />
134 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
135 <h2 style="margin: 0;">Codebase explanation</h2>
136 {canRegenerate && (
137 <form
138 method="POST"
139 action={`/${owner}/${repo}/explain/regenerate`}
140 style="display: inline"
141 >
142 <button type="submit" class="star-btn">
143 Regenerate
144 </button>
145 </form>
146 )}
147 </div>
148 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">
149 Generated from commit <code>{sha.slice(0, 7)}</code> · model{" "}
150 <code>{result.model}</code>
151 {result.cached ? " · cached" : ""}
152 </div>
153 <div class="markdown-body">
154 {html(
155 [renderMarkdown(result.markdown)] as unknown as TemplateStringsArray
156 )}
157 </div>
158 </Layout>
159 );
160 }
161);
162
163aiExplainRoutes.post(
164 "/:owner/:repo/explain/regenerate",
165 requireAuth,
166 async (c) => {
167 const { owner, repo } = c.req.param();
168 const user = c.get("user")!;
169
170 const resolved = await resolveRepo(owner, repo);
171 if (!resolved) return c.notFound();
172
173 if (resolved.ownerId !== user.id) {
174 return c.redirect(`/${owner}/${repo}/explain`);
175 }
176
177 const sha = await resolveHeadSha(owner, repo);
178 if (!sha) {
179 return c.redirect(`/${owner}/${repo}/explain`);
180 }
181
182 // Run synchronously so the redirect lands on a fresh result. The helper
183 // itself never throws; worst case the user sees the fallback copy.
184 await explainCodebase({
185 owner,
186 repo,
187 repositoryId: resolved.repoId,
188 commitSha: sha,
189 force: true,
190 });
191
192 return c.redirect(`/${owner}/${repo}/explain`);
193 }
194);
195
196export default aiExplainRoutes;
Addedsrc/routes/ai-tests.tsx+442−0View fileUnifiedSplit
1/**
2 * Block D8 — AI-generated test suite route.
3 *
4 * GET /:owner/:repo/ai/tests?path=...&ref=...
5 * Renders a form to pick a source file and generate a failing test
6 * stub for it. When `path` is provided the form is pre-filled with
7 * the currently-selected file so the user can "Generate" with one
8 * click.
9 *
10 * GET /:owner/:repo/ai/tests?path=...&format=raw
11 * Returns `c.text(result.code, 200, {"Content-Type": ...})` for CLI
12 * consumption (e.g. `curl | bat`). No HTML shell.
13 *
14 * POST /:owner/:repo/ai/tests/generate
15 * Auth required. Actually runs the model and renders the result
16 * page with highlighted source, highlighted test, a copy-to-clipboard
17 * button, a review warning, and a regenerate button.
18 */
19
20import { Hono } from "hono";
21import { html, raw } from "hono/html";
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import { repositories, users } from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { IssueNav } from "./issues";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 getBlob,
32 getDefaultBranch,
33 getTree,
34 resolveRef,
35} from "../git/repository";
36import type { GitTreeEntry } from "../git/repository";
37import { highlightCode } from "../lib/highlight";
38import {
39 contentTypeFor,
40 detectLanguage,
41 detectTestFramework,
42 generateTestStub,
43} from "../lib/ai-tests";
44
45const aiTestsRoutes = new Hono<AuthEnv>();
46
47interface ResolvedRepo {
48 ownerId: string;
49 ownerUsername: string;
50 repoId: string;
51 repoName: string;
52}
53
54async function resolveRepo(
55 ownerName: string,
56 repoName: string
57): Promise<ResolvedRepo | null> {
58 try {
59 const [ownerRow] = await db
60 .select()
61 .from(users)
62 .where(eq(users.username, ownerName))
63 .limit(1);
64 if (!ownerRow) return null;
65 const [repoRow] = await db
66 .select()
67 .from(repositories)
68 .where(
69 and(
70 eq(repositories.ownerId, ownerRow.id),
71 eq(repositories.name, repoName)
72 )
73 )
74 .limit(1);
75 if (!repoRow) return null;
76 return {
77 ownerId: ownerRow.id,
78 ownerUsername: ownerRow.username,
79 repoId: repoRow.id,
80 repoName: repoRow.name,
81 };
82 } catch {
83 return null;
84 }
85}
86
87/**
88 * Shallow listing of source blobs reachable from the tree root and a couple
89 * of common top-level source directories. Kept intentionally small — the
90 * picker in the form is just a convenience, users can also type a path
91 * directly.
92 */
93async function listRepoFiles(
94 owner: string,
95 repo: string,
96 ref: string
97): Promise<string[]> {
98 const out: string[] = [];
99 let root: GitTreeEntry[] = [];
100 try {
101 root = await getTree(owner, repo, ref, "");
102 } catch {
103 root = [];
104 }
105 for (const entry of root) {
106 if (entry.type === "blob") {
107 out.push(entry.name);
108 }
109 }
110 const candidates = ["src", "lib", "app", "server", "pkg", "tests"];
111 for (const dir of candidates) {
112 const hit = root.find((e) => e.type === "tree" && e.name === dir);
113 if (!hit) continue;
114 let children: GitTreeEntry[] = [];
115 try {
116 children = await getTree(owner, repo, ref, dir);
117 } catch {
118 children = [];
119 }
120 for (const child of children) {
121 if (child.type === "blob") {
122 out.push(`${dir}/${child.name}`);
123 } else if (child.type === "tree") {
124 let grand: GitTreeEntry[] = [];
125 try {
126 grand = await getTree(owner, repo, ref, `${dir}/${child.name}`);
127 } catch {
128 grand = [];
129 }
130 for (const g of grand) {
131 if (g.type === "blob") {
132 out.push(`${dir}/${child.name}/${g.name}`);
133 }
134 }
135 }
136 }
137 if (out.length > 500) break;
138 }
139 return out;
140}
141
142function renderPicker(
143 ownerName: string,
144 repoName: string,
145 allFiles: string[],
146 currentPath: string,
147 ref: string
148) {
149 const trimmed = allFiles.slice(0, 200);
150 return (
151 <form
152 method="POST"
153 action={`/${ownerName}/${repoName}/ai/tests/generate`}
154 style="margin-top: 16px; display: flex; flex-direction: column; gap: 12px; max-width: 720px;"
155 >
156 <input type="hidden" name="ref" value={ref} />
157 <label style="display: flex; flex-direction: column; gap: 6px;">
158 <span style="font-weight: 600;">Source file</span>
159 <input
160 type="text"
161 name="path"
162 value={currentPath}
163 placeholder="src/lib/foo.ts"
164 required
165 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
166 />
167 </label>
168 {trimmed.length > 0 && (
169 <label style="display: flex; flex-direction: column; gap: 6px;">
170 <span style="font-weight: 600;">…or pick from the repo</span>
171 <select
172 name="pickPath"
173 onchange="this.form.path.value = this.value"
174 style="padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text);"
175 >
176 <option value="">— select a file —</option>
177 {trimmed.map((f) => (
178 <option value={f} selected={f === currentPath}>
179 {f}
180 </option>
181 ))}
182 </select>
183 </label>
184 )}
185 <div>
186 <button type="submit" class="star-btn" style="padding: 6px 14px;">
187 Generate tests
188 </button>
189 </div>
190 </form>
191 );
192}
193
194aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
195 const { owner, repo } = c.req.param();
196 const user = c.get("user");
197 const path = (c.req.query("path") || "").trim();
198 const reqRef = (c.req.query("ref") || "").trim();
199 const format = (c.req.query("format") || "").trim().toLowerCase();
200
201 const resolved = await resolveRepo(owner, repo);
202 if (!resolved) {
203 return c.html(
204 <Layout title="Not Found" user={user}>
205 <div class="empty-state">
206 <h2>Repository not found</h2>
207 </div>
208 </Layout>,
209 404
210 );
211 }
212
213 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
214 const ref = reqRef || defaultBranch;
215 const sha = (await resolveRef(owner, repo, ref)) || ref;
216
217 // Raw format: just emit the generated code (for CLI use).
218 if (format === "raw") {
219 if (!path) {
220 return c.text("missing ?path=", 400, {
221 "Content-Type": "text/plain; charset=utf-8",
222 });
223 }
224 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
225 if (!blob || blob.isBinary) {
226 return c.text("file not found", 404, {
227 "Content-Type": "text/plain; charset=utf-8",
228 });
229 }
230 const language = detectLanguage(path);
231 const repoFiles = await listRepoFiles(owner, repo, sha);
232 const framework = detectTestFramework(language, repoFiles);
233 const result = await generateTestStub({
234 path,
235 language,
236 framework,
237 sourceCode: blob.content,
238 });
239 return c.text(result.code, 200, {
240 "Content-Type": contentTypeFor(result.language),
241 });
242 }
243
244 // HTML form mode.
245 const repoFiles = await listRepoFiles(owner, repo, sha);
246 const detectedLang = path ? detectLanguage(path) : "other";
247 const detectedFramework = detectTestFramework(detectedLang, repoFiles);
248
249 return c.html(
250 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
251 <RepoHeader owner={owner} repo={repo} />
252 <IssueNav owner={owner} repo={repo} active="code" />
253 <div style="margin: 16px 0;">
254 <h2 style="margin: 0 0 8px;">AI-generated tests</h2>
255 <p style="color: var(--text-muted); margin: 0;">
256 Pick a source file and gluecron will ask Claude to draft a{" "}
257 <strong>failing</strong> test stub that exercises its public surface.
258 Treat the output as a starting-point — always review before
259 committing.
260 </p>
261 {path && (
262 <p style="color: var(--text-muted); margin: 8px 0 0; font-size: 13px;">
263 Detected language: <code>{detectedLang}</code> · framework:{" "}
264 <code>{detectedFramework}</code>
265 </p>
266 )}
267 </div>
268 {renderPicker(owner, repo, repoFiles, path, ref)}
269 </Layout>
270 );
271});
272
273aiTestsRoutes.post(
274 "/:owner/:repo/ai/tests/generate",
275 requireAuth,
276 async (c) => {
277 const { owner, repo } = c.req.param();
278 const user = c.get("user")!;
279 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
280 const path = String(body.path || "").trim();
281 const reqRef = String(body.ref || "").trim();
282
283 const resolved = await resolveRepo(owner, repo);
284 if (!resolved) {
285 return c.html(
286 <Layout title="Not Found" user={user}>
287 <div class="empty-state">
288 <h2>Repository not found</h2>
289 </div>
290 </Layout>,
291 404
292 );
293 }
294
295 if (!path) {
296 return c.redirect(`/${owner}/${repo}/ai/tests`);
297 }
298
299 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
300 const ref = reqRef || defaultBranch;
301 const sha = (await resolveRef(owner, repo, ref)) || ref;
302
303 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
304 if (!blob || blob.isBinary) {
305 return c.html(
306 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
307 <RepoHeader owner={owner} repo={repo} />
308 <IssueNav owner={owner} repo={repo} active="code" />
309 <div class="empty-state">
310 <h2>Couldn't read that file</h2>
311 <p>
312 No such path at <code>{ref}</code>, or the file is binary.
313 </p>
314 <p>
315 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
316 </p>
317 </div>
318 </Layout>,
319 404
320 );
321 }
322
323 const language = detectLanguage(path);
324 const repoFiles = await listRepoFiles(owner, repo, sha);
325 const framework = detectTestFramework(language, repoFiles);
326
327 const result = await generateTestStub({
328 path,
329 language,
330 framework,
331 sourceCode: blob.content,
332 });
333
334 const sourceHl = highlightCode(blob.content, path);
335 const testHl = highlightCode(result.code || "", result.suggestedPath);
336
337 const aiFailed = result.framework === "fallback" || !result.code;
338
339 return c.html(
340 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
341 <RepoHeader owner={owner} repo={repo} />
342 <IssueNav owner={owner} repo={repo} active="code" />
343 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
344 <div>
345 <h2 style="margin: 0;">AI-generated tests for <code>{path}</code></h2>
346 <p style="color: var(--text-muted); margin: 4px 0 0; font-size: 13px;">
347 Detected language: <code>{language}</code> · framework:{" "}
348 <code>{aiFailed ? "fallback" : framework}</code> · ref{" "}
349 <code>{ref}</code>
350 </p>
351 </div>
352 <form
353 method="POST"
354 action={`/${owner}/${repo}/ai/tests/generate`}
355 style="display: inline;"
356 >
357 <input type="hidden" name="path" value={path} />
358 <input type="hidden" name="ref" value={ref} />
359 <button type="submit" class="star-btn">Regenerate</button>
360 </form>
361 </div>
362
363 <div
364 class="flash-warning"
365 style="border: 1px solid var(--border); background: rgba(210, 153, 34, 0.12); padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;"
366 >
367 <strong>Review before committing.</strong> These tests are a
368 starting-point only — they are intentionally written to{" "}
369 <em>fail</em> so you are forced to supply real expected values.
370 Gluecron does not verify the behaviour is correct.
371 </div>
372
373 {aiFailed && (
374 <div
375 class="empty-state"
376 style="border: 1px dashed var(--border); padding: 16px; border-radius: 6px; margin-bottom: 16px;"
377 >
378 <p style="margin: 0;">
379 Couldn't generate a test stub. The AI backend may not be
380 configured, or the model returned an empty response. A suggested
381 path was still computed: <code>{result.suggestedPath}</code>.
382 </p>
383 </div>
384 )}
385
386 <section style="margin-bottom: 24px;">
387 <h3 style="margin: 0 0 8px; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
388 Source — <code>{path}</code>
389 </h3>
390 <pre class="hljs" style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto;">
391 <code>{raw(sourceHl.html)}</code>
392 </pre>
393 </section>
394
395 <section>
396 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
397 <h3 style="margin: 0; font-size: 14px; text-transform: uppercase; color: var(--text-muted);">
398 Suggested test — <code>{result.suggestedPath}</code>
399 </h3>
400 <button
401 type="button"
402 class="star-btn"
403 id="copy-test-btn"
404 data-test-code-id="ai-test-code"
405 >
406 Copy
407 </button>
408 </div>
409 <pre
410 class="hljs"
411 id="ai-test-code"
412 style="padding: 12px; border: 1px solid var(--border); border-radius: 6px; overflow: auto; white-space: pre;"
413 >
414 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
415 </pre>
416 </section>
417
418 {html`<script>
419 (function () {
420 var btn = document.getElementById('copy-test-btn');
421 if (!btn) return;
422 btn.addEventListener('click', function () {
423 var id = btn.getAttribute('data-test-code-id');
424 var el = id ? document.getElementById(id) : null;
425 var text = el ? el.innerText : '';
426 if (!text) return;
427 if (navigator.clipboard && navigator.clipboard.writeText) {
428 navigator.clipboard.writeText(text).then(function () {
429 var prev = btn.textContent;
430 btn.textContent = 'Copied';
431 setTimeout(function () { btn.textContent = prev; }, 1500);
432 });
433 }
434 });
435 })();
436 </script>`}
437 </Layout>
438 );
439 }
440);
441
442export default aiTestsRoutes;
Modifiedsrc/routes/api.ts+191−70View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
6import { eq, and, isNull } from "drizzle-orm";
77import { db } from "../db";
8import { users, repositories } from "../db/schema";
8import { users, repositories, organizations, orgMembers } from "../db/schema";
99import { initBareRepo, repoExists } from "../git/repository";
1010import { hashPassword } from "../lib/auth";
11import { orgRoleAtLeast } from "../lib/orgs";
1112
1213const api = new Hono().basePath("/api");
1314
1415// Create repository
1516api.post("/repos", async (c) => {
16 const body = await c.req.json<{
17 let body: {
1718 name: string;
1819 owner: string;
20 orgSlug?: string;
1921 description?: string;
2022 isPrivate?: boolean;
21 }>();
23 };
24 try {
25 body = await c.req.json();
26 } catch {
27 return c.json({ error: "Invalid JSON body" }, 400);
28 }
2229
2330 if (!body.name || !body.owner) {
2431 return c.json({ error: "name and owner are required" }, 400);
2936 return c.json({ error: "Invalid repository name" }, 400);
3037 }
3138
32 // Find owner
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, body.owner));
39 try {
40 // Find creator (user who is performing the action)
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, body.owner));
3745
38 if (!owner) {
39 return c.json({ error: "Owner not found" }, 404);
40 }
46 if (!owner) {
47 return c.json({ error: "Owner not found" }, 404);
48 }
49
50 // B2: if orgSlug supplied, place the repo in the org namespace.
51 // Requires the creator to be an admin+ of the org.
52 let orgId: string | null = null;
53 let namespaceSlug = body.owner;
54 if (body.orgSlug) {
55 const [org] = await db
56 .select({ id: organizations.id, slug: organizations.slug })
57 .from(organizations)
58 .where(eq(organizations.slug, body.orgSlug))
59 .limit(1);
60 if (!org) return c.json({ error: "Organization not found" }, 404);
61
62 const [mem] = await db
63 .select({ role: orgMembers.role })
64 .from(orgMembers)
65 .where(
66 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, owner.id))
67 )
68 .limit(1);
69 if (!mem || !orgRoleAtLeast(mem.role, "admin")) {
70 return c.json({ error: "Admin rights required on org" }, 403);
71 }
72 orgId = org.id;
73 namespaceSlug = org.slug;
74 }
75
76 // Duplicate check: scoped to the right namespace.
77 if (orgId) {
78 const [existing] = await db
79 .select({ id: repositories.id })
80 .from(repositories)
81 .where(
82 and(eq(repositories.orgId, orgId), eq(repositories.name, body.name))
83 )
84 .limit(1);
85 if (existing) {
86 return c.json({ error: "Repository already exists" }, 409);
87 }
88 } else {
89 const [existing] = await db
90 .select({ id: repositories.id })
91 .from(repositories)
92 .where(
93 and(
94 eq(repositories.ownerId, owner.id),
95 eq(repositories.name, body.name),
96 isNull(repositories.orgId)
97 )
98 )
99 .limit(1);
100 if (existing) {
101 return c.json({ error: "Repository already exists" }, 409);
102 }
103 }
104 if (await repoExists(namespaceSlug, body.name)) {
105 return c.json({ error: "Repository already exists" }, 409);
106 }
107
108 // Init bare repo on disk, keyed by the namespace slug (user or org).
109 const diskPath = await initBareRepo(namespaceSlug, body.name);
110
111 // Insert into DB
112 const [repo] = await db
113 .insert(repositories)
114 .values({
115 name: body.name,
116 ownerId: owner.id,
117 orgId,
118 description: body.description || null,
119 isPrivate: body.isPrivate || false,
120 diskPath,
121 })
122 .returning();
123
124 // Green-ecosystem bootstrap: settings, protection, labels, welcome issue
125 if (repo) {
126 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
127 await bootstrapRepository({
128 repositoryId: repo.id,
129 ownerUserId: owner.id,
130 });
131 }
41132
42 // Check duplicate
43 if (await repoExists(body.owner, body.name)) {
44 return c.json({ error: "Repository already exists" }, 409);
133 return c.json(repo, 201);
134 } catch (err) {
135 console.error("[api] POST /repos:", err);
136 return c.json({ error: "Service unavailable" }, 503);
45137 }
46138
47139 // Init bare repo on disk
65157// List user's repositories
66158api.get("/users/:username/repos", async (c) => {
67159 const { username } = c.req.param();
68 const [owner] = await db
69 .select()
70 .from(users)
71 .where(eq(users.username, username));
72 if (!owner) return c.json({ error: "User not found" }, 404);
73
74 const repos = await db
75 .select()
76 .from(repositories)
77 .where(eq(repositories.ownerId, owner.id));
78
79 return c.json(repos);
160 try {
161 const [owner] = await db
162 .select()
163 .from(users)
164 .where(eq(users.username, username));
165 if (!owner) return c.json({ error: "User not found" }, 404);
166
167 const repos = await db
168 .select()
169 .from(repositories)
170 .where(eq(repositories.ownerId, owner.id));
171
172 return c.json(repos);
173 } catch (err) {
174 console.error("[api] /users/:username/repos:", err);
175 return c.json({ error: "Service unavailable" }, 503);
176 }
80177});
81178
82// Get single repository
179// Get single repository (resolves both user- and org-owned namespaces)
83180api.get("/repos/:owner/:name", async (c) => {
84181 const { owner: ownerName, name } = c.req.param();
85 const [owner] = await db
86 .select()
87 .from(users)
88 .where(eq(users.username, ownerName));
89 if (!owner) return c.json({ error: "Not found" }, 404);
90
91 const [repo] = await db
92 .select()
93 .from(repositories)
94 .where(
95 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
96 );
97 if (!repo) return c.json({ error: "Not found" }, 404);
98
99 return c.json(repo);
182 try {
183 const { loadRepoByPath } = await import("../lib/namespace");
184 const repo = await loadRepoByPath(ownerName, name);
185 if (!repo) return c.json({ error: "Not found" }, 404);
186 return c.json(repo);
187 } catch (err) {
188 console.error("[api] /repos/:owner/:name:", err);
189 return c.json({ error: "Service unavailable" }, 503);
190 }
100191});
101192
102193// Quick-setup: create user + repo in one call (dev convenience)
103194api.post("/setup", async (c) => {
104 const body = await c.req.json<{
195 let body: {
105196 username: string;
106197 email: string;
107198 repoName: string;
108199 description?: string;
109 }>();
200 };
201 try {
202 body = await c.req.json();
203 } catch {
204 return c.json({ error: "Invalid JSON body" }, 400);
205 }
206 if (!body.username || !body.email || !body.repoName) {
207 return c.json(
208 { error: "username, email, and repoName are required" },
209 400
210 );
211 }
110212
111 // Upsert user
112 let [user] = await db
113 .select()
114 .from(users)
115 .where(eq(users.username, body.username));
213 try {
214 // Upsert user
215 let [user] = await db
216 .select()
217 .from(users)
218 .where(eq(users.username, body.username));
116219
117 if (!user) {
118 [user] = await db
119 .insert(users)
120 .values({
121 username: body.username,
122 email: body.email,
123 passwordHash: await hashPassword("changeme"),
124 })
125 .returning();
126 }
220 if (!user) {
221 [user] = await db
222 .insert(users)
223 .values({
224 username: body.username,
225 email: body.email,
226 passwordHash: await hashPassword("changeme"),
227 })
228 .returning();
229 }
127230
128 // Create repo if not exists
129 if (!(await repoExists(body.username, body.repoName))) {
130 const diskPath = await initBareRepo(body.username, body.repoName);
131 await db.insert(repositories).values({
132 name: body.repoName,
133 ownerId: user.id,
134 description: body.description || null,
135 diskPath,
231 // Create repo if not exists
232 if (!(await repoExists(body.username, body.repoName))) {
233 const diskPath = await initBareRepo(body.username, body.repoName);
234 const [repo] = await db
235 .insert(repositories)
236 .values({
237 name: body.repoName,
238 ownerId: user.id,
239 description: body.description || null,
240 diskPath,
241 })
242 .returning();
243 if (repo) {
244 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
245 await bootstrapRepository({
246 repositoryId: repo.id,
247 ownerUserId: user.id,
248 });
249 }
250 }
251
252 return c.json({
253 user: user.username,
254 repo: body.repoName,
255 status: "ready",
136256 });
257 } catch (err) {
258 console.error("[api] POST /setup:", err);
259 return c.json({ error: "Service unavailable" }, 503);
137260 }
138
139 return c.json({ user: user.username, repo: body.repoName, status: "ready" });
140261});
141262
142263export default api;
Addedsrc/routes/ask.tsx+425−0View fileUnifiedSplit
1/**
2 * AI chat assistant — global + per-repo.
3 *
4 * GET /ask — global assistant (platform Q&A)
5 * GET /ask/:chatId — resume a saved chat
6 * POST /ask — send a message (global)
7 * GET /:owner/:repo/ask — repo-grounded chat
8 * POST /:owner/:repo/ask — send a repo-grounded message
9 * POST /:owner/:repo/explain — "Explain this file" helper
10 *
11 * Chats are persisted to `ai_chats` so users can return to them.
12 * Form-based — works even with JS disabled.
13 */
14
15import { Hono } from "hono";
16import { eq, and, desc } from "drizzle-orm";
17import { db } from "../db";
18import { aiChats, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { requireAuth, softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { chat, explainFile } from "../lib/ai-chat";
23import type { ChatMessage } from "../lib/ai-chat";
24import { getUnreadCount } from "../lib/unread";
25import { isAiAvailable } from "../lib/ai-client";
26
27const ask = new Hono<AuthEnv>();
28ask.use("*", softAuth);
29
30function loadMessages(raw: string | null | undefined): ChatMessage[] {
31 if (!raw) return [];
32 try {
33 const parsed = JSON.parse(raw);
34 if (Array.isArray(parsed)) {
35 return parsed.filter(
36 (m): m is ChatMessage =>
37 m && typeof m === "object" && typeof m.content === "string" &&
38 (m.role === "user" || m.role === "assistant")
39 );
40 }
41 } catch {
42 /* ignore */
43 }
44 return [];
45}
46
47function renderChatView(
48 c: any,
49 {
50 messages,
51 postUrl,
52 title,
53 subtitle,
54 placeholder,
55 recentChats,
56 user,
57 unreadCount,
58 }: {
59 messages: ChatMessage[];
60 postUrl: string;
61 title: string;
62 subtitle?: string;
63 placeholder: string;
64 recentChats?: Array<{ id: string; title: string | null; updatedAt: Date }>;
65 user: any;
66 unreadCount: number;
67 }
68) {
69 return c.html(
70 <Layout title={title} user={user} notificationCount={unreadCount}>
71 <div class="ask-container">
72 <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px">
73 <h2>{title}</h2>
74 {!isAiAvailable() && (
75 <span class="badge" style="color: var(--yellow); border-color: var(--yellow)">
76 AI unavailable — set ANTHROPIC_API_KEY
77 </span>
78 )}
79 </div>
80 {subtitle && (
81 <p style="color: var(--text-muted); margin-bottom: 16px">{subtitle}</p>
82 )}
83
84 <div class="chat-log">
85 {messages.length === 0 ? (
86 <div class="panel-empty">
87 Ask anything. Reference files with @path/to/file.ext.
88 </div>
89 ) : (
90 messages.map((m) => (
91 <div class={`chat-message ${m.role}`}>
92 <div class="role">{m.role === "user" ? "You" : "GlueCron AI"}</div>
93 {m.content}
94 </div>
95 ))
96 )}
97 </div>
98
99 <form method="POST" action={postUrl} class="chat-form">
100 <textarea
101 name="message"
102 placeholder={placeholder}
103 required
104 autofocus
105 ></textarea>
106 <div
107 style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px"
108 >
109 <div class="chat-hint">
110 {"\u21B5"} Enter + Ctrl/Cmd to send. Mention files with
111 <span class="chat-cited" style="margin-left: 4px">@src/file.ts</span>
112 </div>
113 <button type="submit" class="btn btn-primary">
114 Send
115 </button>
116 </div>
117 </form>
118
119 {recentChats && recentChats.length > 0 && (
120 <div style="margin-top: 32px">
121 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px">
122 Recent chats
123 </h3>
124 <div class="panel">
125 {recentChats.map((ch) => (
126 <div class="panel-item">
127 <div class="dot blue"></div>
128 <div style="flex: 1">
129 <a href={`/ask/${ch.id}`}>
130 {ch.title || "(untitled)"}
131 </a>
132 <div class="meta">
133 {new Date(ch.updatedAt).toLocaleString()}
134 </div>
135 </div>
136 </div>
137 ))}
138 </div>
139 </div>
140 )}
141 </div>
142 </Layout>
143 );
144}
145
146// Backwards-compat alias retained so external imports keep working.
147const ChatView = renderChatView;
148
149async function resumeChat(
150 userId: string,
151 chatId: string
152): Promise<{
153 messages: ChatMessage[];
154 repoOwner: string | null;
155 repoName: string | null;
156} | null> {
157 try {
158 const [row] = await db
159 .select({
160 messages: aiChats.messages,
161 userId: aiChats.userId,
162 repositoryId: aiChats.repositoryId,
163 })
164 .from(aiChats)
165 .where(eq(aiChats.id, chatId))
166 .limit(1);
167 if (!row || row.userId !== userId) return null;
168
169 let repoOwner: string | null = null;
170 let repoName: string | null = null;
171 if (row.repositoryId) {
172 const [repo] = await db
173 .select({ name: repositories.name, username: users.username })
174 .from(repositories)
175 .innerJoin(users, eq(repositories.ownerId, users.id))
176 .where(eq(repositories.id, row.repositoryId))
177 .limit(1);
178 if (repo) {
179 repoOwner = repo.username;
180 repoName = repo.name;
181 }
182 }
183
184 return {
185 messages: loadMessages(row.messages),
186 repoOwner,
187 repoName,
188 };
189 } catch {
190 return null;
191 }
192}
193
194async function appendMessage(opts: {
195 userId: string;
196 chatId: string | null;
197 repositoryId: string | null;
198 userMessage: string;
199 aiReply: string;
200 history: ChatMessage[];
201 title: string;
202}): Promise<string> {
203 const newHistory: ChatMessage[] = [
204 ...opts.history,
205 { role: "user", content: opts.userMessage },
206 { role: "assistant", content: opts.aiReply },
207 ];
208 try {
209 if (opts.chatId) {
210 await db
211 .update(aiChats)
212 .set({ messages: JSON.stringify(newHistory), updatedAt: new Date() })
213 .where(eq(aiChats.id, opts.chatId));
214 return opts.chatId;
215 }
216 const [row] = await db
217 .insert(aiChats)
218 .values({
219 userId: opts.userId,
220 repositoryId: opts.repositoryId ?? undefined,
221 title: opts.title.slice(0, 80),
222 messages: JSON.stringify(newHistory),
223 })
224 .returning();
225 return row?.id || "";
226 } catch (err) {
227 console.error("[ask] persist failed:", err);
228 return opts.chatId || "";
229 }
230}
231
232// ---------- Global assistant ----------
233
234ask.get("/ask", requireAuth, async (c) => {
235 const user = c.get("user")!;
236 const unread = await getUnreadCount(user.id);
237 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
238 try {
239 recent = await db
240 .select({
241 id: aiChats.id,
242 title: aiChats.title,
243 updatedAt: aiChats.updatedAt,
244 })
245 .from(aiChats)
246 .where(eq(aiChats.userId, user.id))
247 .orderBy(desc(aiChats.updatedAt))
248 .limit(10);
249 } catch {
250 /* ignore */
251 }
252
253 return renderChatView(c, {
254 messages: [],
255 postUrl: "/ask",
256 title: "Ask AI",
257 subtitle:
258 "Ask about GlueCron, your repos, or paste a diff to review. Claude is grounded in your repo when visiting /:owner/:repo/ask.",
259 placeholder: "Ask anything...",
260 recentChats: recent,
261 user,
262 unreadCount: unread,
263 });
264});
265
266ask.get("/ask/:chatId", requireAuth, async (c) => {
267 const user = c.get("user")!;
268 const chatId = c.req.param("chatId");
269 const resumed = await resumeChat(user.id, chatId);
270 if (!resumed) return c.redirect("/ask");
271 const unread = await getUnreadCount(user.id);
272 return renderChatView(c, {
273 messages: resumed.messages,
274 postUrl:
275 resumed.repoOwner && resumed.repoName
276 ? `/${resumed.repoOwner}/${resumed.repoName}/ask?chatId=${chatId}`
277 : `/ask?chatId=${chatId}`,
278 title:
279 resumed.repoOwner && resumed.repoName
280 ? `${resumed.repoOwner}/${resumed.repoName} — AI chat`
281 : "Ask AI",
282 placeholder: "Continue the conversation...",
283 user,
284 unreadCount: unread,
285 });
286});
287
288ask.post("/ask", requireAuth, async (c) => {
289 const user = c.get("user")!;
290 const body = await c.req.parseBody();
291 const userMessage = String(body.message || "").trim();
292 const chatId = (c.req.query("chatId") || "").trim();
293 if (!userMessage) return c.redirect("/ask");
294
295 let history: ChatMessage[] = [];
296 if (chatId) {
297 const existing = await resumeChat(user.id, chatId);
298 if (existing) history = existing.messages;
299 }
300
301 const response = await chat(user.username, null, history, userMessage);
302 const nextId = await appendMessage({
303 userId: user.id,
304 chatId: chatId || null,
305 repositoryId: null,
306 userMessage,
307 aiReply: response.reply,
308 history,
309 title: userMessage,
310 });
311
312 return c.redirect(nextId ? `/ask/${nextId}` : "/ask");
313});
314
315// ---------- Repo-grounded assistant ----------
316
317ask.get("/:owner/:repo/ask", requireAuth, async (c) => {
318 const user = c.get("user")!;
319 const { owner, repo } = c.req.param();
320
321 // Verify repo exists
322 const [repoRow] = await db
323 .select({ id: repositories.id })
324 .from(repositories)
325 .innerJoin(users, eq(repositories.ownerId, users.id))
326 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
327 .limit(1);
328 if (!repoRow) return c.notFound();
329
330 const unread = await getUnreadCount(user.id);
331
332 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
333 try {
334 recent = await db
335 .select({
336 id: aiChats.id,
337 title: aiChats.title,
338 updatedAt: aiChats.updatedAt,
339 })
340 .from(aiChats)
341 .where(
342 and(
343 eq(aiChats.userId, user.id),
344 eq(aiChats.repositoryId, repoRow.id)
345 )
346 )
347 .orderBy(desc(aiChats.updatedAt))
348 .limit(10);
349 } catch {
350 /* ignore */
351 }
352
353 return renderChatView(c, {
354 messages: [],
355 postUrl: `/${owner}/${repo}/ask`,
356 title: `Ask about ${owner}/${repo}`,
357 subtitle:
358 "Claude has access to this repository's README, tree, and recent commits. Reference files with @path/to/file.",
359 placeholder: `Ask about ${repo}...`,
360 recentChats: recent,
361 user,
362 unreadCount: unread,
363 });
364});
365
366ask.post("/:owner/:repo/ask", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo } = c.req.param();
369 const body = await c.req.parseBody();
370 const userMessage = String(body.message || "").trim();
371 const chatId = (c.req.query("chatId") || "").trim();
372 if (!userMessage) return c.redirect(`/${owner}/${repo}/ask`);
373
374 const [repoRow] = await db
375 .select({ id: repositories.id })
376 .from(repositories)
377 .innerJoin(users, eq(repositories.ownerId, users.id))
378 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
379 .limit(1);
380 if (!repoRow) return c.notFound();
381
382 let history: ChatMessage[] = [];
383 if (chatId) {
384 const existing = await resumeChat(user.id, chatId);
385 if (existing) history = existing.messages;
386 }
387
388 const response = await chat(owner, repo, history, userMessage);
389 const nextId = await appendMessage({
390 userId: user.id,
391 chatId: chatId || null,
392 repositoryId: repoRow.id,
393 userMessage,
394 aiReply: response.reply,
395 history,
396 title: userMessage,
397 });
398
399 return c.redirect(
400 nextId ? `/ask/${nextId}` : `/${owner}/${repo}/ask`
401 );
402});
403
404// ---------- Explain-this-file helper ----------
405
406ask.post("/:owner/:repo/explain", requireAuth, async (c) => {
407 const { owner, repo } = c.req.param();
408 const body = await c.req.parseBody().catch(() => ({}));
409 const filePath = String((body as any).file || c.req.query("file") || "");
410 const ref = String((body as any).ref || c.req.query("ref") || "");
411 if (!filePath || !ref) {
412 return c.json({ error: "file and ref required" }, 400);
413 }
414
415 const { getBlob } = await import("../git/repository");
416 const blob = await getBlob(owner, repo, ref, filePath);
417 if (!blob || blob.isBinary) {
418 return c.json({ error: "file not found or binary" }, 404);
419 }
420
421 const explanation = await explainFile(owner, repo, filePath, blob.content);
422 return c.json({ explanation });
423});
424
425export default ask;
Addedsrc/routes/audit.tsx+234−0View fileUnifiedSplit
1/**
2 * Audit log UI — personal audit (who has done what with *my* account) and
3 * per-repo audit (who has done what in *my* repo). Reads the `audit_log`
4 * table written by `src/lib/notify.ts#audit()`.
5 */
6
7import { Hono } from "hono";
8import { desc, eq, and } from "drizzle-orm";
9import { db } from "../db";
10import { auditLog, repositories, users } from "../db/schema";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import { Layout } from "../views/layout";
14
15const audit = new Hono<AuthEnv>();
16
17audit.use("/settings/audit", requireAuth);
18audit.use("/:owner/:repo/settings/audit", requireAuth);
19
20const LIMIT = 200;
21
22type AuditRow = {
23 id: string;
24 action: string;
25 targetType: string | null;
26 targetId: string | null;
27 ip: string | null;
28 userAgent: string | null;
29 metadata: string | null;
30 createdAt: Date;
31 actor: string | null;
32};
33
34function renderMetadata(raw: string | null): string {
35 if (!raw) return "";
36 try {
37 const parsed = JSON.parse(raw);
38 return JSON.stringify(parsed);
39 } catch {
40 return raw;
41 }
42}
43
44function timeAgo(d: Date): string {
45 const ms = Date.now() - d.getTime();
46 const s = Math.floor(ms / 1000);
47 if (s < 60) return `${s}s ago`;
48 const m = Math.floor(s / 60);
49 if (m < 60) return `${m}m ago`;
50 const h = Math.floor(m / 60);
51 if (h < 24) return `${h}h ago`;
52 const days = Math.floor(h / 24);
53 if (days < 30) return `${days}d ago`;
54 return d.toISOString().slice(0, 10);
55}
56
57function AuditTable({ rows }: { rows: AuditRow[] }) {
58 if (rows.length === 0) {
59 return (
60 <div class="empty-state">
61 <h2>No audit events yet</h2>
62 <p>Sensitive actions will appear here as they happen.</p>
63 </div>
64 );
65 }
66 return (
67 <div class="audit-log">
68 <table class="audit-table">
69 <thead>
70 <tr>
71 <th>When</th>
72 <th>Actor</th>
73 <th>Action</th>
74 <th>Target</th>
75 <th>IP</th>
76 <th>Details</th>
77 </tr>
78 </thead>
79 <tbody>
80 {rows.map((r) => (
81 <tr>
82 <td class="audit-when" title={r.createdAt.toISOString()}>
83 {timeAgo(new Date(r.createdAt))}
84 </td>
85 <td>{r.actor || <span class="audit-muted">system</span>}</td>
86 <td>
87 <code class="audit-action">{r.action}</code>
88 </td>
89 <td class="audit-target">
90 {r.targetType ? (
91 <span>
92 {r.targetType}
93 {r.targetId ? <code> {r.targetId.slice(0, 8)}</code> : null}
94 </span>
95 ) : (
96 <span class="audit-muted"></span>
97 )}
98 </td>
99 <td>
100 <code class="audit-ip">{r.ip || "—"}</code>
101 </td>
102 <td class="audit-meta">
103 {r.metadata ? (
104 <code title={r.metadata}>{renderMetadata(r.metadata).slice(0, 80)}</code>
105 ) : (
106 <span class="audit-muted"></span>
107 )}
108 </td>
109 </tr>
110 ))}
111 </tbody>
112 </table>
113 </div>
114 );
115}
116
117// Personal audit — events where userId = current user.
118audit.get("/settings/audit", async (c) => {
119 const user = c.get("user")!;
120 let rows: AuditRow[] = [];
121 try {
122 const raw = await db
123 .select({
124 id: auditLog.id,
125 action: auditLog.action,
126 targetType: auditLog.targetType,
127 targetId: auditLog.targetId,
128 ip: auditLog.ip,
129 userAgent: auditLog.userAgent,
130 metadata: auditLog.metadata,
131 createdAt: auditLog.createdAt,
132 actor: users.username,
133 })
134 .from(auditLog)
135 .leftJoin(users, eq(users.id, auditLog.userId))
136 .where(eq(auditLog.userId, user.id))
137 .orderBy(desc(auditLog.createdAt))
138 .limit(LIMIT);
139 rows = raw as AuditRow[];
140 } catch (err) {
141 console.error("[audit] personal:", err);
142 }
143
144 return c.html(
145 <Layout title="Audit log" user={user}>
146 <div class="settings-container" style="max-width: 1000px">
147 <h2>Audit log</h2>
148 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
149 The most recent {LIMIT} sensitive actions tied to your account — logins,
150 token activity, merges, deploys, branch protection changes.
151 </p>
152 <AuditTable rows={rows} />
153 </div>
154 </Layout>
155 );
156});
157
158// Per-repo audit — events with repositoryId = this repo. Owner-only.
159audit.get("/:owner/:repo/settings/audit", async (c) => {
160 const user = c.get("user")!;
161 const { owner, repo } = c.req.param();
162
163 let repoRow: { id: string; ownerId: string; name: string } | null = null;
164 try {
165 const [r] = await db
166 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
167 .from(repositories)
168 .innerJoin(users, eq(users.id, repositories.ownerId))
169 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
170 .limit(1);
171 repoRow = (r as any) || null;
172 } catch (err) {
173 console.error("[audit] repo lookup:", err);
174 }
175 if (!repoRow) return c.notFound();
176 if (repoRow.ownerId !== user.id) {
177 return c.html(
178 <Layout title="Audit log" user={user}>
179 <div class="empty-state">
180 <h2>Forbidden</h2>
181 <p>Only the repository owner can view the audit log.</p>
182 </div>
183 </Layout>,
184 403
185 );
186 }
187
188 let rows: AuditRow[] = [];
189 try {
190 const raw = await db
191 .select({
192 id: auditLog.id,
193 action: auditLog.action,
194 targetType: auditLog.targetType,
195 targetId: auditLog.targetId,
196 ip: auditLog.ip,
197 userAgent: auditLog.userAgent,
198 metadata: auditLog.metadata,
199 createdAt: auditLog.createdAt,
200 actor: users.username,
201 })
202 .from(auditLog)
203 .leftJoin(users, eq(users.id, auditLog.userId))
204 .where(eq(auditLog.repositoryId, repoRow.id))
205 .orderBy(desc(auditLog.createdAt))
206 .limit(LIMIT);
207 rows = raw as AuditRow[];
208 } catch (err) {
209 console.error("[audit] repo:", err);
210 }
211
212 return c.html(
213 <Layout title={`${owner}/${repo} — audit`} user={user}>
214 <div class="settings-container" style="max-width: 1000px">
215 <div class="breadcrumb">
216 <a href={`/${owner}/${repo}`}>
217 {owner}/{repo}
218 </a>
219 <span>/</span>
220 <a href={`/${owner}/${repo}/settings`}>settings</a>
221 <span>/</span>
222 <span>audit</span>
223 </div>
224 <h2>Audit log</h2>
225 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
226 Who did what in <code>{owner}/{repo}</code> — most recent {LIMIT} events.
227 </p>
228 <AuditTable rows={rows} />
229 </div>
230 </Layout>
231 );
232});
233
234export default audit;
Modifiedsrc/routes/auth.tsx+266−4View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
88import { db } from "../db";
9import { users, sessions } from "../db/schema";
9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
1016import {
1117 hashPassword,
1218 verifyPassword,
1420 sessionCookieOptions,
1521 sessionExpiry,
1622} from "../lib/auth";
23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
24import { getSsoConfig } from "../lib/sso";
1725import { Layout } from "../views/layout";
1826import {
1927 Form,
110118 return c.redirect("/register?error=Username+already+taken");
111119 }
112120
121 // B2: usernames share the URL namespace with org slugs; refuse collisions.
122 const [existingOrg] = await db
123 .select({ id: organizations.id })
124 .from(organizations)
125 .where(eq(organizations.slug, username.toLowerCase()))
126 .limit(1);
127 if (existingOrg) {
128 return c.redirect("/register?error=Username+already+taken");
129 }
130
113131 const [existingEmail] = await db
114132 .select()
115133 .from(users)
140158 return c.redirect(redirect);
141159});
142160
143auth.get("/login", (c) => {
161auth.get("/login", async (c) => {
144162 const error = c.req.query("error");
145163 const redirect = c.req.query("redirect") || "";
164 const ssoCfg = await getSsoConfig();
165 const ssoEnabled =
166 !!ssoCfg?.enabled &&
167 !!ssoCfg.authorizationEndpoint &&
168 !!ssoCfg.tokenEndpoint &&
169 !!ssoCfg.userinfoEndpoint &&
170 !!ssoCfg.clientId &&
171 !!ssoCfg.clientSecret;
172 const ssoLabel = ssoCfg?.providerName || "SSO";
146173 return c.html(
147174 <Layout title="Sign in">
148175 <div class="auth-container">
176203 <p class="auth-switch">
177204 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
178205 </p>
206 <script
207 dangerouslySetInnerHTML={{
208 __html: /* js */ `
209 (function () {
210 const btn = document.getElementById('pk-signin-btn');
211 const status = document.getElementById('pk-signin-status');
212 const userInput = document.getElementById('username');
213 const redirect = ${JSON.stringify(redirect || "/")};
214 if (!btn) return;
215 function b64uToBuf(s) {
216 s = s.replace(/-/g,'+').replace(/_/g,'/');
217 while (s.length % 4) s += '=';
218 const bin = atob(s);
219 const buf = new Uint8Array(bin.length);
220 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
221 return buf.buffer;
222 }
223 function bufToB64u(buf) {
224 const bytes = new Uint8Array(buf);
225 let bin = '';
226 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
227 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
228 }
229 btn.addEventListener('click', async function () {
230 if (!window.PublicKeyCredential) {
231 status.textContent = 'Passkeys not supported in this browser.';
232 return;
233 }
234 status.textContent = 'Preparing…';
235 try {
236 const username = (userInput && userInput.value || '').trim();
237 const optsRes = await fetch('/api/passkeys/auth/options', {
238 method: 'POST',
239 headers: { 'content-type': 'application/json' },
240 body: JSON.stringify(username ? { username: username } : {})
241 });
242 if (!optsRes.ok) throw new Error('options failed');
243 const { options, sessionKey } = await optsRes.json();
244 options.challenge = b64uToBuf(options.challenge);
245 if (options.allowCredentials) {
246 options.allowCredentials = options.allowCredentials.map(function (c) {
247 return Object.assign({}, c, { id: b64uToBuf(c.id) });
248 });
249 }
250 status.textContent = 'Touch your authenticator…';
251 const cred = await navigator.credentials.get({ publicKey: options });
252 const resp = {
253 id: cred.id,
254 rawId: bufToB64u(cred.rawId),
255 type: cred.type,
256 response: {
257 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
258 authenticatorData: bufToB64u(cred.response.authenticatorData),
259 signature: bufToB64u(cred.response.signature),
260 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
261 },
262 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
263 };
264 const verifyRes = await fetch('/api/passkeys/auth/verify', {
265 method: 'POST',
266 headers: { 'content-type': 'application/json' },
267 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
268 });
269 if (!verifyRes.ok) {
270 const j = await verifyRes.json().catch(function () { return {}; });
271 throw new Error(j.error || 'verify failed');
272 }
273 status.textContent = 'Signed in. Redirecting…';
274 window.location.href = redirect;
275 } catch (e) {
276 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
277 }
278 });
279 })();
280 `,
281 }}
282 />
179283 </div>
180284 </Layout>
181285 );
212316 return c.redirect("/login?error=Invalid+credentials");
213317 }
214318
319 // B4: if the user has TOTP enabled, issue a pending-2fa session and
320 // redirect to the code prompt.
321 const [totp] = await db
322 .select({ enabledAt: userTotp.enabledAt })
323 .from(userTotp)
324 .where(eq(userTotp.userId, user.id))
325 .limit(1);
326 const needs2fa = !!(totp && totp.enabledAt);
327
215328 const token = generateSessionToken();
216329 await db.insert(sessions).values({
217330 userId: user.id,
218331 token,
219332 expiresAt: sessionExpiry(),
333 requires2fa: needs2fa,
220334 });
221335
222336 setCookie(c, "session", token, sessionCookieOptions());
337 if (needs2fa) {
338 return c.redirect(
339 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
340 );
341 }
223342 return c.redirect(redirect);
224343});
225344
345// --- 2FA verify (B4) ---
346auth.get("/login/2fa", async (c) => {
347 const token = getCookie(c, "session");
348 if (!token) return c.redirect("/login");
349 const error = c.req.query("error");
350 const redirect = c.req.query("redirect") || "/";
351 return c.html(
352 <Layout title="Two-factor authentication">
353 <div class="auth-container">
354 <h2>Enter your code</h2>
355 <p
356 class="auth-switch"
357 style="margin-bottom: 16px; margin-top: 0"
358 >
359 Open your authenticator app and enter the 6-digit code. Lost your
360 device? Paste a recovery code instead.
361 </p>
362 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
363 <form
364 method="POST"
365 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
366 >
367 <div class="form-group">
368 <label for="code">Code</label>
369 <input
370 type="text"
371 id="code"
372 name="code"
373 required
374 autocomplete="one-time-code"
375 inputmode="numeric"
376 maxLength={24}
377 placeholder="123456 or xxxx-xxxx-xxxx"
378 />
379 </div>
380 <button type="submit" class="btn btn-primary">
381 Verify
382 </button>
383 </form>
384 <p class="auth-switch">
385 <a href="/logout">Cancel</a>
386 </p>
387 </div>
388 </Layout>
389 );
390});
391
392auth.post("/login/2fa", async (c) => {
393 const token = getCookie(c, "session");
394 if (!token) return c.redirect("/login");
395 const body = await c.req.parseBody();
396 const code = String(body.code || "").trim();
397 const redirect = c.req.query("redirect") || "/";
398
399 if (!code) {
400 return c.redirect(
401 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
402 );
403 }
404
405 try {
406 const [session] = await db
407 .select()
408 .from(sessions)
409 .where(eq(sessions.token, token))
410 .limit(1);
411 if (
412 !session ||
413 new Date(session.expiresAt) < new Date() ||
414 !session.requires2fa
415 ) {
416 return c.redirect("/login");
417 }
418
419 const [totp] = await db
420 .select()
421 .from(userTotp)
422 .where(eq(userTotp.userId, session.userId))
423 .limit(1);
424 if (!totp || !totp.enabledAt) {
425 // User doesn't have 2FA actually enabled — clear the flag and let
426 // them in. This can only happen if 2FA was disabled in another
427 // session between password check and code prompt.
428 await db
429 .update(sessions)
430 .set({ requires2fa: false })
431 .where(eq(sessions.token, token));
432 return c.redirect(redirect);
433 }
434
435 // Try TOTP code first.
436 const isSix = /^\d{6}$/.test(code);
437 let ok = false;
438 if (isSix) {
439 ok = await verifyTotpCode(totp.secret, code);
440 }
441 // Fall through to recovery code.
442 if (!ok) {
443 const hash = await hashRecoveryCode(code);
444 const [rec] = await db
445 .select()
446 .from(userRecoveryCodes)
447 .where(
448 and(
449 eq(userRecoveryCodes.userId, session.userId),
450 eq(userRecoveryCodes.codeHash, hash),
451 isNull(userRecoveryCodes.usedAt)
452 )
453 )
454 .limit(1);
455 if (rec) {
456 await db
457 .update(userRecoveryCodes)
458 .set({ usedAt: new Date() })
459 .where(eq(userRecoveryCodes.id, rec.id));
460 ok = true;
461 }
462 }
463
464 if (!ok) {
465 return c.redirect(
466 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
467 );
468 }
469
470 await db
471 .update(sessions)
472 .set({ requires2fa: false })
473 .where(eq(sessions.token, token));
474 await db
475 .update(userTotp)
476 .set({ lastUsedAt: new Date() })
477 .where(eq(userTotp.userId, session.userId));
478
479 return c.redirect(redirect);
480 } catch (err) {
481 console.error("[auth] 2fa verify:", err);
482 return c.redirect(
483 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
484 );
485 }
486});
487
226488auth.get("/logout", async (c) => {
227489 deleteCookie(c, "session", { path: "/" });
228490 return c.redirect("/");
Addedsrc/routes/billing.tsx+259−0View fileUnifiedSplit
1/**
2 * Block F4 — Billing + quota UI.
3 *
4 * GET /settings/billing — personal quota view + plan table
5 * GET /admin/billing — site admin: user list + overrides
6 * POST /admin/billing/:userId/plan — set user's plan (audit-logged)
7 *
8 * All read operations degrade gracefully if the billing tables are empty
9 * (FALLBACK_PLANS in lib/billing.ts mirror the seed rows). Plan assignment
10 * is site-admin only; there is no self-service purchase flow here — that's
11 * Stripe's job, and deliberately out-of-scope for the v1 panel.
12 */
13
14import { Hono } from "hono";
15import { desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { users, userQuotas } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { audit } from "../lib/notify";
23import {
24 formatPrice,
25 getUserQuota,
26 listPlans,
27 setUserPlan,
28} from "../lib/billing";
29
30const billing = new Hono<AuthEnv>();
31billing.use("*", softAuth);
32
33// ----- Personal billing page -----
34
35billing.get("/settings/billing", requireAuth, async (c) => {
36 const user = c.get("user")!;
37 const [quota, plans] = await Promise.all([
38 getUserQuota(user.id),
39 listPlans(),
40 ]);
41
42 const bar = (pct: number) => {
43 const color = pct >= 90 ? "var(--red)" : pct >= 70 ? "#f0b72f" : "var(--green)";
44 return (
45 <div
46 style="background:var(--bg-secondary);height:8px;border-radius:4px;overflow:hidden"
47 >
48 <div
49 style={`width:${pct}%;height:100%;background:${color};transition:width .2s`}
50 />
51 </div>
52 );
53 };
54
55 return c.html(
56 <Layout title="Billing — Gluecron" user={user}>
57 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
58 <h2>Billing & usage</h2>
59 <a href="/settings" class="btn btn-sm">
60 Back to settings
61 </a>
62 </div>
63
64 <div class="panel" style="padding:16px;margin-bottom:20px">
65 <div style="display:flex;justify-content:space-between;align-items:center">
66 <div>
67 <div style="font-size:12px;color:var(--text-muted);text-transform:uppercase">
68 Current plan
69 </div>
70 <div style="font-size:22px;font-weight:700">{quota.plan.name}</div>
71 <div style="font-size:13px;color:var(--text-muted)">
72 {formatPrice(quota.plan.priceCents)}
73 </div>
74 </div>
75 <div style="text-align:right;font-size:12px;color:var(--text-muted)">
76 {quota.cycleStart
77 ? `Cycle started ${new Date(quota.cycleStart).toLocaleDateString()}`
78 : "No cycle recorded"}
79 </div>
80 </div>
81 </div>
82
83 <h3>Usage this cycle</h3>
84 <div class="panel" style="padding:16px;margin-bottom:20px">
85 <div class="form-group">
86 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
87 <span>Storage</span>
88 <span style="color:var(--text-muted);font-family:var(--font-mono)">
89 {quota.usage.storageMbUsed} / {quota.plan.storageMbLimit} MB
90 </span>
91 </div>
92 {bar(quota.percent.storage)}
93 </div>
94 <div class="form-group">
95 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
96 <span>AI tokens (monthly)</span>
97 <span style="color:var(--text-muted);font-family:var(--font-mono)">
98 {quota.usage.aiTokensUsedThisMonth.toLocaleString()} /{" "}
99 {quota.plan.aiTokensMonthly.toLocaleString()}
100 </span>
101 </div>
102 {bar(quota.percent.aiTokens)}
103 </div>
104 <div class="form-group" style="margin-bottom:0">
105 <div style="display:flex;justify-content:space-between;margin-bottom:4px">
106 <span>Bandwidth (monthly)</span>
107 <span style="color:var(--text-muted);font-family:var(--font-mono)">
108 {quota.usage.bandwidthGbUsedThisMonth} /{" "}
109 {quota.plan.bandwidthGbMonthly} GB
110 </span>
111 </div>
112 {bar(quota.percent.bandwidth)}
113 </div>
114 </div>
115
116 <h3>Available plans</h3>
117 <div
118 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px"
119 >
120 {plans.map((p) => {
121 const isCurrent = p.slug === quota.planSlug;
122 return (
123 <div
124 class="panel"
125 style={`padding:16px${isCurrent ? ";border-color:var(--green)" : ""}`}
126 >
127 <div style="font-size:16px;font-weight:700">{p.name}</div>
128 <div style="font-size:18px;margin:6px 0">
129 {formatPrice(p.priceCents)}
130 </div>
131 <div style="font-size:12px;color:var(--text-muted);line-height:1.6">
132 <div>{p.repoLimit.toLocaleString()} repos</div>
133 <div>{p.storageMbLimit.toLocaleString()} MB storage</div>
134 <div>{p.aiTokensMonthly.toLocaleString()} AI tokens/mo</div>
135 <div>{p.bandwidthGbMonthly} GB bandwidth/mo</div>
136 <div>
137 {p.privateRepos ? "Private repos ✓" : "Public repos only"}
138 </div>
139 </div>
140 {isCurrent && (
141 <div
142 style="margin-top:10px;font-size:11px;color:var(--green);font-weight:600"
143 >
144 CURRENT PLAN
145 </div>
146 )}
147 </div>
148 );
149 })}
150 </div>
151 <p style="font-size:12px;color:var(--text-muted);margin-top:12px">
152 To change plans, contact a site administrator.
153 </p>
154 </Layout>
155 );
156});
157
158// ----- Admin billing panel -----
159
160billing.get("/admin/billing", async (c) => {
161 const user = c.get("user");
162 if (!user) return c.redirect("/login?next=/admin/billing");
163 if (!(await isSiteAdmin(user.id))) {
164 return c.html(
165 <Layout title="Forbidden" user={user}>
166 <div class="empty-state">
167 <h2>403 — Not a site admin</h2>
168 </div>
169 </Layout>,
170 403
171 );
172 }
173
174 const plans = await listPlans();
175 const rows = await db
176 .select({
177 id: users.id,
178 username: users.username,
179 planSlug: userQuotas.planSlug,
180 storageMbUsed: userQuotas.storageMbUsed,
181 aiTokensUsedThisMonth: userQuotas.aiTokensUsedThisMonth,
182 })
183 .from(users)
184 .leftJoin(userQuotas, eq(users.id, userQuotas.userId))
185 .orderBy(desc(users.createdAt))
186 .limit(200);
187
188 return c.html(
189 <Layout title="Admin — Billing" user={user}>
190 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
191 <h2>Billing — all users</h2>
192 <a href="/admin" class="btn btn-sm">
193 Back
194 </a>
195 </div>
196 <div class="panel">
197 {rows.length === 0 ? (
198 <div class="panel-empty">No users.</div>
199 ) : (
200 rows.map((r) => (
201 <div class="panel-item" style="justify-content:space-between">
202 <div style="flex:1;min-width:0">
203 <a href={`/${r.username}`} style="font-weight:600">
204 {r.username}
205 </a>
206 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
207 Plan: <strong>{r.planSlug || "free"}</strong> ·{" "}
208 {r.storageMbUsed || 0} MB ·{" "}
209 {(r.aiTokensUsedThisMonth || 0).toLocaleString()} tokens
210 </div>
211 </div>
212 <form
213 method="POST"
214 action={`/admin/billing/${r.id}/plan`}
215 style="display:flex;gap:6px;align-items:center"
216 >
217 <select name="slug" style="font-size:12px">
218 {plans.map((p) => (
219 <option
220 value={p.slug}
221 selected={(r.planSlug || "free") === p.slug}
222 >
223 {p.name}
224 </option>
225 ))}
226 </select>
227 <button type="submit" class="btn btn-sm">
228 Set
229 </button>
230 </form>
231 </div>
232 ))
233 )}
234 </div>
235 </Layout>
236 );
237});
238
239billing.post("/admin/billing/:userId/plan", async (c) => {
240 const user = c.get("user");
241 if (!user) return c.redirect("/login?next=/admin/billing");
242 if (!(await isSiteAdmin(user.id))) {
243 return c.text("Forbidden", 403);
244 }
245 const userId = c.req.param("userId");
246 const body = await c.req.parseBody();
247 const slug = String(body.slug || "free");
248 await setUserPlan(userId, slug);
249 await audit({
250 userId: user.id,
251 action: "admin.billing.set_plan",
252 targetType: "user",
253 targetId: userId,
254 metadata: { plan: slug },
255 });
256 return c.redirect("/admin/billing");
257});
258
259export default billing;
Addedsrc/routes/code-scanning.tsx+221−0View fileUnifiedSplit
1/**
2 * Block I5 — Code scanning UI.
3 *
4 * GET /:owner/:repo/security
5 *
6 * Aggregates gate_runs where the gate name contains "scan" (Secret scan,
7 * Security scan, Dependency scan) and presents them as a clean alerts
8 * dashboard. Data already exists — this is a surfacing layer only.
9 */
10
11import { Hono } from "hono";
12import { and, desc, eq, or, sql } from "drizzle-orm";
13import { db } from "../db";
14import { gateRuns, repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader, RepoNav } from "../views/components";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19
20const codeScanning = new Hono<AuthEnv>();
21codeScanning.use("*", softAuth);
22
23codeScanning.get("/:owner/:repo/security", async (c) => {
24 const { owner: ownerName, repo: repoName } = c.req.param();
25 const user = c.get("user");
26
27 const [ownerUser] = await db
28 .select()
29 .from(users)
30 .where(eq(users.username, ownerName))
31 .limit(1);
32 if (!ownerUser) return c.notFound();
33
34 const [repo] = await db
35 .select()
36 .from(repositories)
37 .where(
38 and(
39 eq(repositories.ownerId, ownerUser.id),
40 eq(repositories.name, repoName)
41 )
42 )
43 .limit(1);
44 if (!repo) return c.notFound();
45 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
46 return c.notFound();
47 }
48
49 // Pull the most recent 100 scan-related gate runs.
50 const runs = await db
51 .select()
52 .from(gateRuns)
53 .where(
54 and(
55 eq(gateRuns.repositoryId, repo.id),
56 or(
57 sql`lower(${gateRuns.gateName}) like '%scan%'`,
58 sql`lower(${gateRuns.gateName}) like '%security%'`
59 )!
60 )
61 )
62 .orderBy(desc(gateRuns.createdAt))
63 .limit(100);
64
65 // Summarize: latest status per gate, total alerts (failed + repaired).
66 const latestByName = new Map<
67 string,
68 { status: string; summary: string | null; sha: string; at: Date }
69 >();
70 for (const r of runs) {
71 if (!latestByName.has(r.gateName)) {
72 latestByName.set(r.gateName, {
73 status: r.status,
74 summary: r.summary,
75 sha: r.commitSha,
76 at: r.createdAt,
77 });
78 }
79 }
80
81 const failed = runs.filter((r) => r.status === "failed").length;
82 const repaired = runs.filter((r) => r.status === "repaired").length;
83
84 return c.html(
85 <Layout title={`Security — ${ownerName}/${repoName}`} user={user}>
86 <RepoHeader
87 owner={ownerName}
88 repo={repoName}
89 currentUser={user?.username}
90 archived={repo.isArchived}
91 isTemplate={repo.isTemplate}
92 />
93 <RepoNav owner={ownerName} repo={repoName} active="gates" />
94
95 <div style="display:flex;gap:12px;margin:20px 0">
96 <div
97 class="panel"
98 style="flex:1;padding:16px;text-align:center"
99 >
100 <div style="font-size:28px;font-weight:700">
101 {latestByName.size}
102 </div>
103 <div style="font-size:12px;color:var(--text-muted)">
104 Configured scanners
105 </div>
106 </div>
107 <div
108 class="panel"
109 style="flex:1;padding:16px;text-align:center"
110 >
111 <div
112 style={`font-size:28px;font-weight:700;color:${failed > 0 ? "var(--red)" : "var(--text)"}`}
113 >
114 {failed}
115 </div>
116 <div style="font-size:12px;color:var(--text-muted)">
117 Failed runs (last 100)
118 </div>
119 </div>
120 <div
121 class="panel"
122 style="flex:1;padding:16px;text-align:center"
123 >
124 <div style="font-size:28px;font-weight:700;color:var(--green)">
125 {repaired}
126 </div>
127 <div style="font-size:12px;color:var(--text-muted)">
128 Auto-repaired
129 </div>
130 </div>
131 </div>
132
133 <h3>Scanner status</h3>
134 <div class="panel" style="margin-bottom:20px">
135 {latestByName.size === 0 ? (
136 <div class="panel-empty">
137 No scan runs yet. Push a commit to trigger scanners.
138 </div>
139 ) : (
140 Array.from(latestByName.entries()).map(([name, info]) => (
141 <div class="panel-item" style="justify-content:space-between">
142 <div>
143 <div style="font-weight:600">{name}</div>
144 <div
145 style="font-size:12px;color:var(--text-muted);margin-top:2px"
146 >
147 {info.summary || "no summary"}
148 </div>
149 </div>
150 <div style="text-align:right">
151 <span
152 style={`font-size:11px;text-transform:uppercase;padding:2px 8px;border-radius:10px;background:${statusColor(info.status)};color:white`}
153 >
154 {info.status}
155 </span>
156 <div
157 style="font-size:11px;color:var(--text-muted);margin-top:4px"
158 >
159 <code>{info.sha.slice(0, 7)}</code> ·{" "}
160 {info.at.toLocaleDateString()}
161 </div>
162 </div>
163 </div>
164 ))
165 )}
166 </div>
167
168 <h3>Recent runs</h3>
169 <div class="panel">
170 {runs.length === 0 ? (
171 <div class="panel-empty">No runs.</div>
172 ) : (
173 runs.slice(0, 50).map((r) => (
174 <div class="panel-item" style="justify-content:space-between">
175 <div style="flex:1;min-width:0">
176 <code style="font-size:12px">{r.commitSha.slice(0, 7)}</code>{" "}
177 <span style="font-size:13px">{r.gateName}</span>
178 {r.summary && (
179 <div
180 style="font-size:12px;color:var(--text-muted);margin-top:2px"
181 >
182 {r.summary}
183 </div>
184 )}
185 </div>
186 <div style="text-align:right;white-space:nowrap">
187 <span
188 style={`font-size:11px;text-transform:uppercase;padding:2px 8px;border-radius:10px;background:${statusColor(r.status)};color:white`}
189 >
190 {r.status}
191 </span>
192 <div
193 style="font-size:11px;color:var(--text-muted);margin-top:2px"
194 >
195 {r.createdAt.toLocaleString()}
196 </div>
197 </div>
198 </div>
199 ))
200 )}
201 </div>
202 </Layout>
203 );
204});
205
206function statusColor(status: string): string {
207 switch (status) {
208 case "passed":
209 return "var(--green)";
210 case "failed":
211 return "var(--red)";
212 case "repaired":
213 return "var(--accent)";
214 case "skipped":
215 return "var(--text-muted)";
216 default:
217 return "var(--text-muted)";
218 }
219}
220
221export default codeScanning;
Addedsrc/routes/commit-statuses.ts+149−0View fileUnifiedSplit
1/**
2 * Block J8 — Commit status API (GitHub-parity).
3 *
4 * External CI / automation systems POST statuses against a (repo, sha, context)
5 * triple. Reads are public for public repos (softAuth visibility check) and
6 * writes require repo-owner auth (session / OAuth / PAT accepted by
7 * requireAuth).
8 *
9 * POST /api/v1/repos/:owner/:repo/statuses/:sha
10 * body: { state, context?, description?, target_url? }
11 * 200: { ok: true, status }
12 * 400: invalid state / sha
13 * 401/403: auth / permission
14 *
15 * GET /api/v1/repos/:owner/:repo/commits/:sha/statuses
16 * 200: { total, statuses: [...] }
17 *
18 * GET /api/v1/repos/:owner/:repo/commits/:sha/status
19 * 200: { state, total, counts, contexts }
20 */
21
22import { Hono } from "hono";
23import { and, eq } from "drizzle-orm";
24import { db } from "../db";
25import { repositories, users } from "../db/schema";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 combinedStatus,
30 isValidSha,
31 isValidState,
32 listStatuses,
33 setStatus,
34} from "../lib/commit-statuses";
35
36const statuses = new Hono<AuthEnv>();
37
38async function resolveRepo(ownerName: string, repoName: string) {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
50 )
51 .limit(1);
52 if (!repo) return null;
53 return { owner, repo };
54}
55
56// ---------------------------------------------------------------------------
57// POST status
58// ---------------------------------------------------------------------------
59statuses.post(
60 "/api/v1/repos/:owner/:repo/statuses/:sha",
61 softAuth,
62 requireAuth,
63 async (c) => {
64 const { owner: ownerName, repo: repoName, sha } = c.req.param();
65 const user = c.get("user")!;
66 if (!isValidSha(sha)) {
67 return c.json({ error: "Invalid sha" }, 400);
68 }
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.json({ error: "Repository not found" }, 404);
71 if (resolved.owner.id !== user.id) {
72 return c.json({ error: "Forbidden" }, 403);
73 }
74
75 let body: any = {};
76 try {
77 body = await c.req.json();
78 } catch {
79 body = {};
80 }
81
82 const state = body.state;
83 if (!isValidState(state)) {
84 return c.json(
85 {
86 error:
87 "Invalid state; must be one of pending, success, failure, error",
88 },
89 400
90 );
91 }
92
93 const row = await setStatus({
94 repositoryId: resolved.repo.id,
95 commitSha: sha,
96 state,
97 context: body.context ?? body.Context ?? "default",
98 description: body.description ?? null,
99 targetUrl: body.target_url ?? body.targetUrl ?? null,
100 creatorId: user.id,
101 });
102
103 if (!row) return c.json({ error: "Could not save status" }, 500);
104
105 return c.json({ ok: true, status: row });
106 }
107);
108
109// ---------------------------------------------------------------------------
110// GET statuses list
111// ---------------------------------------------------------------------------
112statuses.get(
113 "/api/v1/repos/:owner/:repo/commits/:sha/statuses",
114 softAuth,
115 async (c) => {
116 const { owner: ownerName, repo: repoName, sha } = c.req.param();
117 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
118 const resolved = await resolveRepo(ownerName, repoName);
119 if (!resolved) return c.json({ error: "Repository not found" }, 404);
120 const user = c.get("user");
121 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
122 return c.json({ error: "Forbidden" }, 403);
123 }
124 const rows = await listStatuses(resolved.repo.id, sha);
125 return c.json({ total: rows.length, statuses: rows });
126 }
127);
128
129// ---------------------------------------------------------------------------
130// GET combined status
131// ---------------------------------------------------------------------------
132statuses.get(
133 "/api/v1/repos/:owner/:repo/commits/:sha/status",
134 softAuth,
135 async (c) => {
136 const { owner: ownerName, repo: repoName, sha } = c.req.param();
137 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
138 const resolved = await resolveRepo(ownerName, repoName);
139 if (!resolved) return c.json({ error: "Repository not found" }, 404);
140 const user = c.get("user");
141 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
142 return c.json({ error: "Forbidden" }, 403);
143 }
144 const combined = await combinedStatus(resolved.repo.id, sha);
145 return c.json(combined);
146 }
147);
148
149export default statuses;
Addedsrc/routes/copilot.ts+75−0View fileUnifiedSplit
1/**
2 * Block D9 — Copilot-style completion HTTP surface.
3 *
4 * POST /api/copilot/completions (authed — PAT / OAuth / session)
5 * Body: { prefix, suffix?, language?, repo? }
6 * Returns: { completion, model, cached }
7 *
8 * GET /api/copilot/ping (unauthed)
9 * Returns: { ok: true, aiAvailable: boolean }
10 *
11 * Keep per-user limits tight: IDE plugins call this on every keystroke, so a
12 * misbehaving client can otherwise exhaust the shared Anthropic quota fast.
13 */
14
15import { Hono } from "hono";
16import { requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import { rateLimit } from "../middleware/rate-limit";
19import { completeCode } from "../lib/ai-completion";
20import { isAiAvailable } from "../lib/ai-client";
21
22const copilot = new Hono<AuthEnv>();
23
24// Tight per-caller limit: 60/min. NOTE: `rateLimit` keys by bearer-token
25// prefix when Authorization is present, otherwise by IP — so session-cookie
26// callers share a single IP bucket. That's acceptable for an IDE endpoint
27// where the expected caller is almost always a PAT/OAuth token.
28const completionLimit = rateLimit({
29 windowMs: 60_000,
30 max: 60,
31 prefix: "copilot",
32});
33
34copilot.get("/api/copilot/ping", (c) => {
35 return c.json({ ok: true, aiAvailable: isAiAvailable() });
36});
37
38copilot.post(
39 "/api/copilot/completions",
40 completionLimit,
41 requireAuth,
42 async (c) => {
43 let body: any;
44 try {
45 body = await c.req.json();
46 } catch {
47 return c.json({ error: "invalid JSON body" }, 400);
48 }
49 if (!body || typeof body !== "object") {
50 return c.json({ error: "body must be a JSON object" }, 400);
51 }
52
53 const { prefix, suffix, language, repo } = body as {
54 prefix?: unknown;
55 suffix?: unknown;
56 language?: unknown;
57 repo?: unknown;
58 };
59
60 if (typeof prefix !== "string" || prefix.length === 0) {
61 return c.json({ error: "prefix (non-empty string) is required" }, 400);
62 }
63
64 const result = await completeCode({
65 prefix,
66 suffix: typeof suffix === "string" ? suffix : undefined,
67 language: typeof language === "string" ? language : undefined,
68 repoHint: typeof repo === "string" ? repo : undefined,
69 });
70
71 return c.json(result);
72 }
73);
74
75export default copilot;
Addedsrc/routes/dashboard.tsx+449−0View fileUnifiedSplit
1/**
2 * Dashboard — the authed user's home page.
3 *
4 * Shows:
5 * - Unread notifications (top 5 with link to full inbox)
6 * - PRs awaiting your review
7 * - PRs you authored that are open
8 * - Issues assigned to you (currently "authored by you" until assignments land)
9 * - Recent activity across your repos
10 * - Your repositories
11 * - Gate health summary across all your repos
12 *
13 * Rendered at `/dashboard`. The `/` route still calls this for logged-in users.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 activityFeed,
21 gateRuns,
22 issues,
23 notifications,
24 pullRequests,
25 repositories,
26 users,
27} from "../db/schema";
28import { Layout } from "../views/layout";
29import { RepoCard } from "../views/components";
30import { requireAuth, softAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { getUnreadCount } from "../lib/unread";
33
34const dashboard = new Hono<AuthEnv>();
35dashboard.use("*", softAuth);
36
37function relTime(d: Date | string): string {
38 const t = typeof d === "string" ? new Date(d) : d;
39 const diffMs = Date.now() - t.getTime();
40 const mins = Math.floor(diffMs / 60000);
41 if (mins < 1) return "just now";
42 if (mins < 60) return `${mins}m ago`;
43 const hrs = Math.floor(mins / 60);
44 if (hrs < 24) return `${hrs}h ago`;
45 const days = Math.floor(hrs / 24);
46 if (days < 30) return `${days}d ago`;
47 return t.toLocaleDateString();
48}
49
50export async function renderDashboard(c: any) {
51 const user = c.get("user")!;
52 const unreadCount = await getUnreadCount(user.id);
53
54 // User's repositories
55 const myRepos = await db
56 .select()
57 .from(repositories)
58 .where(eq(repositories.ownerId, user.id))
59 .orderBy(desc(repositories.updatedAt))
60 .limit(12);
61
62 const myRepoIds = myRepos.map((r) => r.id);
63
64 // Unread notifications (top 5)
65 let recentNotifications: Array<{
66 id: string;
67 kind: string;
68 title: string;
69 url: string | null;
70 createdAt: Date;
71 }> = [];
72 try {
73 recentNotifications = await db
74 .select({
75 id: notifications.id,
76 kind: notifications.kind,
77 title: notifications.title,
78 url: notifications.url,
79 createdAt: notifications.createdAt,
80 })
81 .from(notifications)
82 .where(
83 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
84 )
85 .orderBy(desc(notifications.createdAt))
86 .limit(5);
87 } catch {
88 /* ignore */
89 }
90
91 // Open PRs you authored
92 let myPrs: Array<{
93 id: string;
94 number: number;
95 title: string;
96 state: string;
97 repoName: string;
98 repoOwner: string;
99 createdAt: Date;
100 }> = [];
101 try {
102 myPrs = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 createdAt: pullRequests.createdAt,
111 })
112 .from(pullRequests)
113 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
114 .innerJoin(users, eq(repositories.ownerId, users.id))
115 .where(
116 and(
117 eq(pullRequests.authorId, user.id),
118 eq(pullRequests.state, "open")
119 )
120 )
121 .orderBy(desc(pullRequests.updatedAt))
122 .limit(10);
123 } catch {
124 /* ignore */
125 }
126
127 // PRs in your repos awaiting review (open, not authored by you)
128 let reviewablePrs: Array<{
129 id: string;
130 number: number;
131 title: string;
132 repoName: string;
133 repoOwner: string;
134 createdAt: Date;
135 }> = [];
136 if (myRepoIds.length > 0) {
137 try {
138 reviewablePrs = await db
139 .select({
140 id: pullRequests.id,
141 number: pullRequests.number,
142 title: pullRequests.title,
143 repoName: repositories.name,
144 repoOwner: users.username,
145 createdAt: pullRequests.createdAt,
146 })
147 .from(pullRequests)
148 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
149 .innerJoin(users, eq(repositories.ownerId, users.id))
150 .where(
151 and(
152 inArray(pullRequests.repositoryId, myRepoIds),
153 eq(pullRequests.state, "open")
154 )
155 )
156 .orderBy(desc(pullRequests.updatedAt))
157 .limit(10);
158 } catch {
159 /* ignore */
160 }
161 }
162
163 // Issues you authored that are still open
164 let myIssues: Array<{
165 id: string;
166 number: number;
167 title: string;
168 repoName: string;
169 repoOwner: string;
170 createdAt: Date;
171 }> = [];
172 try {
173 myIssues = await db
174 .select({
175 id: issues.id,
176 number: issues.number,
177 title: issues.title,
178 repoName: repositories.name,
179 repoOwner: users.username,
180 createdAt: issues.createdAt,
181 })
182 .from(issues)
183 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
184 .innerJoin(users, eq(repositories.ownerId, users.id))
185 .where(and(eq(issues.authorId, user.id), eq(issues.state, "open")))
186 .orderBy(desc(issues.updatedAt))
187 .limit(10);
188 } catch {
189 /* ignore */
190 }
191
192 // Recent activity across user's repos
193 let recentActivity: Array<{
194 id: string;
195 action: string;
196 repoName: string;
197 repoOwner: string;
198 createdAt: Date;
199 }> = [];
200 if (myRepoIds.length > 0) {
201 try {
202 recentActivity = await db
203 .select({
204 id: activityFeed.id,
205 action: activityFeed.action,
206 repoName: repositories.name,
207 repoOwner: users.username,
208 createdAt: activityFeed.createdAt,
209 })
210 .from(activityFeed)
211 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
212 .innerJoin(users, eq(repositories.ownerId, users.id))
213 .where(inArray(activityFeed.repositoryId, myRepoIds))
214 .orderBy(desc(activityFeed.createdAt))
215 .limit(15);
216 } catch {
217 /* ignore */
218 }
219 }
220
221 // Gate health across your repos (last 20 runs)
222 let gateHealth: Array<{
223 gateName: string;
224 status: string;
225 repoName: string;
226 repoOwner: string;
227 createdAt: Date;
228 summary: string | null;
229 }> = [];
230 if (myRepoIds.length > 0) {
231 try {
232 gateHealth = await db
233 .select({
234 gateName: gateRuns.gateName,
235 status: gateRuns.status,
236 repoName: repositories.name,
237 repoOwner: users.username,
238 createdAt: gateRuns.createdAt,
239 summary: gateRuns.summary,
240 })
241 .from(gateRuns)
242 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
243 .innerJoin(users, eq(repositories.ownerId, users.id))
244 .where(inArray(gateRuns.repositoryId, myRepoIds))
245 .orderBy(desc(gateRuns.createdAt))
246 .limit(10);
247 } catch {
248 /* ignore */
249 }
250 }
251
252 const greenCount = gateHealth.filter(
253 (g) => g.status === "passed" || g.status === "repaired" || g.status === "skipped"
254 ).length;
255 const redCount = gateHealth.filter((g) => g.status === "failed").length;
256
257 return c.html(
258 <Layout title="Dashboard" user={user} notificationCount={unreadCount}>
259 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
260 <h2>Welcome back, {user.displayName || user.username}</h2>
261 <a href="/new" class="btn btn-primary">
262 + New repository
263 </a>
264 </div>
265
266 <div class="dashboard-grid">
267 {/* Left column */}
268 <div>
269 <div class="dashboard-section">
270 <h3>
271 Needs your attention
272 <a href="/notifications">view all</a>
273 </h3>
274 <div class="panel">
275 {recentNotifications.length === 0 ? (
276 <div class="panel-empty">Inbox zero. Nice work.</div>
277 ) : (
278 recentNotifications.map((n) => (
279 <div class="panel-item">
280 <div class="dot blue"></div>
281 <div style="flex: 1">
282 {n.url ? (
283 <a href={n.url}>{n.title}</a>
284 ) : (
285 <span>{n.title}</span>
286 )}
287 <div class="meta">
288 {n.kind} · {relTime(n.createdAt)}
289 </div>
290 </div>
291 </div>
292 ))
293 )}
294 </div>
295 </div>
296
297 <div class="dashboard-section">
298 <h3>PRs awaiting review in your repos</h3>
299 <div class="panel">
300 {reviewablePrs.length === 0 ? (
301 <div class="panel-empty">No open PRs in your repositories.</div>
302 ) : (
303 reviewablePrs.map((pr) => (
304 <div class="panel-item">
305 <div class="dot green"></div>
306 <div style="flex: 1">
307 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
308 {pr.title}
309 </a>
310 <div class="meta">
311 {pr.repoOwner}/{pr.repoName}#{pr.number} · opened{" "}
312 {relTime(pr.createdAt)}
313 </div>
314 </div>
315 </div>
316 ))
317 )}
318 </div>
319 </div>
320
321 <div class="dashboard-section">
322 <h3>Your open PRs</h3>
323 <div class="panel">
324 {myPrs.length === 0 ? (
325 <div class="panel-empty">You have no open PRs.</div>
326 ) : (
327 myPrs.map((pr) => (
328 <div class="panel-item">
329 <div class="dot blue"></div>
330 <div style="flex: 1">
331 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
332 {pr.title}
333 </a>
334 <div class="meta">
335 {pr.repoOwner}/{pr.repoName}#{pr.number} ·{" "}
336 {relTime(pr.createdAt)}
337 </div>
338 </div>
339 </div>
340 ))
341 )}
342 </div>
343 </div>
344
345 <div class="dashboard-section">
346 <h3>Your open issues</h3>
347 <div class="panel">
348 {myIssues.length === 0 ? (
349 <div class="panel-empty">No open issues.</div>
350 ) : (
351 myIssues.map((i) => (
352 <div class="panel-item">
353 <div class="dot yellow"></div>
354 <div style="flex: 1">
355 <a href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}>
356 {i.title}
357 </a>
358 <div class="meta">
359 {i.repoOwner}/{i.repoName}#{i.number} ·{" "}
360 {relTime(i.createdAt)}
361 </div>
362 </div>
363 </div>
364 ))
365 )}
366 </div>
367 </div>
368 </div>
369
370 {/* Right column */}
371 <div>
372 <div class="dashboard-section">
373 <h3>Gate health</h3>
374 <div class="panel" style="padding: 16px">
375 <div style="display: flex; gap: 12px; margin-bottom: 12px">
376 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius)">
377 <div style="font-size: 24px; font-weight: 700; color: var(--green)">
378 {greenCount}
379 </div>
380 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
381 green
382 </div>
383 </div>
384 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(248, 81, 73, 0.1); border-radius: var(--radius)">
385 <div style="font-size: 24px; font-weight: 700; color: var(--red)">
386 {redCount}
387 </div>
388 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
389 failed
390 </div>
391 </div>
392 </div>
393 <div style="font-size: 12px; color: var(--text-muted); text-align: center">
394 Last 10 gate runs across your repos
395 </div>
396 </div>
397 </div>
398
399 <div class="dashboard-section">
400 <h3>Recent activity</h3>
401 <div class="panel">
402 {recentActivity.length === 0 ? (
403 <div class="panel-empty">No activity yet.</div>
404 ) : (
405 recentActivity.map((a) => (
406 <div class="panel-item">
407 <div
408 class={`dot ${a.action === "push" ? "green" : a.action === "pr_merge" ? "blue" : "yellow"}`}
409 ></div>
410 <div style="flex: 1">
411 <a href={`/${a.repoOwner}/${a.repoName}`}>
412 {a.repoOwner}/{a.repoName}
413 </a>{" "}
414 <span style="color: var(--text-muted)">{a.action}</span>
415 <div class="meta">{relTime(a.createdAt)}</div>
416 </div>
417 </div>
418 ))
419 )}
420 </div>
421 </div>
422 </div>
423 </div>
424
425 <div class="dashboard-section" style="margin-top: 32px">
426 <h3>
427 Your repositories
428 <a href={`/${user.username}`}>view all</a>
429 </h3>
430 {myRepos.length === 0 ? (
431 <div class="empty-state">
432 <h2>No repositories yet</h2>
433 <p>Create your first repository to get started.</p>
434 </div>
435 ) : (
436 <div class="card-grid">
437 {myRepos.map((repo) => (
438 <RepoCard repo={repo} ownerName={user.username} />
439 ))}
440 </div>
441 )}
442 </div>
443 </Layout>
444 );
445}
446
447dashboard.get("/dashboard", requireAuth, (c) => renderDashboard(c));
448
449export default dashboard;
Addedsrc/routes/dep-updater.tsx+274−0View fileUnifiedSplit
1/**
2 * Block D2 — AI dependency updater UI.
3 *
4 * GET /:owner/:repo/settings/dep-updater — run history + "Run now"
5 * POST /:owner/:repo/settings/dep-updater/run — kicks off a run (fire & forget)
6 *
7 * Owner-only. See `src/lib/dep-updater.ts` for the orchestrator.
8 */
9
10import { Hono } from "hono";
11import { eq, and, desc } from "drizzle-orm";
12import { db } from "../db";
13import { depUpdateRuns, repositories, users } from "../db/schema";
14import { Layout } from "../views/layout";
15import { RepoHeader } from "../views/components";
16import { IssueNav } from "./issues";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { runDepUpdateRun, type Bump } from "../lib/dep-updater";
20
21const depUpdater = new Hono<AuthEnv>();
22
23depUpdater.use("*", softAuth);
24
25/**
26 * Resolve repo row + enforce owner-only access. Returns either a
27 * rendered Response (when unauthorised / missing) or `{ repo }`.
28 */
29async function resolveOwnerRepo(
30 c: any,
31 ownerName: string,
32 repoName: string
33): Promise<
34 | { kind: "ok"; repo: typeof repositories.$inferSelect }
35 | { kind: "response"; res: Response }
36> {
37 const user = c.get("user");
38 if (!user) {
39 return {
40 kind: "response",
41 res: c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`),
42 };
43 }
44
45 try {
46 const [owner] = await db
47 .select()
48 .from(users)
49 .where(eq(users.username, ownerName))
50 .limit(1);
51 if (!owner) return { kind: "response", res: c.notFound() };
52 if (owner.id !== user.id) {
53 return {
54 kind: "response",
55 res: c.html(
56 <Layout title="Unauthorized" user={user}>
57 <div class="empty-state">
58 <h2>Unauthorized</h2>
59 <p>Only the repository owner can configure the dependency updater.</p>
60 </div>
61 </Layout>,
62 403
63 ),
64 };
65 }
66 const [repo] = await db
67 .select()
68 .from(repositories)
69 .where(
70 and(
71 eq(repositories.ownerId, owner.id),
72 eq(repositories.name, repoName)
73 )
74 )
75 .limit(1);
76 if (!repo) return { kind: "response", res: c.notFound() };
77 return { kind: "ok", repo };
78 } catch (err) {
79 // DB unreachable — let the global 503 / 500 handler cope.
80 return {
81 kind: "response",
82 res: c.html(
83 <Layout title="Error" user={user}>
84 <div class="empty-state">
85 <h2>Service unavailable</h2>
86 <p>The dependency updater is temporarily offline.</p>
87 </div>
88 </Layout>,
89 503
90 ),
91 };
92 }
93}
94
95function statusChip(status: string) {
96 const colorMap: Record<string, string> = {
97 success: "badge-open",
98 no_updates: "badge-closed",
99 failed: "badge-closed",
100 running: "badge-open",
101 pending: "badge-closed",
102 };
103 const cls = colorMap[status] || "badge-closed";
104 return <span class={`issue-badge ${cls}`}>{status}</span>;
105}
106
107function safeParseBumps(raw: string): Bump[] {
108 try {
109 const v = JSON.parse(raw || "[]");
110 return Array.isArray(v) ? v : [];
111 } catch {
112 return [];
113 }
114}
115
116// GET — run history + "Run now"
117depUpdater.get(
118 "/:owner/:repo/settings/dep-updater",
119 requireAuth,
120 async (c) => {
121 const { owner: ownerName, repo: repoName } = c.req.param();
122 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
123 if (resolved.kind === "response") return resolved.res;
124 const { repo } = resolved;
125 const user = c.get("user")!;
126
127 let runs: Array<typeof depUpdateRuns.$inferSelect> = [];
128 try {
129 runs = await db
130 .select()
131 .from(depUpdateRuns)
132 .where(eq(depUpdateRuns.repositoryId, repo.id))
133 .orderBy(desc(depUpdateRuns.createdAt))
134 .limit(20);
135 } catch {
136 runs = [];
137 }
138
139 return c.html(
140 <Layout title={`Dep Updater — ${ownerName}/${repoName}`} user={user}>
141 <RepoHeader owner={ownerName} repo={repoName} />
142 <IssueNav owner={ownerName} repo={repoName} active="code" />
143 <div style="max-width: 900px">
144 <h2 style="margin-bottom: 8px">Dependency updater</h2>
145 <p style="color: var(--text-muted); margin-bottom: 20px">
146 Reads <code>package.json</code> on the default branch, queries the
147 npm registry for newer versions, and opens a pull request with the
148 bumped dependencies. Runs on-demand from this page; background
149 scheduling can be added later.
150 </p>
151
152 <form
153 method="POST"
154 action={`/${ownerName}/${repoName}/settings/dep-updater/run`}
155 style="margin-bottom: 24px"
156 >
157 <button type="submit" class="btn btn-primary">
158 Run now
159 </button>
160 </form>
161
162 <h3 style="margin: 24px 0 8px; font-size: 16px">Recent runs</h3>
163 {runs.length === 0 ? (
164 <div class="empty-state">
165 <p>No runs yet. Click "Run now" to start your first scan.</p>
166 </div>
167 ) : (
168 <div class="issue-list">
169 {runs.map((r) => {
170 const applied = safeParseBumps(r.appliedBumps);
171 const attempted = safeParseBumps(r.attemptedBumps);
172 const bumps = applied.length > 0 ? applied : attempted;
173 const when = new Date(r.createdAt).toLocaleString();
174 return (
175 <div
176 class="issue-row"
177 style="padding: 12px; border-bottom: 1px solid var(--border)"
178 >
179 <div style="display: flex; align-items: center; gap: 8px">
180 {statusChip(r.status)}
181 <span style="color: var(--text-muted); font-size: 13px">
182 {when}
183 </span>
184 {r.prNumber != null && (
185 <a
186 href={`/${ownerName}/${repoName}/pulls/${r.prNumber}`}
187 style="font-size: 13px"
188 >
189 PR #{r.prNumber}
190 </a>
191 )}
192 {r.branchName && (
193 <code style="font-size: 12px; color: var(--text-muted)">
194 {r.branchName}
195 </code>
196 )}
197 </div>
198 {r.errorMessage && (
199 <div
200 style="margin-top: 6px; font-size: 13px; color: var(--red)"
201 >
202 {r.errorMessage}
203 </div>
204 )}
205 {bumps.length > 0 && (
206 <details style="margin-top: 8px">
207 <summary style="cursor: pointer; font-size: 13px">
208 {bumps.length} bump{bumps.length === 1 ? "" : "s"}
209 </summary>
210 <table style="margin-top: 8px; font-size: 13px; width: 100%">
211 <thead>
212 <tr style="text-align: left; color: var(--text-muted)">
213 <th style="padding: 4px 8px">Package</th>
214 <th style="padding: 4px 8px">From</th>
215 <th style="padding: 4px 8px">To</th>
216 <th style="padding: 4px 8px">Kind</th>
217 <th style="padding: 4px 8px">Major?</th>
218 </tr>
219 </thead>
220 <tbody>
221 {bumps.map((b) => (
222 <tr>
223 <td style="padding: 4px 8px">
224 <code>{b.name}</code>
225 </td>
226 <td style="padding: 4px 8px">{b.from}</td>
227 <td style="padding: 4px 8px">{b.to}</td>
228 <td style="padding: 4px 8px">{b.kind}</td>
229 <td style="padding: 4px 8px">
230 {b.major ? "yes" : "no"}
231 </td>
232 </tr>
233 ))}
234 </tbody>
235 </table>
236 </details>
237 )}
238 </div>
239 );
240 })}
241 </div>
242 )}
243 </div>
244 </Layout>
245 );
246 }
247);
248
249// POST — fire and forget
250depUpdater.post(
251 "/:owner/:repo/settings/dep-updater/run",
252 requireAuth,
253 async (c) => {
254 const { owner: ownerName, repo: repoName } = c.req.param();
255 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
256 if (resolved.kind === "response") return resolved.res;
257 const { repo } = resolved;
258 const user = c.get("user")!;
259
260 // Fire-and-forget. The run records its own failures to `dep_update_runs`.
261 runDepUpdateRun({
262 repositoryId: repo.id,
263 owner: ownerName,
264 repo: repoName,
265 userId: user.id,
266 }).catch((err) => {
267 console.error("[dep-updater] run failed:", err);
268 });
269
270 return c.redirect(`/${ownerName}/${repoName}/settings/dep-updater`);
271 }
272);
273
274export default depUpdater;
Addedsrc/routes/deployments.tsx+374−0View fileUnifiedSplit
1/**
2 * Environments + deployment history UI.
3 *
4 * Routes:
5 * GET /:owner/:repo/deployments full deploy history per env
6 * GET /:owner/:repo/deployments/:id single deployment detail
7 *
8 * Data comes from the `deployments` table populated by Crontech / gate
9 * logic on successful push to the default branch.
10 */
11
12import { Hono } from "hono";
13import { desc, eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { deployments, repositories, users } from "../db/schema";
16import type { AuthEnv } from "../middleware/auth";
17import { softAuth, requireAuth } from "../middleware/auth";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { onDeployFailure } from "../lib/ai-incident";
21
22const dep = new Hono<AuthEnv>();
23
24dep.use("/:owner/:repo/deployments", softAuth);
25dep.use("/:owner/:repo/deployments/*", softAuth);
26
27type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
28
29async function resolveRepo(owner: string, name: string) {
30 try {
31 const [row] = await db
32 .select({
33 id: repositories.id,
34 name: repositories.name,
35 ownerId: repositories.ownerId,
36 })
37 .from(repositories)
38 .innerJoin(users, eq(users.id, repositories.ownerId))
39 .where(and(eq(users.username, owner), eq(repositories.name, name)))
40 .limit(1);
41 return row || null;
42 } catch {
43 return null;
44 }
45}
46
47/** Parse "auto-issue #42" from a blockedReason string. Returns null if absent. */
48function parseAutoIssueNumber(blockedReason: string | null): number | null {
49 if (!blockedReason) return null;
50 const m = blockedReason.match(/auto-issue #(\d+)/);
51 return m ? parseInt(m[1], 10) : null;
52}
53
54function statusBadgeClass(status: string): string {
55 switch (status) {
56 case "success":
57 return "gate-status passed";
58 case "failed":
59 return "gate-status failed";
60 case "blocked":
61 return "gate-status skipped";
62 case "running":
63 case "pending":
64 return "gate-status running";
65 default:
66 return "gate-status";
67 }
68}
69
70function fmtTs(t: Date | null | undefined): string {
71 if (!t) return "—";
72 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
73}
74
75function groupByEnv(rows: Row[]): Record<string, Row[]> {
76 const out: Record<string, Row[]> = {};
77 for (const r of rows) {
78 (out[r.environment] ||= []).push(r);
79 }
80 return out;
81}
82
83function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } {
84 const last = rows[0];
85 const recent = rows.slice(0, 20);
86 const successes = recent.filter((r) => r.status === "success").length;
87 const rate = recent.length ? successes / recent.length : 1;
88 return { last, successRate: rate };
89}
90
91dep.get("/:owner/:repo/deployments", async (c) => {
92 const { owner, repo } = c.req.param();
93 const user = c.get("user");
94 const repoRow = await resolveRepo(owner, repo);
95 if (!repoRow) return c.notFound();
96
97 let rows: Row[] = [];
98 try {
99 rows = (await db
100 .select({
101 id: deployments.id,
102 repositoryId: deployments.repositoryId,
103 environment: deployments.environment,
104 commitSha: deployments.commitSha,
105 ref: deployments.ref,
106 status: deployments.status,
107 blockedReason: deployments.blockedReason,
108 target: deployments.target,
109 triggeredBy: deployments.triggeredBy,
110 createdAt: deployments.createdAt,
111 completedAt: deployments.completedAt,
112 triggeredByName: users.username,
113 })
114 .from(deployments)
115 .leftJoin(users, eq(users.id, deployments.triggeredBy))
116 .where(eq(deployments.repositoryId, repoRow.id))
117 .orderBy(desc(deployments.createdAt))
118 .limit(500)) as Row[];
119 } catch (err) {
120 console.error("[deployments] list:", err);
121 }
122
123 const envs = groupByEnv(rows);
124 const envNames = Object.keys(envs).sort();
125
126 return c.html(
127 <Layout title={`${owner}/${repo} — deployments`} user={user}>
128 <RepoHeader owner={owner} repo={repo} />
129 <div style="max-width: 1000px">
130 <h2>Deployments</h2>
131 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
132 Every deploy to every environment, newest first. Rolled up by
133 environment with the latest status and success rate across the last
134 20 runs.
135 </p>
136
137 {envNames.length === 0 && (
138 <div class="empty-state">
139 <h2>No deployments yet</h2>
140 <p>
141 When a green push reaches the default branch and a deploy
142 target is configured, the deploy will show up here.
143 </p>
144 </div>
145 )}
146
147 {envNames.map((env) => {
148 const envRows = envs[env];
149 const { last, successRate } = envSummary(envRows);
150 return (
151 <div
152 style="border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); margin-bottom: 16px; overflow: hidden"
153 >
154 <div
155 style="padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; background: var(--bg-tertiary); border-bottom: 1px solid var(--border)"
156 >
157 <h3 style="margin: 0; font-size: 15px">{env}</h3>
158 <div style="display: flex; gap: 12px; align-items: center; font-size: 12px">
159 {last && (
160 <span class={statusBadgeClass(last.status)}>
161 {last.status}
162 </span>
163 )}
164 <span style="color: var(--text-muted)">
165 {Math.round(successRate * 100)}% green · {envRows.length} total
166 </span>
167 </div>
168 </div>
169 <div class="gate-list" style="border: none; border-radius: 0">
170 {envRows.slice(0, 10).map((r) => (
171 <div class="gate-run-row">
172 <span class={statusBadgeClass(r.status)}>{r.status}</span>
173 <code
174 style="font-family: var(--font-mono); font-size: 12px"
175 >
176 {r.commitSha.slice(0, 7)}
177 </code>
178 <span style="color: var(--text-muted); font-size: 12px">
179 {r.ref.replace(/^refs\/heads\//, "")}
180 </span>
181 <span style="color: var(--text-muted); font-size: 12px; margin-left: auto">
182 {r.target || "—"}
183 </span>
184 <span style="color: var(--text-muted); font-size: 12px">
185 by {r.triggeredByName || "system"}
186 </span>
187 <span style="color: var(--text-muted); font-size: 12px">
188 {fmtTs(r.createdAt)}
189 </span>
190 <a
191 href={`/${owner}/${repo}/deployments/${r.id}`}
192 style="font-size: 12px"
193 >
194 details
195 </a>
196 </div>
197 ))}
198 {envRows.length > 10 && (
199 <div class="gate-run-row" style="color: var(--text-muted); font-size: 12px">
200 + {envRows.length - 10} more{"\u2026"}
201 </div>
202 )}
203 </div>
204 </div>
205 );
206 })}
207 </div>
208 </Layout>
209 );
210});
211
212dep.get("/:owner/:repo/deployments/:id", async (c) => {
213 const { owner, repo, id } = c.req.param();
214 const user = c.get("user");
215 const repoRow = await resolveRepo(owner, repo);
216 if (!repoRow) return c.notFound();
217
218 let row: Row | null = null;
219 try {
220 const [r] = await db
221 .select({
222 id: deployments.id,
223 repositoryId: deployments.repositoryId,
224 environment: deployments.environment,
225 commitSha: deployments.commitSha,
226 ref: deployments.ref,
227 status: deployments.status,
228 blockedReason: deployments.blockedReason,
229 target: deployments.target,
230 triggeredBy: deployments.triggeredBy,
231 createdAt: deployments.createdAt,
232 completedAt: deployments.completedAt,
233 triggeredByName: users.username,
234 })
235 .from(deployments)
236 .leftJoin(users, eq(users.id, deployments.triggeredBy))
237 .where(
238 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
239 )
240 .limit(1);
241 row = (r as Row) || null;
242 } catch (err) {
243 console.error("[deployments] detail:", err);
244 }
245
246 if (!row) return c.notFound();
247
248 return c.html(
249 <Layout
250 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
251 user={user}
252 >
253 <RepoHeader owner={owner} repo={repo} />
254 <div style="max-width: 700px">
255 <div class="breadcrumb">
256 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
257 <span>/</span>
258 <span>{row.id.slice(0, 8)}</span>
259 </div>
260 <h2>
261 <span class={statusBadgeClass(row.status)}>{row.status}</span>{" "}
262 <span style="font-family: var(--font-mono); font-size: 16px">
263 {row.commitSha.slice(0, 7)}
264 </span>{" "}
265 <span style="color: var(--text-muted); font-weight: 400">
266 &rarr; {row.environment}
267 </span>
268 </h2>
269 <table class="audit-table" style="margin-top: 16px">
270 <tbody>
271 <tr>
272 <th style="width: 140px">Target</th>
273 <td>{row.target || "—"}</td>
274 </tr>
275 <tr>
276 <th>Ref</th>
277 <td>
278 <code>{row.ref}</code>
279 </td>
280 </tr>
281 <tr>
282 <th>Commit</th>
283 <td>
284 <a href={`/${owner}/${repo}/commit/${row.commitSha}`}>
285 <code>{row.commitSha}</code>
286 </a>
287 </td>
288 </tr>
289 <tr>
290 <th>Triggered by</th>
291 <td>{row.triggeredByName || "system"}</td>
292 </tr>
293 <tr>
294 <th>Created</th>
295 <td>{fmtTs(row.createdAt)}</td>
296 </tr>
297 <tr>
298 <th>Completed</th>
299 <td>{fmtTs(row.completedAt)}</td>
300 </tr>
301 {row.blockedReason && (
302 <tr>
303 <th>Blocked reason</th>
304 <td style="color: var(--red)">{row.blockedReason}</td>
305 </tr>
306 )}
307 {(() => {
308 const n = parseAutoIssueNumber(row.blockedReason);
309 return n !== null ? (
310 <tr>
311 <th>Incident issue</th>
312 <td>
313 <a href={`/${owner}/${repo}/issues/${n}`}>#{n}</a>
314 </td>
315 </tr>
316 ) : null;
317 })()}
318 </tbody>
319 </table>
320 {row.status === "failed" && (
321 <form
322 method="post"
323 action={`/${owner}/${repo}/deployments/${row.id}/retry-incident`}
324 style="margin-top: 16px"
325 >
326 <button type="submit" class="btn btn-secondary">
327 Re-run incident analysis
328 </button>
329 </form>
330 )}
331 </div>
332 </Layout>
333 );
334});
335
336// D4: re-trigger the AI incident responder for a failed deployment. Owner-only.
337// Redirects back to the deployment detail page in all cases.
338dep.post(
339 "/:owner/:repo/deployments/:id/retry-incident",
340 requireAuth,
341 async (c) => {
342 const { owner, repo, id } = c.req.param();
343 const user = c.get("user")!;
344 const repoRow = await resolveRepo(owner, repo);
345 const back = `/${owner}/${repo}/deployments/${id}`;
346 if (!repoRow) return c.notFound();
347 if (repoRow.ownerId !== user.id) {
348 return c.redirect(back);
349 }
350 try {
351 const [depRow] = await db
352 .select()
353 .from(deployments)
354 .where(
355 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
356 )
357 .limit(1);
358 if (!depRow || depRow.status !== "failed") return c.redirect(back);
359 await onDeployFailure({
360 repositoryId: repoRow.id,
361 deploymentId: depRow.id,
362 ref: depRow.ref,
363 commitSha: depRow.commitSha,
364 target: depRow.target,
365 errorMessage: depRow.blockedReason,
366 });
367 } catch (err) {
368 console.error("[deployments] retry-incident:", err);
369 }
370 return c.redirect(back);
371 }
372);
373
374export default dep;
Addedsrc/routes/deps.tsx+233−0View fileUnifiedSplit
1/**
2 * Block J1 — Dependency graph routes.
3 *
4 * GET /:owner/:repo/dependencies — grouped list + summary
5 * POST /:owner/:repo/dependencies/reindex — owner-only, walk manifests
6 */
7
8import { Hono } from "hono";
9import { and, eq } from "drizzle-orm";
10import { db } from "../db";
11import { repositories, users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { RepoHeader, RepoNav } from "../views/components";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import {
17 indexRepositoryDependencies,
18 listDependenciesForRepo,
19 summarizeDependencies,
20} from "../lib/deps";
21
22const deps = new Hono<AuthEnv>();
23deps.use("*", softAuth);
24
25async function loadRepo(ownerName: string, repoName: string) {
26 const [owner] = await db
27 .select()
28 .from(users)
29 .where(eq(users.username, ownerName))
30 .limit(1);
31 if (!owner) return null;
32 const [repo] = await db
33 .select()
34 .from(repositories)
35 .where(
36 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
37 )
38 .limit(1);
39 if (!repo) return null;
40 return { owner, repo };
41}
42
43// ---------- Overview ----------
44
45deps.get("/:owner/:repo/dependencies", async (c) => {
46 const user = c.get("user");
47 const { owner: ownerName, repo: repoName } = c.req.param();
48 const ctx = await loadRepo(ownerName, repoName);
49 if (!ctx) return c.notFound();
50 const { repo } = ctx;
51 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
52 return c.notFound();
53 }
54
55 const all = await listDependenciesForRepo(repo.id);
56 const summary = await summarizeDependencies(repo.id);
57 const isOwner = !!user && user.id === repo.ownerId;
58 const message = c.req.query("message");
59 const error = c.req.query("error");
60
61 // Group by ecosystem
62 const grouped = new Map<string, typeof all>();
63 for (const d of all) {
64 const list = grouped.get(d.ecosystem) || [];
65 list.push(d);
66 grouped.set(d.ecosystem, list);
67 }
68
69 return c.html(
70 <Layout
71 title={`Dependencies — ${ownerName}/${repoName}`}
72 user={user}
73 >
74 <RepoHeader owner={ownerName} repo={repoName} />
75 <RepoNav owner={ownerName} repo={repoName} active="code" />
76 <div class="settings-container">
77 <div style="display:flex;justify-content:space-between;align-items:center">
78 <h2 style="margin:0">Dependencies</h2>
79 {isOwner && (
80 <form
81 method="POST"
82 action={`/${ownerName}/${repoName}/dependencies/reindex`}
83 >
84 <button type="submit" class="btn btn-primary btn-sm">
85 Reindex
86 </button>
87 </form>
88 )}
89 </div>
90 {message && (
91 <div class="auth-success" style="margin-top:12px">
92 {decodeURIComponent(message)}
93 </div>
94 )}
95 {error && (
96 <div class="auth-error" style="margin-top:12px">
97 {decodeURIComponent(error)}
98 </div>
99 )}
100 <p style="color:var(--text-muted);margin-top:8px">
101 Parsed from <code>package.json</code>, <code>requirements.txt</code>,{" "}
102 <code>pyproject.toml</code>, <code>go.mod</code>,{" "}
103 <code>Cargo.toml</code>, <code>Gemfile</code>, and{" "}
104 <code>composer.json</code> on the default branch. Transitive
105 dependencies are not resolved.
106 </p>
107
108 {all.length === 0 ? (
109 <div class="panel-empty" style="padding:24px">
110 No dependencies indexed yet.
111 {isOwner && " Click Reindex to scan the repository."}
112 </div>
113 ) : (
114 <>
115 <div
116 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:16px 0"
117 >
118 <div class="panel" style="padding:12px;text-align:center">
119 <div style="font-size:20px;font-weight:700">
120 {all.length}
121 </div>
122 <div
123 style="font-size:11px;color:var(--text-muted);text-transform:uppercase"
124 >
125 Dependencies
126 </div>
127 </div>
128 {summary.map((s) => (
129 <div class="panel" style="padding:12px;text-align:center">
130 <div style="font-size:20px;font-weight:700">
131 {s.count}
132 </div>
133 <div
134 style="font-size:11px;color:var(--text-muted);text-transform:uppercase"
135 >
136 {s.ecosystem}
137 </div>
138 </div>
139 ))}
140 </div>
141
142 {Array.from(grouped.entries()).map(([ecosystem, list]) => (
143 <>
144 <h3 style="margin-top:20px">
145 {ecosystem}{" "}
146 <span
147 style="font-size:12px;color:var(--text-muted);font-weight:400"
148 >
149 ({list.length})
150 </span>
151 </h3>
152 <div class="panel" style="margin-bottom:12px">
153 {list.map((d) => (
154 <div
155 class="panel-item"
156 style="justify-content:space-between;flex-wrap:wrap;gap:6px"
157 >
158 <div>
159 <span
160 style="font-family:var(--font-mono);font-weight:600"
161 >
162 {d.name}
163 </span>
164 {d.versionSpec && (
165 <span
166 style="margin-left:8px;font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
167 >
168 {d.versionSpec}
169 </span>
170 )}
171 {d.isDev && (
172 <span
173 style="margin-left:8px;font-size:10px;padding:2px 6px;background:var(--bg-subtle);border-radius:3px;color:var(--text-muted);text-transform:uppercase"
174 >
175 dev
176 </span>
177 )}
178 </div>
179 <a
180 href={`/${ownerName}/${repoName}/blob/HEAD/${d.manifestPath}`}
181 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
182 >
183 {d.manifestPath}
184 </a>
185 </div>
186 ))}
187 </div>
188 </>
189 ))}
190 </>
191 )}
192 </div>
193 </Layout>
194 );
195});
196
197// ---------- Reindex (owner-only) ----------
198
199deps.post("/:owner/:repo/dependencies/reindex", requireAuth, async (c) => {
200 const user = c.get("user");
201 const { owner: ownerName, repo: repoName } = c.req.param();
202 const ctx = await loadRepo(ownerName, repoName);
203 if (!ctx) return c.notFound();
204 const { repo } = ctx;
205 if (!user || user.id !== repo.ownerId) {
206 return c.html(
207 <Layout title="Forbidden" user={user}>
208 <div class="empty-state">
209 <h2>403</h2>
210 <p>Only the repository owner can reindex dependencies.</p>
211 </div>
212 </Layout>,
213 403
214 );
215 }
216
217 const result = await indexRepositoryDependencies(repo.id);
218 const to = `/${ownerName}/${repoName}/dependencies`;
219 if (!result) {
220 return c.redirect(
221 `${to}?error=${encodeURIComponent(
222 "Reindex failed — is the default branch empty?"
223 )}`
224 );
225 }
226 return c.redirect(
227 `${to}?message=${encodeURIComponent(
228 `Indexed ${result.indexed} dependencies across ${result.manifests} manifests.`
229 )}`
230 );
231});
232
233export default deps;
Addedsrc/routes/developer-apps.tsx+558−0View fileUnifiedSplit
1/**
2 * Developer Apps UI (Block B6).
3 *
4 * Lets authenticated users register + manage their OAuth 2.0 apps:
5 * GET /settings/applications list + new button
6 * GET /settings/applications/new form
7 * POST /settings/applications/new create (returns client_secret once)
8 * GET /settings/applications/:id edit / rotate secret / delete
9 * POST /settings/applications/:id update
10 * POST /settings/applications/:id/rotate generate a new client secret
11 * POST /settings/applications/:id/delete remove app + all tokens
12 *
13 * All writes audit()' the action. Read-only responses are HTML (SSR JSX).
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { oauthApps } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 generateClientId,
25 generateClientSecret,
26 sha256Hex,
27 isValidRedirectUri,
28 parseRedirectUris,
29} from "../lib/oauth";
30import { audit } from "../lib/notify";
31
32const apps = new Hono<AuthEnv>();
33
34apps.use("/settings/applications", requireAuth);
35apps.use("/settings/applications/*", requireAuth);
36
37function normaliseRedirectUris(raw: string): {
38 ok: boolean;
39 value?: string;
40 error?: string;
41} {
42 const lines = raw
43 .split(/\r?\n/)
44 .map((s) => s.trim())
45 .filter(Boolean);
46 if (lines.length === 0) {
47 return { ok: false, error: "At least one redirect URI is required" };
48 }
49 if (lines.length > 10) {
50 return { ok: false, error: "At most 10 redirect URIs allowed" };
51 }
52 for (const u of lines) {
53 if (!isValidRedirectUri(u)) {
54 return { ok: false, error: `Invalid redirect URI: ${u}` };
55 }
56 }
57 return { ok: true, value: lines.join("\n") };
58}
59
60apps.get("/settings/applications", async (c) => {
61 const user = c.get("user")!;
62 const error = c.req.query("error");
63 const success = c.req.query("success");
64
65 let rows: (typeof oauthApps.$inferSelect)[] = [];
66 try {
67 rows = await db
68 .select()
69 .from(oauthApps)
70 .where(eq(oauthApps.ownerId, user.id));
71 } catch (err) {
72 console.error("[oauth-apps] list:", err);
73 }
74
75 return c.html(
76 <Layout title="OAuth applications" user={user}>
77 <div class="settings-container">
78 <div class="breadcrumb">
79 <a href="/settings">settings</a>
80 <span>/</span>
81 <span>applications</span>
82 </div>
83 <h2>OAuth applications</h2>
84 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
85 {success && (
86 <div class="auth-success">{decodeURIComponent(success)}</div>
87 )}
88 <p style="color: var(--text-muted); font-size: 13px">
89 Register third-party apps that can request access to gluecron on
90 behalf of users via the OAuth 2.0 authorization code flow.
91 </p>
92 <div style="margin: 16px 0">
93 <a href="/settings/applications/new" class="btn btn-primary">
94 New OAuth app
95 </a>
96 </div>
97 <div
98 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
99 >
100 {rows.length === 0 ? (
101 <div
102 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
103 >
104 No OAuth apps registered yet.
105 </div>
106 ) : (
107 rows.map((app) => (
108 <div
109 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary)"
110 >
111 <div style="display: flex; justify-content: space-between; align-items: center">
112 <div>
113 <strong>
114 <a href={`/settings/applications/${app.id}`}>{app.name}</a>
115 </strong>
116 {app.revokedAt && (
117 <span style="color: var(--red); font-size: 12px; margin-left: 8px">
118 revoked
119 </span>
120 )}
121 <div
122 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
123 >
124 <code>{app.clientId}</code>
125 {" · "}added {new Date(app.createdAt).toLocaleDateString()}
126 </div>
127 </div>
128 <a
129 href={`/settings/applications/${app.id}`}
130 class="btn btn-sm"
131 >
132 manage
133 </a>
134 </div>
135 </div>
136 ))
137 )}
138 </div>
139 </div>
140 </Layout>
141 );
142});
143
144apps.get("/settings/applications/new", async (c) => {
145 const user = c.get("user")!;
146 const error = c.req.query("error");
147 return c.html(
148 <Layout title="New OAuth app" user={user}>
149 <div class="settings-container">
150 <div class="breadcrumb">
151 <a href="/settings/applications">applications</a>
152 <span>/</span>
153 <span>new</span>
154 </div>
155 <h2>Register a new OAuth app</h2>
156 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
157 <form method="POST" action="/settings/applications/new">
158 <div class="form-group">
159 <label for="name">Application name</label>
160 <input
161 type="text"
162 id="name"
163 name="name"
164 required
165 maxLength={80}
166 placeholder="My Awesome Integration"
167 />
168 </div>
169 <div class="form-group">
170 <label for="homepage_url">Homepage URL</label>
171 <input
172 type="url"
173 id="homepage_url"
174 name="homepage_url"
175 placeholder="https://example.com"
176 />
177 </div>
178 <div class="form-group">
179 <label for="description">Description</label>
180 <textarea
181 id="description"
182 name="description"
183 rows={3}
184 maxLength={500}
185 />
186 </div>
187 <div class="form-group">
188 <label for="redirect_uris">Authorization callback URLs</label>
189 <textarea
190 id="redirect_uris"
191 name="redirect_uris"
192 rows={4}
193 required
194 placeholder="https://example.com/oauth/callback"
195 />
196 <small style="color: var(--text-muted)">
197 One URL per line. HTTPS required (HTTP allowed for localhost).
198 Exact match; no wildcards.
199 </small>
200 </div>
201 <div class="form-group">
202 <label>
203 <input
204 type="checkbox"
205 name="confidential"
206 value="on"
207 checked
208 />
209 {" "}Confidential client (server-side app)
210 </label>
211 <br />
212 <small style="color: var(--text-muted)">
213 Uncheck for public SPA / mobile apps — they must use PKCE
214 instead of a client secret.
215 </small>
216 </div>
217 <button type="submit" class="btn btn-primary">
218 Register app
219 </button>
220 <a
221 href="/settings/applications"
222 class="btn"
223 style="margin-left: 8px"
224 >
225 Cancel
226 </a>
227 </form>
228 </div>
229 </Layout>
230 );
231});
232
233apps.post("/settings/applications/new", async (c) => {
234 const user = c.get("user")!;
235 const body = await c.req.parseBody();
236 const name = String(body.name || "").trim().slice(0, 80);
237 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
238 const description = String(body.description || "").trim().slice(0, 500);
239 const confidential = String(body.confidential || "") === "on";
240 const redirectRaw = String(body.redirect_uris || "");
241
242 if (!name) {
243 return c.redirect("/settings/applications/new?error=Name+is+required");
244 }
245 const parsed = normaliseRedirectUris(redirectRaw);
246 if (!parsed.ok) {
247 return c.redirect(
248 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
249 );
250 }
251
252 const clientId = generateClientId();
253 const clientSecret = generateClientSecret();
254 const clientSecretHash = await sha256Hex(clientSecret);
255
256 try {
257 const [row] = await db
258 .insert(oauthApps)
259 .values({
260 ownerId: user.id,
261 name,
262 clientId,
263 clientSecretHash,
264 clientSecretPrefix: clientSecret.slice(0, 8),
265 redirectUris: parsed.value!,
266 homepageUrl: homepageUrl || null,
267 description: description || null,
268 confidential,
269 })
270 .returning();
271 await audit({
272 userId: user.id,
273 action: "oauth_app.create",
274 targetType: "oauth_app",
275 targetId: row.id,
276 metadata: { clientId },
277 });
278 // Redirect to the manage page with the plaintext secret appended once.
279 return c.redirect(
280 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
281 );
282 } catch (err) {
283 console.error("[oauth-apps] create:", err);
284 return c.redirect(
285 "/settings/applications/new?error=Service+unavailable"
286 );
287 }
288});
289
290apps.get("/settings/applications/:id", async (c) => {
291 const user = c.get("user")!;
292 const id = c.req.param("id");
293 const error = c.req.query("error");
294 const success = c.req.query("success");
295 const secret = c.req.query("secret");
296
297 let app: typeof oauthApps.$inferSelect | undefined;
298 try {
299 const [row] = await db
300 .select()
301 .from(oauthApps)
302 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
303 .limit(1);
304 app = row;
305 } catch (err) {
306 console.error("[oauth-apps] get:", err);
307 }
308 if (!app) {
309 return c.redirect("/settings/applications?error=Not+found");
310 }
311
312 return c.html(
313 <Layout title={app.name} user={user}>
314 <div class="settings-container">
315 <div class="breadcrumb">
316 <a href="/settings/applications">applications</a>
317 <span>/</span>
318 <span>{app.name}</span>
319 </div>
320 <h2>{app.name}</h2>
321 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
322 {success && (
323 <div class="auth-success">{decodeURIComponent(success)}</div>
324 )}
325
326 {secret && (
327 <div
328 style="padding: 12px; border: 1px solid var(--yellow); background: rgba(255,193,7,0.1); border-radius: var(--radius); margin-bottom: 16px"
329 >
330 <strong>Save this client secret — it will not be shown again:</strong>
331 <pre
332 style="margin-top: 8px; padding: 8px; background: var(--bg); border-radius: 4px; overflow-x: auto; user-select: all"
333 >
334 {secret}
335 </pre>
336 </div>
337 )}
338
339 <dl style="display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; margin-bottom: 16px">
340 <dt style="color: var(--text-muted)">Client ID</dt>
341 <dd>
342 <code style="user-select: all">{app.clientId}</code>
343 </dd>
344 <dt style="color: var(--text-muted)">Client secret prefix</dt>
345 <dd>
346 <code>{app.clientSecretPrefix}…</code>
347 </dd>
348 <dt style="color: var(--text-muted)">Type</dt>
349 <dd>{app.confidential ? "Confidential" : "Public (PKCE)"}</dd>
350 <dt style="color: var(--text-muted)">Created</dt>
351 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
352 </dl>
353
354 <form method="POST" action={`/settings/applications/${app.id}`}>
355 <div class="form-group">
356 <label for="name">Application name</label>
357 <input
358 type="text"
359 id="name"
360 name="name"
361 required
362 maxLength={80}
363 defaultValue={app.name}
364 />
365 </div>
366 <div class="form-group">
367 <label for="homepage_url">Homepage URL</label>
368 <input
369 type="url"
370 id="homepage_url"
371 name="homepage_url"
372 defaultValue={app.homepageUrl || ""}
373 />
374 </div>
375 <div class="form-group">
376 <label for="description">Description</label>
377 <textarea
378 id="description"
379 name="description"
380 rows={3}
381 maxLength={500}
382 >
383 {app.description || ""}
384 </textarea>
385 </div>
386 <div class="form-group">
387 <label for="redirect_uris">Authorization callback URLs</label>
388 <textarea
389 id="redirect_uris"
390 name="redirect_uris"
391 rows={4}
392 required
393 >
394 {app.redirectUris}
395 </textarea>
396 </div>
397 <button type="submit" class="btn btn-primary">
398 Save changes
399 </button>
400 </form>
401
402 <hr style="margin: 24px 0; border-color: var(--border)" />
403
404 <h3>Rotate client secret</h3>
405 <p style="color: var(--text-muted); font-size: 13px">
406 Generate a new secret. The old one is invalidated immediately —
407 existing access tokens keep working, but token exchange with the
408 old secret will fail.
409 </p>
410 <form
411 method="POST"
412 action={`/settings/applications/${app.id}/rotate`}
413 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
414 >
415 <button type="submit" class="btn">
416 Rotate secret
417 </button>
418 </form>
419
420 <hr style="margin: 24px 0; border-color: var(--border)" />
421
422 <h3 style="color: var(--red)">Danger zone</h3>
423 <form
424 method="POST"
425 action={`/settings/applications/${app.id}/delete`}
426 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
427 >
428 <button type="submit" class="btn btn-danger">
429 Delete app
430 </button>
431 </form>
432 </div>
433 </Layout>
434 );
435});
436
437apps.post("/settings/applications/:id", async (c) => {
438 const user = c.get("user")!;
439 const id = c.req.param("id");
440 const body = await c.req.parseBody();
441 const name = String(body.name || "").trim().slice(0, 80);
442 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
443 const description = String(body.description || "").trim().slice(0, 500);
444 const redirectRaw = String(body.redirect_uris || "");
445
446 if (!name) {
447 return c.redirect(
448 `/settings/applications/${id}?error=Name+is+required`
449 );
450 }
451 const parsed = normaliseRedirectUris(redirectRaw);
452 if (!parsed.ok) {
453 return c.redirect(
454 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
455 );
456 }
457 try {
458 const [existing] = await db
459 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
460 .from(oauthApps)
461 .where(eq(oauthApps.id, id))
462 .limit(1);
463 if (!existing || existing.ownerId !== user.id) {
464 return c.redirect("/settings/applications?error=Not+found");
465 }
466 await db
467 .update(oauthApps)
468 .set({
469 name,
470 homepageUrl: homepageUrl || null,
471 description: description || null,
472 redirectUris: parsed.value!,
473 updatedAt: new Date(),
474 })
475 .where(eq(oauthApps.id, id));
476 await audit({
477 userId: user.id,
478 action: "oauth_app.update",
479 targetType: "oauth_app",
480 targetId: id,
481 });
482 return c.redirect(`/settings/applications/${id}?success=Saved`);
483 } catch (err) {
484 console.error("[oauth-apps] update:", err);
485 return c.redirect(
486 `/settings/applications/${id}?error=Service+unavailable`
487 );
488 }
489});
490
491apps.post("/settings/applications/:id/rotate", async (c) => {
492 const user = c.get("user")!;
493 const id = c.req.param("id");
494 try {
495 const [existing] = await db
496 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
497 .from(oauthApps)
498 .where(eq(oauthApps.id, id))
499 .limit(1);
500 if (!existing || existing.ownerId !== user.id) {
501 return c.redirect("/settings/applications?error=Not+found");
502 }
503 const newSecret = generateClientSecret();
504 const newHash = await sha256Hex(newSecret);
505 await db
506 .update(oauthApps)
507 .set({
508 clientSecretHash: newHash,
509 clientSecretPrefix: newSecret.slice(0, 8),
510 updatedAt: new Date(),
511 })
512 .where(eq(oauthApps.id, id));
513 await audit({
514 userId: user.id,
515 action: "oauth_app.rotate_secret",
516 targetType: "oauth_app",
517 targetId: id,
518 });
519 return c.redirect(
520 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
521 );
522 } catch (err) {
523 console.error("[oauth-apps] rotate:", err);
524 return c.redirect(
525 `/settings/applications/${id}?error=Service+unavailable`
526 );
527 }
528});
529
530apps.post("/settings/applications/:id/delete", async (c) => {
531 const user = c.get("user")!;
532 const id = c.req.param("id");
533 try {
534 const [existing] = await db
535 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
536 .from(oauthApps)
537 .where(eq(oauthApps.id, id))
538 .limit(1);
539 if (!existing || existing.ownerId !== user.id) {
540 return c.redirect("/settings/applications?error=Not+found");
541 }
542 await db.delete(oauthApps).where(eq(oauthApps.id, id));
543 await audit({
544 userId: user.id,
545 action: "oauth_app.delete",
546 targetType: "oauth_app",
547 targetId: id,
548 });
549 return c.redirect("/settings/applications?success=App+deleted");
550 } catch (err) {
551 console.error("[oauth-apps] delete:", err);
552 return c.redirect(
553 `/settings/applications/${id}?error=Service+unavailable`
554 );
555 }
556});
557
558export default apps;
Addedsrc/routes/discussions.tsx+674−0View fileUnifiedSplit
1/**
2 * Block E2 — Discussions: forum-style threaded conversations attached to a repo.
3 *
4 * Similar to GitHub Discussions: categorised, pinnable, answer-able threads
5 * that sit alongside issues but are conversational (Q&A, ideas, announcements).
6 *
7 * Never throws — all DB paths wrapped in try/catch; callers see a 500-like
8 * shell page or a redirect on any failure.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 discussions,
16 discussionComments,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader, RepoNav } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const CATEGORIES = [
27 "general",
28 "q-and-a",
29 "ideas",
30 "announcements",
31 "show-and-tell",
32] as const;
33
34export function isValidCategory(c: string): boolean {
35 return (CATEGORIES as readonly string[]).includes(c);
36}
37
38const discussionRoutes = new Hono<AuthEnv>();
39
40async function resolveRepo(ownerName: string, repoName: string) {
41 try {
42 const [owner] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, ownerName))
46 .limit(1);
47 if (!owner) return null;
48 const [repo] = await db
49 .select()
50 .from(repositories)
51 .where(
52 and(
53 eq(repositories.ownerId, owner.id),
54 eq(repositories.name, repoName)
55 )
56 )
57 .limit(1);
58 if (!repo) return null;
59 return { owner, repo };
60 } catch {
61 return null;
62 }
63}
64
65function notFound(user: any, label = "Not found") {
66 return (
67 <Layout title={label} user={user}>
68 <div class="empty-state">
69 <h2>{label}</h2>
70 </div>
71 </Layout>
72 );
73}
74
75// List
76discussionRoutes.get("/:owner/:repo/discussions", softAuth, async (c) => {
77 const { owner: ownerName, repo: repoName } = c.req.param();
78 const user = c.get("user");
79 const category = c.req.query("category") || "";
80
81 const resolved = await resolveRepo(ownerName, repoName);
82 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
83 const { repo } = resolved;
84
85 let rows: any[] = [];
86 try {
87 const whereClause =
88 category && isValidCategory(category)
89 ? and(
90 eq(discussions.repositoryId, repo.id),
91 eq(discussions.category, category)
92 )
93 : eq(discussions.repositoryId, repo.id);
94 rows = await db
95 .select({
96 d: discussions,
97 author: { username: users.username },
98 commentCount: sql<number>`(SELECT count(*) FROM discussion_comments WHERE discussion_id = ${discussions.id})`,
99 })
100 .from(discussions)
101 .innerJoin(users, eq(discussions.authorId, users.id))
102 .where(whereClause)
103 .orderBy(desc(discussions.pinned), desc(discussions.updatedAt));
104 } catch {
105 rows = [];
106 }
107
108 return c.html(
109 <Layout title={`Discussions — ${ownerName}/${repoName}`} user={user}>
110 <RepoHeader owner={ownerName} repo={repoName} />
111 <div class="repo-nav">
112 <a href={`/${ownerName}/${repoName}`}>Code</a>
113 <a href={`/${ownerName}/${repoName}/issues`}>Issues</a>
114 <a href={`/${ownerName}/${repoName}/pulls`}>Pull Requests</a>
115 <a href={`/${ownerName}/${repoName}/discussions`} class="active">
116 Discussions
117 </a>
118 </div>
119 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
120 <div style="display: flex; gap: 8px;">
121 <a
122 href={`/${ownerName}/${repoName}/discussions`}
123 class={!category ? "active" : ""}
124 style="padding: 4px 10px; border-radius: 6px;"
125 >
126 All
127 </a>
128 {CATEGORIES.map((cat) => (
129 <a
130 href={`/${ownerName}/${repoName}/discussions?category=${cat}`}
131 class={cat === category ? "active" : ""}
132 style="padding: 4px 10px; border-radius: 6px;"
133 >
134 {cat}
135 </a>
136 ))}
137 </div>
138 {user && (
139 <a
140 href={`/${ownerName}/${repoName}/discussions/new`}
141 class="btn btn-primary"
142 >
143 New discussion
144 </a>
145 )}
146 </div>
147 {rows.length === 0 ? (
148 <div class="empty-state">
149 <p>No discussions yet.</p>
150 </div>
151 ) : (
152 <table class="file-table">
153 <tbody>
154 {rows.map((r) => (
155 <tr>
156 <td style="width: 40px; color: var(--text-muted);">
157 #{r.d.number}
158 </td>
159 <td>
160 {r.d.pinned && <span class="badge">📌 Pinned</span>}{" "}
161 <a
162 href={`/${ownerName}/${repoName}/discussions/${r.d.number}`}
163 >
164 <strong>{r.d.title}</strong>
165 </a>{" "}
166 <span class="badge">{r.d.category}</span>
167 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
168 by @{r.author.username}
169 {r.d.state === "closed" ? " · closed" : ""}
170 {r.d.locked ? " · locked" : ""}
171 </div>
172 </td>
173 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
174 💬 {r.commentCount}
175 </td>
176 </tr>
177 ))}
178 </tbody>
179 </table>
180 )}
181 </Layout>
182 );
183});
184
185// New discussion form
186discussionRoutes.get(
187 "/:owner/:repo/discussions/new",
188 requireAuth,
189 async (c) => {
190 const { owner: ownerName, repo: repoName } = c.req.param();
191 const user = c.get("user");
192 const resolved = await resolveRepo(ownerName, repoName);
193 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
194 return c.html(
195 <Layout title="New discussion" user={user}>
196 <RepoHeader owner={ownerName} repo={repoName} />
197 <h2 style="margin-top: 20px;">Start a discussion</h2>
198 <form
199 method="POST"
200 action={`/${ownerName}/${repoName}/discussions`}
201 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
202 >
203 <input
204 type="text"
205 name="title"
206 placeholder="Title"
207 required
208 style="padding: 8px;"
209 />
210 <select name="category" style="padding: 8px;">
211 {CATEGORIES.map((c) => (
212 <option value={c}>{c}</option>
213 ))}
214 </select>
215 <textarea
216 name="body"
217 rows={10}
218 placeholder="Write your post (markdown supported)"
219 style="padding: 8px; font-family: inherit;"
220 ></textarea>
221 <button type="submit" class="btn btn-primary">
222 Start discussion
223 </button>
224 </form>
225 </Layout>
226 );
227 }
228);
229
230// Create
231discussionRoutes.post(
232 "/:owner/:repo/discussions",
233 requireAuth,
234 async (c) => {
235 const { owner: ownerName, repo: repoName } = c.req.param();
236 const user = c.get("user")!;
237 const resolved = await resolveRepo(ownerName, repoName);
238 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
239
240 const form = await c.req.formData();
241 const title = (form.get("title") as string || "").trim();
242 const body = (form.get("body") as string || "").trim();
243 const categoryRaw = (form.get("category") as string || "general").trim();
244 const category = isValidCategory(categoryRaw) ? categoryRaw : "general";
245
246 if (!title) {
247 return c.redirect(`/${ownerName}/${repoName}/discussions/new`);
248 }
249
250 try {
251 const [row] = await db
252 .insert(discussions)
253 .values({
254 repositoryId: resolved.repo.id,
255 authorId: user.id,
256 category,
257 title,
258 body,
259 })
260 .returning({ number: discussions.number });
261 return c.redirect(
262 `/${ownerName}/${repoName}/discussions/${row.number}`
263 );
264 } catch {
265 return c.redirect(`/${ownerName}/${repoName}/discussions`);
266 }
267 }
268);
269
270// Detail
271discussionRoutes.get(
272 "/:owner/:repo/discussions/:number",
273 softAuth,
274 async (c) => {
275 const { owner: ownerName, repo: repoName } = c.req.param();
276 const user = c.get("user");
277 const numParam = Number(c.req.param("number"));
278 const resolved = await resolveRepo(ownerName, repoName);
279 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
280
281 let discussion: any = null;
282 let comments: any[] = [];
283 try {
284 const [row] = await db
285 .select({ d: discussions, author: { username: users.username } })
286 .from(discussions)
287 .innerJoin(users, eq(discussions.authorId, users.id))
288 .where(
289 and(
290 eq(discussions.repositoryId, resolved.repo.id),
291 eq(discussions.number, numParam)
292 )
293 )
294 .limit(1);
295 if (row) discussion = row;
296 if (discussion) {
297 comments = await db
298 .select({
299 c: discussionComments,
300 author: { username: users.username },
301 })
302 .from(discussionComments)
303 .innerJoin(users, eq(discussionComments.authorId, users.id))
304 .where(eq(discussionComments.discussionId, discussion.d.id))
305 .orderBy(discussionComments.createdAt);
306 }
307 } catch {
308 // leave nulls
309 }
310
311 if (!discussion) return c.html(notFound(user, "Discussion not found"), 404);
312
313 const isOwner = user && user.id === resolved.repo.ownerId;
314 const isAuthor = user && user.id === discussion.d.authorId;
315 const canModerate = isOwner || isAuthor;
316
317 return c.html(
318 <Layout
319 title={`${discussion.d.title} · discussion #${discussion.d.number}`}
320 user={user}
321 >
322 <RepoHeader owner={ownerName} repo={repoName} />
323 <div style="margin-top: 16px;">
324 <div style="display: flex; justify-content: space-between; align-items: center;">
325 <h1 style="margin: 0;">
326 {discussion.d.title}{" "}
327 <span style="color: var(--text-muted);">
328 #{discussion.d.number}
329 </span>
330 </h1>
331 <div style="display: flex; gap: 8px;">
332 <span class="badge">{discussion.d.category}</span>
333 {discussion.d.state === "closed" && (
334 <span class="badge">closed</span>
335 )}
336 {discussion.d.locked && <span class="badge">🔒 locked</span>}
337 {discussion.d.pinned && <span class="badge">📌 pinned</span>}
338 </div>
339 </div>
340 <div style="color: var(--text-muted); font-size: 13px; margin-top: 4px;">
341 Started by @{discussion.author.username}
342 </div>
343 </div>
344 <article class="comment" style="margin-top: 16px;">
345 <div
346 // biome-ignore lint: rendered server-side from trusted markdown
347 dangerouslySetInnerHTML={{
348 __html: renderMarkdown(discussion.d.body || ""),
349 }}
350 />
351 </article>
352 <h3 style="margin-top: 32px;">{comments.length} Comments</h3>
353 {comments.map((com) => {
354 const isAnswer = com.c.id === discussion.d.answerCommentId;
355 return (
356 <article
357 class="comment"
358 style={`margin-top: 12px; ${isAnswer ? "border: 2px solid var(--green); padding: 12px;" : ""}`}
359 >
360 <div style="display: flex; justify-content: space-between;">
361 <div style="font-size: 13px; color: var(--text-muted);">
362 @{com.author.username}
363 {isAnswer && " · ✅ Answer"}
364 </div>
365 {isOwner &&
366 discussion.d.category === "q-and-a" &&
367 !isAnswer && (
368 <form
369 method="POST"
370 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/answer/${com.c.id}`}
371 style="display: inline;"
372 >
373 <button type="submit" class="btn">
374 Mark as answer
375 </button>
376 </form>
377 )}
378 </div>
379 <div
380 style="margin-top: 8px;"
381 dangerouslySetInnerHTML={{
382 __html: renderMarkdown(com.c.body || ""),
383 }}
384 />
385 </article>
386 );
387 })}
388 {user && !discussion.d.locked && discussion.d.state === "open" && (
389 <form
390 method="POST"
391 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/comment`}
392 style="margin-top: 24px; display: flex; flex-direction: column; gap: 8px;"
393 >
394 <textarea
395 name="body"
396 rows={5}
397 placeholder="Add a comment (markdown supported)"
398 required
399 style="padding: 8px; font-family: inherit;"
400 ></textarea>
401 <button type="submit" class="btn btn-primary">
402 Comment
403 </button>
404 </form>
405 )}
406 {user && (
407 <div style="margin-top: 24px; display: flex; gap: 8px;">
408 {canModerate && (
409 <form
410 method="POST"
411 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/close`}
412 style="display: inline;"
413 >
414 <button type="submit" class="btn">
415 {discussion.d.state === "open" ? "Close" : "Reopen"}
416 </button>
417 </form>
418 )}
419 {isOwner && (
420 <>
421 <form
422 method="POST"
423 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/lock`}
424 style="display: inline;"
425 >
426 <button type="submit" class="btn">
427 {discussion.d.locked ? "Unlock" : "Lock"}
428 </button>
429 </form>
430 <form
431 method="POST"
432 action={`/${ownerName}/${repoName}/discussions/${discussion.d.number}/pin`}
433 style="display: inline;"
434 >
435 <button type="submit" class="btn">
436 {discussion.d.pinned ? "Unpin" : "Pin"}
437 </button>
438 </form>
439 </>
440 )}
441 </div>
442 )}
443 </Layout>
444 );
445 }
446);
447
448// Add comment
449discussionRoutes.post(
450 "/:owner/:repo/discussions/:number/comment",
451 requireAuth,
452 async (c) => {
453 const { owner: ownerName, repo: repoName } = c.req.param();
454 const user = c.get("user")!;
455 const numParam = Number(c.req.param("number"));
456 const resolved = await resolveRepo(ownerName, repoName);
457 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
458
459 const form = await c.req.formData();
460 const body = (form.get("body") as string || "").trim();
461 const parent = (form.get("parent_comment_id") as string) || null;
462 if (!body) {
463 return c.redirect(
464 `/${ownerName}/${repoName}/discussions/${numParam}`
465 );
466 }
467
468 try {
469 const [row] = await db
470 .select()
471 .from(discussions)
472 .where(
473 and(
474 eq(discussions.repositoryId, resolved.repo.id),
475 eq(discussions.number, numParam)
476 )
477 )
478 .limit(1);
479 if (!row || row.locked || row.state === "closed") {
480 return c.redirect(
481 `/${ownerName}/${repoName}/discussions/${numParam}`
482 );
483 }
484 await db.insert(discussionComments).values({
485 discussionId: row.id,
486 authorId: user.id,
487 body,
488 parentCommentId: parent || null,
489 });
490 await db
491 .update(discussions)
492 .set({ updatedAt: new Date() })
493 .where(eq(discussions.id, row.id));
494 } catch {
495 // swallow
496 }
497 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
498 }
499);
500
501// Toggle lock (owner)
502discussionRoutes.post(
503 "/:owner/:repo/discussions/:number/lock",
504 requireAuth,
505 async (c) => {
506 const { owner: ownerName, repo: repoName } = c.req.param();
507 const user = c.get("user")!;
508 const numParam = Number(c.req.param("number"));
509 const resolved = await resolveRepo(ownerName, repoName);
510 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
511 if (user.id !== resolved.repo.ownerId) {
512 return c.redirect(
513 `/${ownerName}/${repoName}/discussions/${numParam}`
514 );
515 }
516 try {
517 const [row] = await db
518 .select()
519 .from(discussions)
520 .where(
521 and(
522 eq(discussions.repositoryId, resolved.repo.id),
523 eq(discussions.number, numParam)
524 )
525 )
526 .limit(1);
527 if (row) {
528 await db
529 .update(discussions)
530 .set({ locked: !row.locked })
531 .where(eq(discussions.id, row.id));
532 }
533 } catch {
534 // swallow
535 }
536 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
537 }
538);
539
540// Toggle pin (owner)
541discussionRoutes.post(
542 "/:owner/:repo/discussions/:number/pin",
543 requireAuth,
544 async (c) => {
545 const { owner: ownerName, repo: repoName } = c.req.param();
546 const user = c.get("user")!;
547 const numParam = Number(c.req.param("number"));
548 const resolved = await resolveRepo(ownerName, repoName);
549 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
550 if (user.id !== resolved.repo.ownerId) {
551 return c.redirect(
552 `/${ownerName}/${repoName}/discussions/${numParam}`
553 );
554 }
555 try {
556 const [row] = await db
557 .select()
558 .from(discussions)
559 .where(
560 and(
561 eq(discussions.repositoryId, resolved.repo.id),
562 eq(discussions.number, numParam)
563 )
564 )
565 .limit(1);
566 if (row) {
567 await db
568 .update(discussions)
569 .set({ pinned: !row.pinned })
570 .where(eq(discussions.id, row.id));
571 }
572 } catch {
573 // swallow
574 }
575 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
576 }
577);
578
579// Mark answer (owner on q-and-a)
580discussionRoutes.post(
581 "/:owner/:repo/discussions/:number/answer/:commentId",
582 requireAuth,
583 async (c) => {
584 const { owner: ownerName, repo: repoName, commentId } = c.req.param();
585 const user = c.get("user")!;
586 const numParam = Number(c.req.param("number"));
587 const resolved = await resolveRepo(ownerName, repoName);
588 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
589
590 try {
591 const [row] = await db
592 .select()
593 .from(discussions)
594 .where(
595 and(
596 eq(discussions.repositoryId, resolved.repo.id),
597 eq(discussions.number, numParam)
598 )
599 )
600 .limit(1);
601 if (!row) {
602 return c.redirect(`/${ownerName}/${repoName}/discussions`);
603 }
604 const isOwner = user.id === resolved.repo.ownerId;
605 const isAuthor = user.id === row.authorId;
606 if (!isOwner && !isAuthor) {
607 return c.redirect(
608 `/${ownerName}/${repoName}/discussions/${numParam}`
609 );
610 }
611 if (row.category !== "q-and-a") {
612 return c.text(
613 "Only q-and-a discussions can have answers",
614 400
615 );
616 }
617 await db
618 .update(discussions)
619 .set({ answerCommentId: commentId })
620 .where(eq(discussions.id, row.id));
621 await db
622 .update(discussionComments)
623 .set({ isAnswer: true })
624 .where(eq(discussionComments.id, commentId));
625 } catch {
626 // swallow
627 }
628 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
629 }
630);
631
632// Toggle close (owner or author)
633discussionRoutes.post(
634 "/:owner/:repo/discussions/:number/close",
635 requireAuth,
636 async (c) => {
637 const { owner: ownerName, repo: repoName } = c.req.param();
638 const user = c.get("user")!;
639 const numParam = Number(c.req.param("number"));
640 const resolved = await resolveRepo(ownerName, repoName);
641 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
642 try {
643 const [row] = await db
644 .select()
645 .from(discussions)
646 .where(
647 and(
648 eq(discussions.repositoryId, resolved.repo.id),
649 eq(discussions.number, numParam)
650 )
651 )
652 .limit(1);
653 if (!row) {
654 return c.redirect(`/${ownerName}/${repoName}/discussions`);
655 }
656 const isOwner = user.id === resolved.repo.ownerId;
657 const isAuthor = user.id === row.authorId;
658 if (!isOwner && !isAuthor) {
659 return c.redirect(
660 `/${ownerName}/${repoName}/discussions/${numParam}`
661 );
662 }
663 await db
664 .update(discussions)
665 .set({ state: row.state === "open" ? "closed" : "open" })
666 .where(eq(discussions.id, row.id));
667 } catch {
668 // swallow
669 }
670 return c.redirect(`/${ownerName}/${repoName}/discussions/${numParam}`);
671 }
672);
673
674export default discussionRoutes;
Addedsrc/routes/environments.tsx+597−0View fileUnifiedSplit
1/**
2 * Environments settings + approval routes (Block C4).
3 *
4 * GET /:owner/:repo/settings/environments list + create form (owner-only)
5 * POST /:owner/:repo/settings/environments create
6 * POST /:owner/:repo/settings/environments/:envId update
7 * POST /:owner/:repo/settings/environments/:envId/delete
8 *
9 * POST /:owner/:repo/deployments/:deploymentId/approve approve a pending deploy
10 * POST /:owner/:repo/deployments/:deploymentId/reject reject a pending deploy
11 *
12 * Approve/reject live under /deployments/:id/... so they don't collide with
13 * the existing `GET /:owner/:repo/deployments/:id` detail page.
14 */
15
16import { Hono } from "hono";
17import type { Context } from "hono";
18import { and, eq, inArray } from "drizzle-orm";
19import { db } from "../db";
20import {
21 environments,
22 deployments,
23 repositories,
24 users,
25} from "../db/schema";
26import type { Environment } from "../db/schema";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { Layout } from "../views/layout";
30import { RepoHeader, RepoNav } from "../views/components";
31import { getUnreadCount } from "../lib/unread";
32import { audit, notify } from "../lib/notify";
33import {
34 allowedBranchesOf,
35 computeApprovalState,
36 getEnvironmentById,
37 getEnvironmentByName,
38 isReviewer,
39 listEnvironments,
40 recordApproval,
41 reviewerIdsOf,
42} from "../lib/environments";
43
44const r = new Hono<AuthEnv>();
45r.use("*", softAuth);
46
47// ---------------------------------------------------------------------------
48// helpers
49// ---------------------------------------------------------------------------
50
51async function loadRepo(owner: string, repo: string) {
52 try {
53 const [row] = await db
54 .select({
55 id: repositories.id,
56 name: repositories.name,
57 defaultBranch: repositories.defaultBranch,
58 ownerId: repositories.ownerId,
59 starCount: repositories.starCount,
60 forkCount: repositories.forkCount,
61 })
62 .from(repositories)
63 .innerJoin(users, eq(repositories.ownerId, users.id))
64 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
65 .limit(1);
66 return row || null;
67 } catch (err) {
68 console.error("[environments] loadRepo failed:", err);
69 return null;
70 }
71}
72
73function splitCsv(raw: unknown): string[] {
74 if (typeof raw !== "string") return [];
75 return raw
76 .split(",")
77 .map((s) => s.trim())
78 .filter(Boolean);
79}
80
81async function resolveUsernamesToIds(usernames: string[]): Promise<string[]> {
82 if (usernames.length === 0) return [];
83 try {
84 const rows = await db
85 .select({ id: users.id, username: users.username })
86 .from(users)
87 .where(inArray(users.username, usernames));
88 return rows.map((r) => r.id);
89 } catch (err) {
90 console.error("[environments] resolve usernames failed:", err);
91 return [];
92 }
93}
94
95async function idsToUsernames(ids: string[]): Promise<string[]> {
96 if (ids.length === 0) return [];
97 try {
98 const rows = await db
99 .select({ id: users.id, username: users.username })
100 .from(users)
101 .where(inArray(users.id, ids));
102 const map = new Map(rows.map((r) => [r.id, r.username]));
103 return ids.map((id) => map.get(id) || id);
104 } catch {
105 return ids;
106 }
107}
108
109// ---------------------------------------------------------------------------
110// GET /:owner/:repo/settings/environments
111// ---------------------------------------------------------------------------
112
113r.get("/:owner/:repo/settings/environments", requireAuth, async (c) => {
114 const user = c.get("user")!;
115 const { owner, repo } = c.req.param();
116 const repoRow = await loadRepo(owner, repo);
117 if (!repoRow) return c.notFound();
118 if (repoRow.ownerId !== user.id) {
119 return c.redirect(`/${owner}/${repo}`);
120 }
121
122 const envs = await listEnvironments(repoRow.id);
123 const unread = await getUnreadCount(user.id);
124 const success = c.req.query("success");
125 const err = c.req.query("error");
126
127 // Resolve reviewer IDs → usernames per env for display.
128 const envUsernames: Record<string, string[]> = {};
129 for (const env of envs) {
130 envUsernames[env.id] = await idsToUsernames(reviewerIdsOf(env));
131 }
132
133 return c.html(
134 <Layout
135 title={`Environments — ${owner}/${repo}`}
136 user={user}
137 notificationCount={unread}
138 >
139 <RepoHeader
140 owner={owner}
141 repo={repo}
142 starCount={repoRow.starCount}
143 forkCount={repoRow.forkCount}
144 currentUser={user.username}
145 />
146 <RepoNav owner={owner} repo={repo} active="code" />
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
148 <h3>Environments</h3>
149 <a href={`/${owner}/${repo}/deployments`} class="btn btn-sm">
150 Back to deployments
151 </a>
152 </div>
153 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
154 {err && <div class="auth-error">{decodeURIComponent(err)}</div>}
155
156 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px">
157 Require human approval before a deploy to this environment runs.
158 Branch patterns restrict which refs may target the environment.
159 </p>
160
161 <div class="panel" style="margin-bottom: 24px">
162 {envs.length === 0 ? (
163 <div class="panel-empty">No environments yet.</div>
164 ) : (
165 envs.map((env) => {
166 const reviewers = envUsernames[env.id] || [];
167 const branches = allowedBranchesOf(env);
168 return (
169 <form
170 method="POST"
171 action={`/${owner}/${repo}/settings/environments/${env.id}`}
172 class="panel-item"
173 style="flex-direction: column; align-items: stretch; gap: 8px"
174 >
175 <div
176 style="display: flex; justify-content: space-between; align-items: center"
177 >
178 <strong style="font-size: 15px">{env.name}</strong>
179 <div style="display: flex; gap: 6px">
180 <button type="submit" class="btn btn-sm btn-primary">
181 Save
182 </button>
183 </div>
184 </div>
185 <div class="form-group" style="margin: 0">
186 <label style="display: flex; align-items: center; gap: 6px">
187 <input
188 type="checkbox"
189 name="requireApproval"
190 value="1"
191 checked={env.requireApproval}
192 />
193 Require approval before deploy
194 </label>
195 </div>
196 <div class="form-group" style="margin: 0">
197 <label>Reviewers (comma-separated usernames)</label>
198 <input
199 type="text"
200 name="reviewers"
201 value={reviewers.join(", ")}
202 placeholder="alice, bob"
203 />
204 </div>
205 <div class="form-group" style="margin: 0">
206 <label>Wait timer (minutes)</label>
207 <input
208 type="number"
209 name="waitTimerMinutes"
210 min="0"
211 max="1440"
212 value={String(env.waitTimerMinutes)}
213 style="width: 120px"
214 />
215 </div>
216 <div class="form-group" style="margin: 0">
217 <label>Allowed branches (comma-separated glob patterns)</label>
218 <input
219 type="text"
220 name="allowedBranches"
221 value={branches.join(", ")}
222 placeholder="main, release/*"
223 />
224 </div>
225 <div style="display: flex; justify-content: flex-end">
226 <button
227 type="submit"
228 formaction={`/${owner}/${repo}/settings/environments/${env.id}/delete`}
229 class="btn btn-sm btn-danger"
230 onclick="return confirm('Delete this environment?')"
231 >
232 Delete
233 </button>
234 </div>
235 </form>
236 );
237 })
238 )}
239 </div>
240
241 <h3 style="margin-top: 24px; margin-bottom: 12px">New environment</h3>
242 <form
243 method="POST"
244 action={`/${owner}/${repo}/settings/environments`}
245 class="panel"
246 style="padding: 16px"
247 >
248 <div class="form-group">
249 <label>Name</label>
250 <input
251 type="text"
252 name="name"
253 required
254 placeholder="production"
255 />
256 </div>
257 <div class="form-group">
258 <label style="display: flex; align-items: center; gap: 6px">
259 <input
260 type="checkbox"
261 name="requireApproval"
262 value="1"
263 checked
264 />
265 Require approval
266 </label>
267 </div>
268 <div class="form-group">
269 <label>Reviewers (comma-separated usernames)</label>
270 <input type="text" name="reviewers" placeholder="alice, bob" />
271 </div>
272 <div class="form-group">
273 <label>Wait timer (minutes)</label>
274 <input
275 type="number"
276 name="waitTimerMinutes"
277 min="0"
278 max="1440"
279 value="0"
280 style="width: 120px"
281 />
282 </div>
283 <div class="form-group">
284 <label>Allowed branches (comma-separated glob patterns)</label>
285 <input
286 type="text"
287 name="allowedBranches"
288 placeholder="main, release/*"
289 />
290 </div>
291 <button type="submit" class="btn btn-primary">
292 Create environment
293 </button>
294 </form>
295 </Layout>
296 );
297});
298
299// ---------------------------------------------------------------------------
300// POST /:owner/:repo/settings/environments (create)
301// ---------------------------------------------------------------------------
302
303r.post("/:owner/:repo/settings/environments", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308 if (repoRow.ownerId !== user.id) {
309 return c.redirect(`/${owner}/${repo}`);
310 }
311
312 const body = await c.req.parseBody();
313 const name = String(body.name || "").trim();
314 if (!name) {
315 return c.redirect(
316 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
317 "Name required"
318 )}`
319 );
320 }
321 const requireApproval = body.requireApproval === "1" || body.requireApproval === "on";
322 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
323 const waitTimerMinutes = Math.max(
324 0,
325 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
326 );
327 const allowedBranches = splitCsv(body.allowedBranches);
328
329 try {
330 await db.insert(environments).values({
331 repositoryId: repoRow.id,
332 name,
333 requireApproval,
334 reviewers: JSON.stringify(reviewers),
335 waitTimerMinutes,
336 allowedBranches: JSON.stringify(allowedBranches),
337 });
338 } catch (err) {
339 console.error("[environments] create failed:", err);
340 return c.redirect(
341 `/${owner}/${repo}/settings/environments?error=${encodeURIComponent(
342 "Could not create (duplicate name?)"
343 )}`
344 );
345 }
346
347 await audit({
348 userId: user.id,
349 repositoryId: repoRow.id,
350 action: "environment.create",
351 targetType: "environment",
352 metadata: { name, requireApproval, reviewers, allowedBranches },
353 });
354
355 return c.redirect(
356 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
357 "Environment created"
358 )}`
359 );
360});
361
362// ---------------------------------------------------------------------------
363// POST /:owner/:repo/settings/environments/:envId (update)
364// ---------------------------------------------------------------------------
365
366r.post("/:owner/:repo/settings/environments/:envId", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo, envId } = c.req.param();
369 const repoRow = await loadRepo(owner, repo);
370 if (!repoRow) return c.notFound();
371 if (repoRow.ownerId !== user.id) {
372 return c.redirect(`/${owner}/${repo}`);
373 }
374
375 const env = await getEnvironmentById(repoRow.id, envId);
376 if (!env) return c.notFound();
377
378 const body = await c.req.parseBody();
379 const requireApproval =
380 body.requireApproval === "1" || body.requireApproval === "on";
381 const reviewers = await resolveUsernamesToIds(splitCsv(body.reviewers));
382 const waitTimerMinutes = Math.max(
383 0,
384 Math.min(1440, parseInt(String(body.waitTimerMinutes || "0"), 10) || 0)
385 );
386 const allowedBranches = splitCsv(body.allowedBranches);
387
388 try {
389 await db
390 .update(environments)
391 .set({
392 requireApproval,
393 reviewers: JSON.stringify(reviewers),
394 waitTimerMinutes,
395 allowedBranches: JSON.stringify(allowedBranches),
396 updatedAt: new Date(),
397 })
398 .where(
399 and(
400 eq(environments.id, envId),
401 eq(environments.repositoryId, repoRow.id)
402 )
403 );
404 } catch (err) {
405 console.error("[environments] update failed:", err);
406 }
407
408 await audit({
409 userId: user.id,
410 repositoryId: repoRow.id,
411 action: "environment.update",
412 targetType: "environment",
413 targetId: envId,
414 metadata: { requireApproval, reviewers, allowedBranches, waitTimerMinutes },
415 });
416
417 return c.redirect(
418 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
419 "Environment updated"
420 )}`
421 );
422});
423
424// ---------------------------------------------------------------------------
425// POST /:owner/:repo/settings/environments/:envId/delete
426// ---------------------------------------------------------------------------
427
428r.post(
429 "/:owner/:repo/settings/environments/:envId/delete",
430 requireAuth,
431 async (c) => {
432 const user = c.get("user")!;
433 const { owner, repo, envId } = c.req.param();
434 const repoRow = await loadRepo(owner, repo);
435 if (!repoRow) return c.notFound();
436 if (repoRow.ownerId !== user.id) {
437 return c.redirect(`/${owner}/${repo}`);
438 }
439
440 try {
441 await db
442 .delete(environments)
443 .where(
444 and(
445 eq(environments.id, envId),
446 eq(environments.repositoryId, repoRow.id)
447 )
448 );
449 } catch (err) {
450 console.error("[environments] delete failed:", err);
451 }
452
453 await audit({
454 userId: user.id,
455 repositoryId: repoRow.id,
456 action: "environment.delete",
457 targetType: "environment",
458 targetId: envId,
459 });
460
461 return c.redirect(
462 `/${owner}/${repo}/settings/environments?success=${encodeURIComponent(
463 "Environment removed"
464 )}`
465 );
466 }
467);
468
469// ---------------------------------------------------------------------------
470// Approve/reject a pending deployment
471// ---------------------------------------------------------------------------
472
473async function loadDeployment(repositoryId: string, deploymentId: string) {
474 try {
475 const [row] = await db
476 .select()
477 .from(deployments)
478 .where(
479 and(
480 eq(deployments.id, deploymentId),
481 eq(deployments.repositoryId, repositoryId)
482 )
483 )
484 .limit(1);
485 return row || null;
486 } catch (err) {
487 console.error("[environments] loadDeployment failed:", err);
488 return null;
489 }
490}
491
492async function decide(
493 c: Context<AuthEnv>,
494 decision: "approved" | "rejected"
495) {
496 const user = c.get("user")!;
497 const { owner, repo, deploymentId } = c.req.param();
498 const repoRow = await loadRepo(owner, repo);
499 if (!repoRow) return c.notFound();
500
501 const deployment = await loadDeployment(repoRow.id, deploymentId);
502 if (!deployment) return c.notFound();
503
504 const envName = deployment.environment;
505 const env = await getEnvironmentByName(repoRow.id, envName);
506 if (!env) {
507 // No env configured — nothing to approve. Treat as 404 for safety.
508 return c.notFound();
509 }
510
511 const allowed = await isReviewer(env, user.id);
512 if (!allowed) {
513 return c.redirect(
514 `/${owner}/${repo}/deployments/${deploymentId}?error=${encodeURIComponent(
515 "Not a reviewer"
516 )}`
517 );
518 }
519
520 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
521 const comment = typeof body.comment === "string" ? body.comment : undefined;
522
523 const inserted = await recordApproval({
524 deploymentId,
525 userId: user.id,
526 decision,
527 comment,
528 });
529
530 // Re-read state and flip the deployment row accordingly.
531 const state = await computeApprovalState(deploymentId, env);
532 let newStatus: string | null = null;
533 if (state.rejected) {
534 newStatus = "rejected";
535 } else if (state.approved && deployment.status === "pending_approval") {
536 newStatus = "pending"; // hand off to existing deployer
537 }
538
539 if (newStatus) {
540 try {
541 await db
542 .update(deployments)
543 .set({
544 status: newStatus,
545 blockedReason: newStatus === "rejected" ? "rejected by reviewer" : null,
546 })
547 .where(eq(deployments.id, deploymentId));
548 } catch (err) {
549 console.error("[environments] deployment status flip failed:", err);
550 }
551 }
552
553 await audit({
554 userId: user.id,
555 repositoryId: repoRow.id,
556 action: decision === "approved" ? "deployment.approve" : "deployment.reject",
557 targetType: "deployment",
558 targetId: deploymentId,
559 metadata: { recorded: !!inserted, newStatus },
560 });
561
562 if (deployment.triggeredBy && deployment.triggeredBy !== user.id) {
563 try {
564 await notify(deployment.triggeredBy, {
565 kind: "deployment_approval",
566 title:
567 decision === "approved"
568 ? `Deploy to ${envName} approved`
569 : `Deploy to ${envName} rejected`,
570 body:
571 decision === "approved"
572 ? `${user.username} approved the deploy of ${deployment.commitSha.slice(0, 7)}.`
573 : `${user.username} rejected the deploy of ${deployment.commitSha.slice(0, 7)}.`,
574 url: `/${owner}/${repo}/deployments/${deploymentId}`,
575 repositoryId: repoRow.id,
576 });
577 } catch (err) {
578 console.error("[environments] notify triggeredBy failed:", err);
579 }
580 }
581
582 return c.redirect(`/${owner}/${repo}/deployments/${deploymentId}`);
583}
584
585r.post(
586 "/:owner/:repo/deployments/:deploymentId/approve",
587 requireAuth,
588 async (c) => decide(c, "approved")
589);
590
591r.post(
592 "/:owner/:repo/deployments/:deploymentId/reject",
593 requireAuth,
594 async (c) => decide(c, "rejected")
595);
596
597export default r;
Addedsrc/routes/follows.tsx+257−0View fileUnifiedSplit
1/**
2 * Block J4 — User following routes.
3 *
4 * POST /:user/follow — auth required
5 * POST /:user/unfollow — auth required
6 * GET /:user/followers — public list
7 * GET /:user/following — public list
8 * GET /feed — auth required, personalised activity
9 */
10
11import { Hono } from "hono";
12import { Layout } from "../views/layout";
13import { softAuth, requireAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import {
16 describeAction,
17 feedForUser,
18 followCounts,
19 followUser,
20 isFollowing,
21 listFollowers,
22 listFollowing,
23 resolveUserByName,
24 unfollowUser,
25} from "../lib/follows";
26import { audit } from "../lib/notify";
27
28const follows = new Hono<AuthEnv>();
29follows.use("*", softAuth);
30
31const RESERVED = new Set([
32 "login",
33 "register",
34 "logout",
35 "new",
36 "settings",
37 "api",
38 "feed",
39 "dashboard",
40 "explore",
41 "search",
42 "notifications",
43 "admin",
44 "orgs",
45 "gists",
46 "marketplace",
47 "sponsors",
48 "developer",
49 "ask",
50 "help",
51]);
52
53function profileUrl(username: string): string {
54 return `/${username}`;
55}
56
57// ---------- Follow / unfollow ----------
58
59follows.post("/:user/follow", requireAuth, async (c) => {
60 const me = c.get("user")!;
61 const targetName = c.req.param("user");
62 if (RESERVED.has(targetName)) return c.notFound();
63 const target = await resolveUserByName(targetName);
64 if (!target) return c.notFound();
65 const res = await followUser(me.id, target.id);
66 if (res === "ok") {
67 await audit({
68 userId: me.id,
69 action: "user.follow",
70 targetId: target.id,
71 metadata: { username: target.username },
72 });
73 }
74 return c.redirect(profileUrl(targetName));
75});
76
77follows.post("/:user/unfollow", requireAuth, async (c) => {
78 const me = c.get("user")!;
79 const targetName = c.req.param("user");
80 if (RESERVED.has(targetName)) return c.notFound();
81 const target = await resolveUserByName(targetName);
82 if (!target) return c.notFound();
83 const ok = await unfollowUser(me.id, target.id);
84 if (ok) {
85 await audit({
86 userId: me.id,
87 action: "user.unfollow",
88 targetId: target.id,
89 metadata: { username: target.username },
90 });
91 }
92 return c.redirect(profileUrl(targetName));
93});
94
95// ---------- Lists ----------
96
97async function renderUserList(
98 c: any,
99 ownerName: string,
100 mode: "followers" | "following"
101) {
102 const user = c.get("user");
103 if (RESERVED.has(ownerName)) return c.notFound();
104 const target = await resolveUserByName(ownerName);
105 if (!target) return c.notFound();
106 const list =
107 mode === "followers"
108 ? await listFollowers(target.id)
109 : await listFollowing(target.id);
110 const counts = await followCounts(target.id);
111
112 return c.html(
113 <Layout
114 title={`${mode === "followers" ? "Followers" : "Following"} — ${ownerName}`}
115 user={user}
116 >
117 <div class="settings-container">
118 <h2 style="margin:0">
119 <a href={`/${ownerName}`} style="text-decoration:none">
120 @{ownerName}
121 </a>
122 </h2>
123 <div style="display:flex;gap:16px;margin:10px 0 20px">
124 <a
125 href={`/${ownerName}/followers`}
126 class={mode === "followers" ? "btn btn-primary" : "btn"}
127 >
128 Followers <span style="opacity:.7">({counts.followers})</span>
129 </a>
130 <a
131 href={`/${ownerName}/following`}
132 class={mode === "following" ? "btn btn-primary" : "btn"}
133 >
134 Following <span style="opacity:.7">({counts.following})</span>
135 </a>
136 </div>
137 {list.length === 0 ? (
138 <div class="panel-empty" style="padding:24px">
139 No {mode}.
140 </div>
141 ) : (
142 <div class="panel">
143 {list.map((u) => (
144 <div class="panel-item">
145 <a href={`/${u.username}`} style="font-weight:600">
146 @{u.username}
147 </a>
148 {u.displayName && (
149 <span style="color:var(--text-muted);margin-left:8px">
150 {u.displayName}
151 </span>
152 )}
153 </div>
154 ))}
155 </div>
156 )}
157 </div>
158 </Layout>
159 );
160}
161
162follows.get("/:user/followers", async (c) =>
163 renderUserList(c, c.req.param("user"), "followers")
164);
165follows.get("/:user/following", async (c) =>
166 renderUserList(c, c.req.param("user"), "following")
167);
168
169// ---------- Personalised feed ----------
170
171follows.get("/feed", requireAuth, async (c) => {
172 const user = c.get("user")!;
173 const entries = await feedForUser(user.id, 50);
174 return c.html(
175 <Layout title="Feed" user={user}>
176 <div class="settings-container">
177 <h2>Your feed</h2>
178 <p style="color:var(--text-muted)">
179 Recent activity from users you follow. Follow someone from their
180 profile page to start filling this up.
181 </p>
182 {entries.length === 0 ? (
183 <div class="panel-empty" style="padding:24px">
184 Nothing here yet. Try the{" "}
185 <a href="/explore">explore page</a> to find people to follow.
186 </div>
187 ) : (
188 <div class="panel">
189 {entries.map((e) => {
190 const repoUrl = `/${e.ownerUsername}/${e.repository.name}`;
191 return (
192 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:2px">
193 <div>
194 <a href={`/${e.actor.username}`} style="font-weight:600">
195 @{e.actor.username}
196 </a>{" "}
197 <span style="color:var(--text-muted)">
198 {describeAction(e.activity.action)}
199 </span>{" "}
200 <a href={repoUrl} style="font-weight:600">
201 {e.ownerUsername}/{e.repository.name}
202 </a>
203 </div>
204 <div
205 style="font-size:12px;color:var(--text-muted)"
206 >
207 {new Date(e.activity.createdAt).toLocaleString()}
208 {e.activity.targetType === "issue" &&
209 e.activity.targetId && (
210 <>
211 {" "}
212 ·{" "}
213 <a
214 href={`${repoUrl}/issues/${e.activity.targetId}`}
215 >
216 #{e.activity.targetId}
217 </a>
218 </>
219 )}
220 {e.activity.targetType === "pr" &&
221 e.activity.targetId && (
222 <>
223 {" "}
224 ·{" "}
225 <a href={`${repoUrl}/pulls/${e.activity.targetId}`}>
226 #{e.activity.targetId}
227 </a>
228 </>
229 )}
230 {e.activity.targetType === "commit" &&
231 e.activity.targetId && (
232 <>
233 {" "}
234 ·{" "}
235 <a
236 href={`${repoUrl}/commit/${e.activity.targetId}`}
237 class="commit-sha"
238 >
239 {String(e.activity.targetId).slice(0, 7)}
240 </a>
241 </>
242 )}
243 </div>
244 </div>
245 );
246 })}
247 </div>
248 )}
249 </div>
250 </Layout>
251 );
252});
253
254export default follows;
255
256// Exported for profile page use (web.tsx).
257export { isFollowing, followCounts, resolveUserByName };
Modifiedsrc/routes/fork.ts+25−11View fileUnifiedSplit
6767 await proc.exited;
6868
6969 // Insert into DB
70 await db.insert(repositories).values({
71 name: repoName,
72 ownerId: user.id,
73 description: sourceRepo.description
74 ? `Fork of ${ownerName}/${repoName}${sourceRepo.description}`
75 : `Fork of ${ownerName}/${repoName}`,
76 isPrivate: false,
77 defaultBranch: sourceRepo.defaultBranch,
78 diskPath: destPath,
79 forkedFromId: sourceRepo.id,
80 });
70 const [newRepo] = await db
71 .insert(repositories)
72 .values({
73 name: repoName,
74 ownerId: user.id,
75 description: sourceRepo.description
76 ? `Fork of ${ownerName}/${repoName}${sourceRepo.description}`
77 : `Fork of ${ownerName}/${repoName}`,
78 isPrivate: false,
79 defaultBranch: sourceRepo.defaultBranch,
80 diskPath: destPath,
81 forkedFromId: sourceRepo.id,
82 })
83 .returning();
84
85 // Bootstrap the fork with green-by-default settings, protection, labels
86 if (newRepo) {
87 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
88 await bootstrapRepository({
89 repositoryId: newRepo.id,
90 ownerUserId: user.id,
91 defaultBranch: sourceRepo.defaultBranch,
92 skipWelcomeIssue: true, // forks don't need a welcome issue
93 });
94 }
8195
8296 // Update fork count
8397 await db
Addedsrc/routes/gates.tsx+506−0View fileUnifiedSplit
1/**
2 * Gates UI — gate run history + branch protection settings + repo settings toggles.
3 *
4 * GET /:owner/:repo/gates — per-repo gate run history
5 * GET /:owner/:repo/gates/settings — settings toggles + branch protection (owner-only)
6 * POST /:owner/:repo/gates/settings — save toggles
7 * POST /:owner/:repo/gates/protection — save/update branch protection rule
8 * POST /:owner/:repo/gates/protection/:id/delete — remove a protection rule
9 * POST /:owner/:repo/gates/run — manually trigger a gate run on the default branch
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq } from "drizzle-orm";
14import { db } from "../db";
15import {
16 branchProtection,
17 gateRuns,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getUnreadCount } from "../lib/unread";
28import { audit } from "../lib/notify";
29
30const gates = new Hono<AuthEnv>();
31gates.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50function relTime(d: Date | string): string {
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diffMs = Date.now() - t.getTime();
53 const mins = Math.floor(diffMs / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63// ---------- Gate run history ----------
64
65gates.get("/:owner/:repo/gates", async (c) => {
66 const user = c.get("user");
67 const { owner, repo } = c.req.param();
68 const repoRow = await loadRepo(owner, repo);
69 if (!repoRow) return c.notFound();
70
71 const runs = await db
72 .select()
73 .from(gateRuns)
74 .where(eq(gateRuns.repositoryId, repoRow.id))
75 .orderBy(desc(gateRuns.createdAt))
76 .limit(100);
77
78 const unread = user ? await getUnreadCount(user.id) : 0;
79 const total = runs.length;
80 const passed = runs.filter((r) => r.status === "passed").length;
81 const failed = runs.filter((r) => r.status === "failed").length;
82 const repaired = runs.filter((r) => r.status === "repaired").length;
83 const skipped = runs.filter((r) => r.status === "skipped").length;
84
85 return c.html(
86 <Layout
87 title={`Gates — ${owner}/${repo}`}
88 user={user}
89 notificationCount={unread}
90 >
91 <RepoHeader
92 owner={owner}
93 repo={repo}
94 starCount={repoRow.starCount}
95 forkCount={repoRow.forkCount}
96 currentUser={user?.username || null}
97 />
98 <RepoNav owner={owner} repo={repo} active="gates" />
99 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
100 <h3>Gate runs</h3>
101 {user && user.id === repoRow.ownerId && (
102 <a
103 href={`/${owner}/${repo}/gates/settings`}
104 class="btn btn-sm"
105 >
106 {"\u2699"} Settings
107 </a>
108 )}
109 </div>
110
111 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px">
112 <div class="panel" style="padding: 12px; text-align: center">
113 <div style="font-size: 22px; font-weight: 700; color: var(--green)">{passed}</div>
114 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Passed</div>
115 </div>
116 <div class="panel" style="padding: 12px; text-align: center">
117 <div style="font-size: 22px; font-weight: 700; color: #bc8cff">{repaired}</div>
118 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Repaired</div>
119 </div>
120 <div class="panel" style="padding: 12px; text-align: center">
121 <div style="font-size: 22px; font-weight: 700; color: var(--red)">{failed}</div>
122 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Failed</div>
123 </div>
124 <div class="panel" style="padding: 12px; text-align: center">
125 <div style="font-size: 22px; font-weight: 700; color: var(--text-muted)">{skipped}</div>
126 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Skipped</div>
127 </div>
128 </div>
129
130 {total === 0 ? (
131 <div class="empty-state">
132 <p>No gate runs yet. Push a commit to trigger the full green ecosystem.</p>
133 </div>
134 ) : (
135 <div class="gate-list">
136 {runs.map((r) => (
137 <div class="gate-run-row">
138 <span class={`gate-status ${r.status}`}>{r.status}</span>
139 <div style="flex: 1">
140 <div style="font-weight: 500">{r.gateName}</div>
141 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
142 <a href={`/${owner}/${repo}/commit/${r.commitSha}`}>
143 {r.commitSha.slice(0, 7)}
144 </a>
145 {" · "}
146 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
147 {" · "}
148 <span>{relTime(r.createdAt)}</span>
149 {r.durationMs ? ` · ${(r.durationMs / 1000).toFixed(1)}s` : ""}
150 </div>
151 {r.summary && (
152 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
153 {r.summary}
154 </div>
155 )}
156 {r.repairCommitSha && (
157 <div style="font-size: 12px; color: #bc8cff; margin-top: 2px">
158 Auto-repaired in{" "}
159 <a href={`/${owner}/${repo}/commit/${r.repairCommitSha}`}>
160 {r.repairCommitSha.slice(0, 7)}
161 </a>
162 </div>
163 )}
164 </div>
165 </div>
166 ))}
167 </div>
168 )}
169 </Layout>
170 );
171});
172
173// ---------- Settings UI ----------
174
175gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
176 const user = c.get("user")!;
177 const { owner, repo } = c.req.param();
178 const repoRow = await loadRepo(owner, repo);
179 if (!repoRow) return c.notFound();
180 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
181
182 const settings = await getOrCreateSettings(repoRow.id);
183 const protections = await db
184 .select()
185 .from(branchProtection)
186 .where(eq(branchProtection.repositoryId, repoRow.id));
187
188 const unread = await getUnreadCount(user.id);
189 const success = c.req.query("success");
190
191 const toggle = (name: string, label: string, checked: boolean, desc?: string) => (
192 <label
193 style="display: flex; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer"
194 >
195 <input type="checkbox" name={name} value="1" checked={checked} />
196 <div>
197 <div style="font-weight: 500">{label}</div>
198 {desc && (
199 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
200 {desc}
201 </div>
202 )}
203 </div>
204 </label>
205 );
206
207 return c.html(
208 <Layout
209 title={`Gate settings — ${owner}/${repo}`}
210 user={user}
211 notificationCount={unread}
212 >
213 <RepoHeader
214 owner={owner}
215 repo={repo}
216 starCount={repoRow.starCount}
217 forkCount={repoRow.forkCount}
218 currentUser={user.username}
219 />
220 <RepoNav owner={owner} repo={repo} active="gates" />
221 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
222 <h3>Gate & auto-repair settings</h3>
223 <a href={`/${owner}/${repo}/gates`} class="btn btn-sm">
224 Back to runs
225 </a>
226 </div>
227 {success && (
228 <div class="auth-success">{decodeURIComponent(success)}</div>
229 )}
230
231 <form method="POST" action={`/${owner}/${repo}/gates/settings`}>
232 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
233 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
234 Gates
235 </div>
236 {toggle("gateTestEnabled", "GateTest scan", settings!.gateTestEnabled, "External test/lint runner")}
237 {toggle("aiReviewEnabled", "AI code review", settings!.aiReviewEnabled, "Claude reviews every PR")}
238 {toggle("secretScanEnabled", "Secret scan", settings!.secretScanEnabled, "Regex + AI secret detection on every push")}
239 {toggle("securityScanEnabled", "Security scan", settings!.securityScanEnabled, "Claude-powered semantic security review")}
240 {toggle("dependencyScanEnabled", "Dependency scan", settings!.dependencyScanEnabled, "Vulnerability scanning on lockfiles")}
241 {toggle("lintEnabled", "Lint", settings!.lintEnabled, "Auto-lint every push")}
242 {toggle("typeCheckEnabled", "Type check", settings!.typeCheckEnabled)}
243 {toggle("testEnabled", "Tests", settings!.testEnabled, "Run your test suite on every push")}
244 </div>
245
246 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
247 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
248 Auto-repair
249 </div>
250 {toggle("autoFixEnabled", "Auto-fix failing gates", settings!.autoFixEnabled, "Claude attempts a fix before a human is pinged")}
251 {toggle("autoMergeResolveEnabled", "Auto-resolve merge conflicts", settings!.autoMergeResolveEnabled)}
252 {toggle("autoFormatEnabled", "Auto-format on commit", settings!.autoFormatEnabled)}
253 </div>
254
255 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
256 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
257 AI features
258 </div>
259 {toggle("aiCommitMessagesEnabled", "AI commit messages", settings!.aiCommitMessagesEnabled)}
260 {toggle("aiPrSummaryEnabled", "AI PR summaries", settings!.aiPrSummaryEnabled)}
261 {toggle("aiChangelogEnabled", "AI release changelogs", settings!.aiChangelogEnabled)}
262 </div>
263
264 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
265 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
266 Deploy
267 </div>
268 {toggle("autoDeployEnabled", "Auto-deploy on green pushes to default branch", settings!.autoDeployEnabled)}
269 {toggle("deployRequireAllGreen", "Block deploys unless all gates are green", settings!.deployRequireAllGreen)}
270 </div>
271
272 <button type="submit" class="btn btn-primary">
273 Save settings
274 </button>
275 </form>
276
277 <h3 style="margin-top: 32px; margin-bottom: 12px">Branch protection</h3>
278 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
279 The default branch is protected on every new repo. Add extra rules for release branches.
280 </p>
281 <div class="panel" style="margin-bottom: 16px">
282 {protections.length === 0 ? (
283 <div class="panel-empty">No protection rules yet.</div>
284 ) : (
285 protections.map((p) => (
286 <div class="panel-item" style="justify-content: space-between">
287 <div style="flex: 1">
288 <code
289 style="background: var(--bg-tertiary); padding: 2px 8px; border-radius: 3px"
290 >
291 {p.pattern}
292 </code>
293 <div class="meta" style="margin-top: 4px">
294 {p.requirePullRequest ? "PR required · " : ""}
295 {p.requireGreenGates ? "Green gates · " : ""}
296 {p.requireAiApproval ? "AI approval · " : ""}
297 {p.requireHumanReview
298 ? `${p.requiredApprovals} human approval(s) · `
299 : ""}
300 {!p.allowForcePush ? "No force push · " : ""}
301 {!p.allowDeletion ? "No deletion" : ""}
302 </div>
303 </div>
304 <div style="display:flex;gap:6px">
305 <a
306 href={`/${owner}/${repo}/gates/protection/${p.id}/checks`}
307 class="btn btn-sm"
308 title="Manage required status checks for this rule"
309 >
310 Required checks
311 </a>
312 <form
313 method="POST"
314 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
315 onsubmit="return confirm('Remove this rule?')"
316 >
317 <button type="submit" class="btn btn-sm btn-danger">
318 Remove
319 </button>
320 </form>
321 </div>
322 </div>
323 ))
324 )}
325 </div>
326
327 <form
328 method="POST"
329 action={`/${owner}/${repo}/gates/protection`}
330 class="panel"
331 style="padding: 16px"
332 >
333 <div class="form-group">
334 <label>Pattern</label>
335 <input
336 type="text"
337 name="pattern"
338 required
339 placeholder="release/* or main"
340 />
341 </div>
342 <div style="display: flex; flex-wrap: wrap; gap: 16px">
343 <label style="display: flex; align-items: center; gap: 6px">
344 <input type="checkbox" name="requirePullRequest" value="1" checked />
345 Require PR
346 </label>
347 <label style="display: flex; align-items: center; gap: 6px">
348 <input type="checkbox" name="requireGreenGates" value="1" checked />
349 Require green gates
350 </label>
351 <label style="display: flex; align-items: center; gap: 6px">
352 <input type="checkbox" name="requireAiApproval" value="1" checked />
353 Require AI approval
354 </label>
355 <label style="display: flex; align-items: center; gap: 6px">
356 <input type="checkbox" name="requireHumanReview" value="1" />
357 Require human review
358 </label>
359 <label style="display: flex; align-items: center; gap: 6px">
360 Approvals{" "}
361 <input
362 type="number"
363 name="requiredApprovals"
364 min="0"
365 max="10"
366 value="1"
367 style="width: 60px"
368 />
369 </label>
370 <label style="display: flex; align-items: center; gap: 6px">
371 <input type="checkbox" name="allowForcePush" value="1" />
372 Allow force push
373 </label>
374 <label style="display: flex; align-items: center; gap: 6px">
375 <input type="checkbox" name="allowDeletion" value="1" />
376 Allow deletion
377 </label>
378 </div>
379 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
380 Add rule
381 </button>
382 </form>
383 </Layout>
384 );
385});
386
387gates.post("/:owner/:repo/gates/settings", requireAuth, async (c) => {
388 const user = c.get("user")!;
389 const { owner, repo } = c.req.param();
390 const repoRow = await loadRepo(owner, repo);
391 if (!repoRow) return c.notFound();
392 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
393
394 const body = await c.req.parseBody();
395 const b = (k: string) => body[k] === "1" || body[k] === "on";
396
397 try {
398 await db
399 .update(repoSettings)
400 .set({
401 gateTestEnabled: b("gateTestEnabled"),
402 aiReviewEnabled: b("aiReviewEnabled"),
403 secretScanEnabled: b("secretScanEnabled"),
404 securityScanEnabled: b("securityScanEnabled"),
405 dependencyScanEnabled: b("dependencyScanEnabled"),
406 lintEnabled: b("lintEnabled"),
407 typeCheckEnabled: b("typeCheckEnabled"),
408 testEnabled: b("testEnabled"),
409 autoFixEnabled: b("autoFixEnabled"),
410 autoMergeResolveEnabled: b("autoMergeResolveEnabled"),
411 autoFormatEnabled: b("autoFormatEnabled"),
412 aiCommitMessagesEnabled: b("aiCommitMessagesEnabled"),
413 aiPrSummaryEnabled: b("aiPrSummaryEnabled"),
414 aiChangelogEnabled: b("aiChangelogEnabled"),
415 autoDeployEnabled: b("autoDeployEnabled"),
416 deployRequireAllGreen: b("deployRequireAllGreen"),
417 updatedAt: new Date(),
418 })
419 .where(eq(repoSettings.repositoryId, repoRow.id));
420 } catch (err) {
421 console.error("[gates] settings save:", err);
422 }
423
424 await audit({
425 userId: user.id,
426 repositoryId: repoRow.id,
427 action: "gates.settings.update",
428 });
429
430 return c.redirect(
431 `/${owner}/${repo}/gates/settings?success=Settings+saved`
432 );
433});
434
435gates.post("/:owner/:repo/gates/protection", requireAuth, async (c) => {
436 const user = c.get("user")!;
437 const { owner, repo } = c.req.param();
438 const repoRow = await loadRepo(owner, repo);
439 if (!repoRow) return c.notFound();
440 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
441
442 const body = await c.req.parseBody();
443 const pattern = String(body.pattern || "").trim();
444 if (!pattern) return c.redirect(`/${owner}/${repo}/gates/settings`);
445 const b = (k: string) => body[k] === "1" || body[k] === "on";
446 const requiredApprovals = Math.max(
447 0,
448 Math.min(10, parseInt(String(body.requiredApprovals || "0"), 10) || 0)
449 );
450
451 try {
452 await db.insert(branchProtection).values({
453 repositoryId: repoRow.id,
454 pattern,
455 requirePullRequest: b("requirePullRequest"),
456 requireGreenGates: b("requireGreenGates"),
457 requireAiApproval: b("requireAiApproval"),
458 requireHumanReview: b("requireHumanReview"),
459 requiredApprovals,
460 allowForcePush: b("allowForcePush"),
461 allowDeletion: b("allowDeletion"),
462 });
463 } catch (err) {
464 console.error("[gates] protection save:", err);
465 }
466
467 await audit({
468 userId: user.id,
469 repositoryId: repoRow.id,
470 action: "branch_protection.create",
471 metadata: { pattern },
472 });
473
474 return c.redirect(
475 `/${owner}/${repo}/gates/settings?success=Rule+added`
476 );
477});
478
479gates.post(
480 "/:owner/:repo/gates/protection/:id/delete",
481 requireAuth,
482 async (c) => {
483 const user = c.get("user")!;
484 const { owner, repo, id } = c.req.param();
485 const repoRow = await loadRepo(owner, repo);
486 if (!repoRow) return c.notFound();
487 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
488 await db
489 .delete(branchProtection)
490 .where(
491 and(
492 eq(branchProtection.id, id),
493 eq(branchProtection.repositoryId, repoRow.id)
494 )
495 );
496 await audit({
497 userId: user.id,
498 repositoryId: repoRow.id,
499 action: "branch_protection.delete",
500 targetId: id,
501 });
502 return c.redirect(`/${owner}/${repo}/gates/settings?success=Rule+removed`);
503 }
504);
505
506export default gates;
Addedsrc/routes/gists.tsx+805−0View fileUnifiedSplit
1/**
2 * Block E4 — Gists: user-owned tiny multi-file repos.
3 *
4 * DB-backed v1 (no git bare repo). Each gist owns a collection of gist_files,
5 * and every edit appends a gist_revisions row with a JSON snapshot of the
6 * full file set at that revision.
7 *
8 * Never throws — all DB paths wrapped in try/catch; any failure redirects.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, sql } from "drizzle-orm";
13import { randomBytes } from "crypto";
14import { db } from "../db";
15import {
16 gists,
17 gistFiles,
18 gistRevisions,
19 gistStars,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { highlightCode } from "../lib/highlight";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { html } from "hono/html";
27
28export function generateSlug(): string {
29 return randomBytes(4).toString("hex");
30}
31
32export function snapshotOf(
33 files: { filename: string; content: string }[]
34): string {
35 const map: Record<string, string> = {};
36 for (const f of files) map[f.filename] = f.content;
37 return JSON.stringify(map);
38}
39
40const gistRoutes = new Hono<AuthEnv>();
41
42function notFound(user: any, label = "Gist not found") {
43 return (
44 <Layout title={label} user={user}>
45 <div class="empty-state">
46 <h2>{label}</h2>
47 </div>
48 </Layout>
49 );
50}
51
52// Discover / list public gists
53gistRoutes.get("/gists", softAuth, async (c) => {
54 const user = c.get("user");
55 const page = Math.max(1, Number(c.req.query("page")) || 1);
56 const limit = 30;
57 const offset = (page - 1) * limit;
58
59 let rows: any[] = [];
60 try {
61 rows = await db
62 .select({
63 g: gists,
64 owner: { username: users.username },
65 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
66 starCount: sql<number>`(SELECT count(*) FROM gist_stars WHERE gist_id = ${gists.id})`,
67 })
68 .from(gists)
69 .innerJoin(users, eq(gists.ownerId, users.id))
70 .where(eq(gists.isPublic, true))
71 .orderBy(desc(gists.updatedAt))
72 .limit(limit)
73 .offset(offset);
74 } catch {
75 rows = [];
76 }
77
78 return c.html(
79 <Layout title="Discover gists" user={user}>
80 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
81 <h1 style="margin: 0;">Discover gists</h1>
82 {user && (
83 <a href="/gists/new" class="btn btn-primary">
84 + New gist
85 </a>
86 )}
87 </div>
88 {rows.length === 0 ? (
89 <div class="empty-state">
90 <p>No public gists yet.</p>
91 </div>
92 ) : (
93 <div class="commit-list">
94 {rows.map((r) => (
95 <div class="commit-item">
96 <div>
97 <div class="commit-message">
98 <a href={`/gists/${r.g.slug}`}>
99 <strong>{r.g.title || r.g.slug}</strong>
100 </a>
101 </div>
102 <div class="commit-meta">
103 by <a href={`/${r.owner.username}`}>@{r.owner.username}</a>{" "}
104 · {r.fileCount} file{r.fileCount !== 1 ? "s" : ""} · ★{" "}
105 {r.starCount}
106 {r.g.description && ` · ${r.g.description}`}
107 </div>
108 </div>
109 <a href={`/gists/${r.g.slug}`} class="commit-sha">
110 {r.g.slug}
111 </a>
112 </div>
113 ))}
114 </div>
115 )}
116 {(rows.length === limit || page > 1) && (
117 <div style="margin-top: 16px;">
118 {page > 1 && <a href={`/gists?page=${page - 1}`}>← prev</a>}
119 {" "}
120 {rows.length === limit && (
121 <a href={`/gists?page=${page + 1}`}>next →</a>
122 )}
123 </div>
124 )}
125 </Layout>
126 );
127});
128
129// New gist form
130gistRoutes.get("/gists/new", requireAuth, async (c) => {
131 const user = c.get("user");
132 return c.html(
133 <Layout title="New gist" user={user}>
134 <h1 style="margin-top: 20px;">Create a gist</h1>
135 <form
136 method="POST"
137 action="/gists"
138 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
139 >
140 <input
141 type="text"
142 name="description"
143 placeholder="Gist description..."
144 style="padding: 8px;"
145 />
146 <div style="display: flex; gap: 16px;">
147 <label>
148 <input type="radio" name="is_public" value="true" checked />{" "}
149 Public
150 </label>
151 <label>
152 <input type="radio" name="is_public" value="false" /> Secret
153 </label>
154 </div>
155 <div id="files">
156 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
157 <input
158 type="text"
159 name="filename[]"
160 placeholder="filename.ext"
161 required
162 style="padding: 6px; width: 300px;"
163 />
164 <textarea
165 name="content[]"
166 rows={12}
167 placeholder="File contents..."
168 required
169 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
170 ></textarea>
171 </div>
172 </div>
173 <button
174 type="button"
175 class="btn"
176 id="add-file"
177 style="align-self: flex-start;"
178 >
179 + Add file
180 </button>
181 <button type="submit" class="btn btn-primary">
182 Create gist
183 </button>
184 </form>
185 {html`
186 <script>
187 document.getElementById("add-file").addEventListener("click", () => {
188 const div = document.createElement("div");
189 div.className = "gist-file";
190 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
191 div.innerHTML =
192 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
193 '<textarea name="content[]" rows="12" placeholder="File contents..." required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
194 document.getElementById("files").appendChild(div);
195 });
196 </script>
197 `}
198 </Layout>
199 );
200});
201
202// Create gist
203gistRoutes.post("/gists", requireAuth, async (c) => {
204 const user = c.get("user")!;
205 const form = await c.req.formData();
206 const description = (form.get("description") as string || "").trim();
207 const isPublic = (form.get("is_public") as string) !== "false";
208 const filenames = form.getAll("filename[]") as string[];
209 const contents = form.getAll("content[]") as string[];
210
211 const files = filenames
212 .map((fn, i) => ({
213 filename: (fn || "").trim(),
214 content: contents[i] || "",
215 }))
216 .filter((f) => f.filename && f.content);
217
218 if (files.length === 0) {
219 return c.text("At least one file is required", 400);
220 }
221
222 // Retry on unique slug collision
223 for (let attempt = 0; attempt < 5; attempt++) {
224 const slug = generateSlug();
225 try {
226 const [gist] = await db
227 .insert(gists)
228 .values({
229 ownerId: user.id,
230 slug,
231 title: files[0].filename,
232 description,
233 isPublic,
234 })
235 .returning({ id: gists.id });
236 await db.insert(gistFiles).values(
237 files.map((f) => ({
238 gistId: gist.id,
239 filename: f.filename,
240 content: f.content,
241 sizeBytes: new TextEncoder().encode(f.content).length,
242 }))
243 );
244 await db.insert(gistRevisions).values({
245 gistId: gist.id,
246 revision: 1,
247 snapshot: snapshotOf(files),
248 authorId: user.id,
249 message: "Initial",
250 });
251 return c.redirect(`/gists/${slug}`);
252 } catch (err: any) {
253 if (attempt === 4) {
254 return c.text("Could not create gist", 500);
255 }
256 // Otherwise assume slug collision, retry with fresh slug.
257 }
258 }
259 return c.redirect("/gists");
260});
261
262// View gist
263gistRoutes.get("/gists/:slug", softAuth, async (c) => {
264 const user = c.get("user");
265 const slug = c.req.param("slug");
266
267 let gist: any = null;
268 let files: any[] = [];
269 let starCount = 0;
270 let isStarred = false;
271 try {
272 const [row] = await db
273 .select({ g: gists, owner: { username: users.username } })
274 .from(gists)
275 .innerJoin(users, eq(gists.ownerId, users.id))
276 .where(eq(gists.slug, slug))
277 .limit(1);
278 if (row) {
279 gist = row;
280 files = await db
281 .select()
282 .from(gistFiles)
283 .where(eq(gistFiles.gistId, gist.g.id))
284 .orderBy(gistFiles.filename);
285 const [cnt] = await db
286 .select({ n: sql<number>`count(*)` })
287 .from(gistStars)
288 .where(eq(gistStars.gistId, gist.g.id));
289 starCount = Number(cnt?.n || 0);
290 if (user) {
291 const [has] = await db
292 .select()
293 .from(gistStars)
294 .where(
295 and(
296 eq(gistStars.gistId, gist.g.id),
297 eq(gistStars.userId, user.id)
298 )
299 )
300 .limit(1);
301 isStarred = !!has;
302 }
303 }
304 } catch {
305 // leave null
306 }
307
308 if (!gist) return c.html(notFound(user), 404);
309
310 const isOwner = user && user.id === gist.g.ownerId;
311 if (!gist.g.isPublic && !isOwner) {
312 return c.html(notFound(user), 404);
313 }
314
315 return c.html(
316 <Layout title={gist.g.title || slug} user={user}>
317 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
318 <div>
319 <h1 style="margin: 0;">
320 <a href={`/${gist.owner.username}`}>@{gist.owner.username}</a>{" "}
321 <span style="color: var(--text-muted);">/</span>{" "}
322 {gist.g.title || slug}
323 {!gist.g.isPublic && <span class="badge">Secret</span>}
324 </h1>
325 {gist.g.description && (
326 <div style="color: var(--text-muted); margin-top: 4px;">
327 {gist.g.description}
328 </div>
329 )}
330 </div>
331 <div style="display: flex; gap: 8px;">
332 {user && !isOwner && (
333 <form
334 method="POST"
335 action={`/gists/${slug}/star`}
336 style="display: inline;"
337 >
338 <button
339 type="submit"
340 class={`star-btn${isStarred ? " starred" : ""}`}
341 >
342 {isStarred ? "★" : "☆"} {starCount}
343 </button>
344 </form>
345 )}
346 {!user && (
347 <span class="star-btn">☆ {starCount}</span>
348 )}
349 <a href={`/gists/${slug}/revisions`} class="btn">
350 Revisions
351 </a>
352 {isOwner && (
353 <>
354 <a href={`/gists/${slug}/edit`} class="btn">
355 Edit
356 </a>
357 <form
358 method="POST"
359 action={`/gists/${slug}/delete`}
360 style="display: inline;"
361 onsubmit="return confirm('Delete this gist?')"
362 >
363 <button type="submit" class="btn">
364 Delete
365 </button>
366 </form>
367 </>
368 )}
369 </div>
370 </div>
371 {files.map((f) => {
372 const { html: highlighted } = highlightCode(f.content, f.filename);
373 return (
374 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
375 <div class="diff-file-header">{f.filename}</div>
376 <div class="blob-code">
377 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
378 {html([highlighted] as unknown as TemplateStringsArray)}
379 </pre>
380 </div>
381 </div>
382 );
383 })}
384 </Layout>
385 );
386});
387
388// Edit form
389gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
390 const user = c.get("user")!;
391 const slug = c.req.param("slug");
392
393 let gist: any = null;
394 let files: any[] = [];
395 try {
396 const [row] = await db
397 .select()
398 .from(gists)
399 .where(eq(gists.slug, slug))
400 .limit(1);
401 if (row && row.ownerId === user.id) {
402 gist = row;
403 files = await db
404 .select()
405 .from(gistFiles)
406 .where(eq(gistFiles.gistId, gist.id))
407 .orderBy(gistFiles.filename);
408 }
409 } catch {
410 // leave null
411 }
412
413 if (!gist) return c.html(notFound(user, "Not found or not yours"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${gist.slug}`} user={user}>
417 <h1 style="margin-top: 20px;">Edit gist</h1>
418 <form
419 method="POST"
420 action={`/gists/${slug}/edit`}
421 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
422 >
423 <input
424 type="text"
425 name="description"
426 value={gist.description}
427 placeholder="Description"
428 style="padding: 8px;"
429 />
430 <input
431 type="text"
432 name="message"
433 placeholder="Revision message (optional)"
434 style="padding: 8px;"
435 />
436 <div id="files">
437 {files.map((f) => (
438 <div class="gist-file" style="border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;">
439 <input
440 type="text"
441 name="filename[]"
442 value={f.filename}
443 required
444 style="padding: 6px; width: 300px;"
445 />
446 <textarea
447 name="content[]"
448 rows={12}
449 required
450 style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"
451 >
452 {f.content}
453 </textarea>
454 </div>
455 ))}
456 </div>
457 <button
458 type="button"
459 class="btn"
460 id="add-file"
461 style="align-self: flex-start;"
462 >
463 + Add file
464 </button>
465 <button type="submit" class="btn btn-primary">
466 Save revision
467 </button>
468 </form>
469 {html`
470 <script>
471 document.getElementById("add-file").addEventListener("click", () => {
472 const div = document.createElement("div");
473 div.className = "gist-file";
474 div.style.cssText = "border: 1px solid var(--border); padding: 12px; margin-bottom: 8px;";
475 div.innerHTML =
476 '<input type="text" name="filename[]" placeholder="filename.ext" required style="padding: 6px; width: 300px;" />' +
477 '<textarea name="content[]" rows="12" required style="width: 100%; padding: 8px; font-family: monospace; margin-top: 8px;"></textarea>';
478 document.getElementById("files").appendChild(div);
479 });
480 </script>
481 `}
482 </Layout>
483 );
484});
485
486// Save edit
487gistRoutes.post("/gists/:slug/edit", requireAuth, async (c) => {
488 const user = c.get("user")!;
489 const slug = c.req.param("slug");
490 const form = await c.req.formData();
491 const description = (form.get("description") as string || "").trim();
492 const message = (form.get("message") as string || "").trim();
493 const filenames = form.getAll("filename[]") as string[];
494 const contents = form.getAll("content[]") as string[];
495
496 const files = filenames
497 .map((fn, i) => ({
498 filename: (fn || "").trim(),
499 content: contents[i] || "",
500 }))
501 .filter((f) => f.filename && f.content);
502
503 if (files.length === 0) {
504 return c.text("At least one file is required", 400);
505 }
506
507 try {
508 const [row] = await db
509 .select()
510 .from(gists)
511 .where(eq(gists.slug, slug))
512 .limit(1);
513 if (!row || row.ownerId !== user.id) {
514 return c.redirect("/gists");
515 }
516 // Replace file set: delete all, re-insert.
517 await db.delete(gistFiles).where(eq(gistFiles.gistId, row.id));
518 await db.insert(gistFiles).values(
519 files.map((f) => ({
520 gistId: row.id,
521 filename: f.filename,
522 content: f.content,
523 sizeBytes: new TextEncoder().encode(f.content).length,
524 }))
525 );
526 // Bump revision.
527 const [last] = await db
528 .select({ r: sql<number>`max(${gistRevisions.revision})` })
529 .from(gistRevisions)
530 .where(eq(gistRevisions.gistId, row.id));
531 const nextRev = Number(last?.r || 0) + 1;
532 await db.insert(gistRevisions).values({
533 gistId: row.id,
534 revision: nextRev,
535 snapshot: snapshotOf(files),
536 authorId: user.id,
537 message: message || null,
538 });
539 await db
540 .update(gists)
541 .set({ description, updatedAt: new Date() })
542 .where(eq(gists.id, row.id));
543 } catch {
544 // swallow
545 }
546 return c.redirect(`/gists/${slug}`);
547});
548
549// Delete
550gistRoutes.post("/gists/:slug/delete", requireAuth, async (c) => {
551 const user = c.get("user")!;
552 const slug = c.req.param("slug");
553 try {
554 const [row] = await db
555 .select()
556 .from(gists)
557 .where(eq(gists.slug, slug))
558 .limit(1);
559 if (row && row.ownerId === user.id) {
560 await db.delete(gists).where(eq(gists.id, row.id));
561 }
562 } catch {
563 // swallow
564 }
565 return c.redirect("/gists");
566});
567
568// Toggle star
569gistRoutes.post("/gists/:slug/star", requireAuth, async (c) => {
570 const user = c.get("user")!;
571 const slug = c.req.param("slug");
572 try {
573 const [row] = await db
574 .select()
575 .from(gists)
576 .where(eq(gists.slug, slug))
577 .limit(1);
578 if (row && row.ownerId !== user.id) {
579 const [existing] = await db
580 .select()
581 .from(gistStars)
582 .where(
583 and(
584 eq(gistStars.gistId, row.id),
585 eq(gistStars.userId, user.id)
586 )
587 )
588 .limit(1);
589 if (existing) {
590 await db.delete(gistStars).where(eq(gistStars.id, existing.id));
591 } else {
592 await db.insert(gistStars).values({
593 gistId: row.id,
594 userId: user.id,
595 });
596 }
597 }
598 } catch {
599 // swallow
600 }
601 return c.redirect(`/gists/${slug}`);
602});
603
604// Revisions list
605gistRoutes.get("/gists/:slug/revisions", softAuth, async (c) => {
606 const user = c.get("user");
607 const slug = c.req.param("slug");
608
609 let gist: any = null;
610 let revs: any[] = [];
611 try {
612 const [row] = await db
613 .select()
614 .from(gists)
615 .where(eq(gists.slug, slug))
616 .limit(1);
617 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
618 gist = row;
619 revs = await db
620 .select({
621 r: gistRevisions,
622 author: { username: users.username },
623 })
624 .from(gistRevisions)
625 .innerJoin(users, eq(gistRevisions.authorId, users.id))
626 .where(eq(gistRevisions.gistId, gist.id))
627 .orderBy(desc(gistRevisions.revision));
628 }
629 } catch {
630 // leave null
631 }
632
633 if (!gist) return c.html(notFound(user), 404);
634
635 return c.html(
636 <Layout title={`${gist.slug} — revisions`} user={user}>
637 <h1 style="margin-top: 16px;">
638 <a href={`/gists/${slug}`}>{gist.title || slug}</a> — revisions
639 </h1>
640 <div class="commit-list" style="margin-top: 16px;">
641 {revs.map((rv) => (
642 <div class="commit-item">
643 <div>
644 <div class="commit-message">
645 <a href={`/gists/${slug}/revisions/${rv.r.revision}`}>
646 <strong>Revision {rv.r.revision}</strong>
647 </a>
648 {rv.r.message ? ` — ${rv.r.message}` : ""}
649 </div>
650 <div class="commit-meta">
651 by @{rv.author.username}
652 </div>
653 </div>
654 <a
655 href={`/gists/${slug}/revisions/${rv.r.revision}`}
656 class="commit-sha"
657 >
658 r{rv.r.revision}
659 </a>
660 </div>
661 ))}
662 </div>
663 </Layout>
664 );
665});
666
667// Revision detail
668gistRoutes.get(
669 "/gists/:slug/revisions/:rev",
670 softAuth,
671 async (c) => {
672 const user = c.get("user");
673 const slug = c.req.param("slug");
674 const rev = Number(c.req.param("rev"));
675
676 let gist: any = null;
677 let snapshot: Record<string, string> | null = null;
678 try {
679 const [row] = await db
680 .select()
681 .from(gists)
682 .where(eq(gists.slug, slug))
683 .limit(1);
684 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
685 gist = row;
686 const [rv] = await db
687 .select()
688 .from(gistRevisions)
689 .where(
690 and(
691 eq(gistRevisions.gistId, gist.id),
692 eq(gistRevisions.revision, rev)
693 )
694 )
695 .limit(1);
696 if (rv) {
697 try {
698 snapshot = JSON.parse(rv.snapshot);
699 } catch {
700 snapshot = {};
701 }
702 }
703 }
704 } catch {
705 // leave null
706 }
707
708 if (!gist || !snapshot)
709 return c.html(notFound(user, "Revision not found"), 404);
710
711 return c.html(
712 <Layout title={`${slug} @ r${rev}`} user={user}>
713 <h1 style="margin-top: 16px;">
714 <a href={`/gists/${slug}`}>{gist.title || slug}</a> @ revision {rev}
715 </h1>
716 {Object.entries(snapshot).map(([filename, content]) => {
717 const { html: highlighted } = highlightCode(content, filename);
718 return (
719 <div style="margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;">
720 <div class="diff-file-header">{filename}</div>
721 <div class="blob-code">
722 <pre style="margin: 0; padding: 12px; font-size: 13px; line-height: 1.6; overflow-x: auto;">
723 {html([highlighted] as unknown as TemplateStringsArray)}
724 </pre>
725 </div>
726 </div>
727 );
728 })}
729 </Layout>
730 );
731 }
732);
733
734// User's public gists
735gistRoutes.get("/:username/gists", softAuth, async (c) => {
736 const user = c.get("user");
737 const username = c.req.param("username");
738
739 let ownerUser: any = null;
740 let rows: any[] = [];
741 try {
742 const [u] = await db
743 .select()
744 .from(users)
745 .where(eq(users.username, username))
746 .limit(1);
747 if (u) {
748 ownerUser = u;
749 const showPrivate = user && user.id === u.id;
750 rows = await db
751 .select({
752 g: gists,
753 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
754 })
755 .from(gists)
756 .where(
757 showPrivate
758 ? eq(gists.ownerId, u.id)
759 : and(eq(gists.ownerId, u.id), eq(gists.isPublic, true))
760 )
761 .orderBy(desc(gists.updatedAt));
762 }
763 } catch {
764 rows = [];
765 }
766
767 if (!ownerUser) return c.html(notFound(user, "User not found"), 404);
768
769 return c.html(
770 <Layout title={`@${username}'s gists`} user={user}>
771 <h1 style="margin-top: 16px;">
772 <a href={`/${username}`}>@{username}</a>'s gists
773 </h1>
774 {rows.length === 0 ? (
775 <div class="empty-state">
776 <p>No gists yet.</p>
777 </div>
778 ) : (
779 <div class="commit-list" style="margin-top: 16px;">
780 {rows.map((r) => (
781 <div class="commit-item">
782 <div>
783 <div class="commit-message">
784 <a href={`/gists/${r.g.slug}`}>
785 <strong>{r.g.title || r.g.slug}</strong>
786 </a>
787 {!r.g.isPublic && <span class="badge">Secret</span>}
788 </div>
789 <div class="commit-meta">
790 {r.fileCount} file{r.fileCount !== 1 ? "s" : ""}
791 {r.g.description && ` · ${r.g.description}`}
792 </div>
793 </div>
794 <a href={`/gists/${r.g.slug}`} class="commit-sha">
795 {r.g.slug}
796 </a>
797 </div>
798 ))}
799 </div>
800 )}
801 </Layout>
802 );
803});
804
805export default gistRoutes;
Modifiedsrc/routes/git.ts+10−0View fileUnifiedSplit
88import { getInfoRefs, serviceRpc } from "../git/protocol";
99import { repoExists } from "../git/repository";
1010import { onPostReceive } from "../hooks/post-receive";
11import { invalidateRepoCache } from "../lib/cache";
12import { trackByName } from "../lib/traffic";
1113
1214const git = new Hono();
1315
5456 if (!(await repoExists(owner, repo))) {
5557 return c.text("Repository not found", 404);
5658 }
59 // F1 — fire-and-forget clone tracking.
60 trackByName(owner, repo, "clone", {
61 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
62 userAgent: c.req.header("user-agent") || null,
63 }).catch(() => {});
5764 return serviceRpc(owner, repo, "git-upload-pack", c.req.raw.body);
5865});
5966
7380 bodyBuffer
7481 );
7582
83 // Invalidate cached git data for this repo immediately
84 invalidateRepoCache(owner, repo);
85
7686 // Fire post-receive hooks asynchronously (don't block response)
7787 // We parse updated refs from the pkt-line protocol in the request
7888 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
Addedsrc/routes/graphql.ts+93−0View fileUnifiedSplit
1/**
2 * Block G2 — GraphQL HTTP endpoint.
3 *
4 * POST /api/graphql — execute { query } against the schema in `lib/graphql`
5 * GET /api/graphql — minimal in-browser "GraphiQL-lite" explorer
6 *
7 * Auth: softAuth only — the schema is queries-only and every resolver enforces
8 * visibility (only public repos surface for logged-out viewers). Writes live on
9 * the REST + /api endpoints.
10 */
11
12import { Hono } from "hono";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import { execute } from "../lib/graphql";
16
17const graphql = new Hono<AuthEnv>();
18graphql.use("*", softAuth);
19
20graphql.post("/api/graphql", async (c) => {
21 let body: { query?: string } = {};
22 try {
23 body = await c.req.json();
24 } catch {
25 return c.json({ errors: [{ message: "Invalid JSON body" }] }, 400);
26 }
27 const q = String(body.query || "");
28 if (!q.trim()) {
29 return c.json({ errors: [{ message: "query is required" }] }, 400);
30 }
31 const user = c.get("user") || null;
32 const result = await execute(q, { user: user ? { id: user.id, username: user.username } : null });
33 return c.json(result);
34});
35
36graphql.get("/api/graphql", (c) => {
37 const sample = `query {
38 viewer { id username email }
39 search(q: "ai", limit: 5) { id name ownerUsername }
40 rateLimit { remaining reset }
41}`;
42 const html = `<!doctype html>
43<html><head><meta charset="UTF-8" /><title>Gluecron GraphQL</title>
44<style>
45 body { background:#0d1117; color:#e6edf3; font-family:system-ui,sans-serif; margin:0; padding:20px; }
46 h1 { font-size:18px; margin:0 0 12px; }
47 .layout { display:grid; grid-template-columns:1fr 1fr; gap:12px; height:85vh; }
48 textarea, pre {
49 background:#161b22; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
50 padding:12px; font-family:monospace; font-size:13px; width:100%; height:100%;
51 box-sizing:border-box; resize:none;
52 }
53 pre { overflow:auto; white-space:pre-wrap; }
54 button {
55 background:#238636; color:#fff; border:0; border-radius:6px;
56 padding:8px 16px; font-weight:600; cursor:pointer; margin-bottom:8px;
57 }
58 a { color:#58a6ff; }
59</style>
60</head><body>
61<h1>gluecron · GraphQL <a href="/">home</a></h1>
62<button onclick="run()">Run (Ctrl+Enter)</button>
63<div class="layout">
64 <textarea id="q" spellcheck="false">${sample.replace(/</g, "&lt;")}</textarea>
65 <pre id="r">{ "hint": "Click Run" }</pre>
66</div>
67<script>
68async function run(){
69 const q = document.getElementById('q').value;
70 document.getElementById('r').textContent = 'Loading…';
71 try {
72 const r = await fetch('/api/graphql', {
73 method:'POST',
74 headers:{'content-type':'application/json'},
75 body: JSON.stringify({ query: q }),
76 credentials:'include'
77 });
78 const j = await r.json();
79 document.getElementById('r').textContent = JSON.stringify(j, null, 2);
80 } catch (e) {
81 document.getElementById('r').textContent = String(e);
82 }
83}
84document.getElementById('q').addEventListener('keydown', (e) => {
85 if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); run(); }
86});
87</script>
88</body></html>`;
89 c.header("content-type", "text/html; charset=utf-8");
90 return c.body(html);
91});
92
93export default graphql;
Addedsrc/routes/health.ts+60−0View fileUnifiedSplit
1/**
2 * Health + metrics endpoints for load-balancer + observability.
3 *
4 * GET /healthz — liveness (always 200 if process alive)
5 * GET /readyz — readiness (checks DB is reachable)
6 * GET /metrics — basic in-process counters
7 */
8
9import { Hono } from "hono";
10import { sql } from "drizzle-orm";
11import { db } from "../db";
12
13const health = new Hono();
14
15const started = Date.now();
16const counters = {
17 requests: 0,
18 errors: 0,
19};
20
21// Count every request that reaches any health route
22health.use("*", async (c, next) => {
23 counters.requests++;
24 await next();
25});
26
27health.get("/healthz", (c) => {
28 return c.json({
29 ok: true,
30 uptimeMs: Date.now() - started,
31 });
32});
33
34health.get("/readyz", async (c) => {
35 try {
36 await db.execute(sql`SELECT 1`);
37 return c.json({ ok: true, db: "up" });
38 } catch (err) {
39 counters.errors++;
40 return c.json(
41 { ok: false, db: "down", error: (err as Error).message },
42 503
43 );
44 }
45});
46
47health.get("/metrics", (c) => {
48 const mem = process.memoryUsage();
49 return c.json({
50 uptimeMs: Date.now() - started,
51 requests: counters.requests,
52 errors: counters.errors,
53 heapUsed: mem.heapUsed,
54 heapTotal: mem.heapTotal,
55 rss: mem.rss,
56 nodeVersion: process.version,
57 });
58});
59
60export default health;
Addedsrc/routes/hooks.ts+505−0View fileUnifiedSplit
1/**
2 * Inbound API hooks — endpoints that external systems (GateTest, CI runners,
3 * Crontech deploy) call into to report async results.
4 *
5 * Security: every hook is authenticated via a shared-secret Bearer token OR
6 * HMAC signature over the raw body. Configure secrets via env vars:
7 * GATETEST_CALLBACK_SECRET — bearer token GateTest must present
8 * GATETEST_HMAC_SECRET — optional HMAC-SHA256 secret over the raw body
9 *
10 * Endpoints:
11 * POST /api/hooks/gatetest — GateTest scan result callback
12 * POST /api/hooks/gatetest/status — periodic status / heartbeat (optional)
13 *
14 * The GateTest service should POST JSON of shape:
15 * {
16 * "repository": "owner/repo",
17 * "sha": "<full-commit-sha>",
18 * "ref": "refs/heads/main",
19 * "pullRequestNumber": 42, // optional
20 * "status": "passed" | "failed" | "error",
21 * "summary": "12 tests passed, 0 failed",
22 * "details": { ... } // optional, persisted as JSON
23 * }
24 *
25 * Response: 200 OK with {ok:true, gateRunId} on success, 401 on auth failure,
26 * 400 on malformed payload, 404 if the repo is unknown.
27 */
28
29import { Hono } from "hono";
30import { and, desc, eq } from "drizzle-orm";
31import { createHmac, timingSafeEqual } from "crypto";
32import { db } from "../db";
33import {
34 apiTokens,
35 gateRuns,
36 pullRequests,
37 repositories,
38 users,
39} from "../db/schema";
40import { notify, audit } from "../lib/notify";
41
42const hooks = new Hono();
43
44interface GateTestPayload {
45 repository?: string;
46 sha?: string;
47 ref?: string;
48 pullRequestNumber?: number;
49 status?: "passed" | "failed" | "error" | "success";
50 summary?: string;
51 details?: unknown;
52 durationMs?: number;
53}
54
55function constantTimeEq(a: string, b: string): boolean {
56 const A = Buffer.from(a);
57 const B = Buffer.from(b);
58 if (A.length !== B.length) return false;
59 try {
60 return timingSafeEqual(A, B);
61 } catch {
62 return false;
63 }
64}
65
66function verifyGateTestAuth(c: any, rawBody: string): { ok: boolean; error?: string } {
67 const bearerSecret = process.env.GATETEST_CALLBACK_SECRET || "";
68 const hmacSecret = process.env.GATETEST_HMAC_SECRET || "";
69
70 // If no secret is configured, refuse by default — do NOT allow anonymous writes.
71 if (!bearerSecret && !hmacSecret) {
72 return {
73 ok: false,
74 error:
75 "Callback endpoint not configured: set GATETEST_CALLBACK_SECRET or GATETEST_HMAC_SECRET",
76 };
77 }
78
79 // Bearer auth (simpler, preferred for server-to-server)
80 if (bearerSecret) {
81 const auth = c.req.header("authorization") || "";
82 if (auth.startsWith("Bearer ")) {
83 const token = auth.slice(7).trim();
84 if (constantTimeEq(token, bearerSecret)) return { ok: true };
85 }
86 // Alternative header for tools that don't send Authorization
87 const xTok = c.req.header("x-gatetest-token") || "";
88 if (xTok && constantTimeEq(xTok, bearerSecret)) return { ok: true };
89 }
90
91 // HMAC signature over the raw body
92 if (hmacSecret) {
93 const sigHeader =
94 c.req.header("x-gatetest-signature") ||
95 c.req.header("x-signature-sha256") ||
96 "";
97 if (sigHeader) {
98 const expected =
99 "sha256=" +
100 createHmac("sha256", hmacSecret).update(rawBody).digest("hex");
101 if (constantTimeEq(sigHeader, expected)) return { ok: true };
102 }
103 }
104
105 return { ok: false, error: "Invalid or missing GateTest credentials" };
106}
107
108async function resolveRepo(full: string): Promise<{ id: string; ownerId: string; name: string } | null> {
109 if (!full || !full.includes("/")) return null;
110 const [owner, name] = full.split("/", 2);
111 try {
112 const [row] = await db
113 .select({
114 id: repositories.id,
115 ownerId: repositories.ownerId,
116 name: repositories.name,
117 })
118 .from(repositories)
119 .innerJoin(users, eq(repositories.ownerId, users.id))
120 .where(and(eq(users.username, owner), eq(repositories.name, name)))
121 .limit(1);
122 return row || null;
123 } catch {
124 return null;
125 }
126}
127
128async function resolvePullRequestId(
129 repositoryId: string,
130 number: number | undefined
131): Promise<string | null> {
132 if (!number) return null;
133 try {
134 const [row] = await db
135 .select({ id: pullRequests.id })
136 .from(pullRequests)
137 .where(
138 and(
139 eq(pullRequests.repositoryId, repositoryId),
140 eq(pullRequests.number, number)
141 )
142 )
143 .limit(1);
144 return row?.id || null;
145 } catch {
146 return null;
147 }
148}
149
150/**
151 * POST /api/hooks/gatetest
152 * Async scan result callback from GateTest.
153 */
154hooks.post("/api/hooks/gatetest", async (c) => {
155 const rawBody = await c.req.text();
156
157 const auth = verifyGateTestAuth(c, rawBody);
158 if (!auth.ok) {
159 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
160 }
161
162 let payload: GateTestPayload;
163 try {
164 payload = JSON.parse(rawBody) as GateTestPayload;
165 } catch {
166 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
167 }
168
169 if (!payload.repository || !payload.sha || !payload.status) {
170 return c.json(
171 {
172 ok: false,
173 error: "Required fields: repository (owner/name), sha, status",
174 },
175 400
176 );
177 }
178
179 const repo = await resolveRepo(payload.repository);
180 if (!repo) {
181 return c.json(
182 { ok: false, error: `Unknown repository: ${payload.repository}` },
183 404
184 );
185 }
186
187 const normalisedStatus =
188 payload.status === "passed" || payload.status === "success"
189 ? "passed"
190 : payload.status === "failed"
191 ? "failed"
192 : "failed"; // treat "error" as a hard fail
193
194 const pullRequestId = await resolvePullRequestId(
195 repo.id,
196 payload.pullRequestNumber
197 );
198
199 let gateRunId: string | null = null;
200 try {
201 const [row] = await db
202 .insert(gateRuns)
203 .values({
204 repositoryId: repo.id,
205 pullRequestId: pullRequestId || undefined,
206 commitSha: payload.sha,
207 ref: payload.ref || "refs/heads/main",
208 gateName: "GateTest",
209 status: normalisedStatus,
210 summary: payload.summary || `GateTest reported ${normalisedStatus}`,
211 details: payload.details ? JSON.stringify(payload.details) : null,
212 durationMs: payload.durationMs,
213 completedAt: new Date(),
214 })
215 .returning({ id: gateRuns.id });
216 gateRunId = row?.id || null;
217 } catch (err) {
218 console.error("[hooks/gatetest] insert failed:", err);
219 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
220 }
221
222 // Notify the repo owner on failure
223 if (normalisedStatus === "failed") {
224 try {
225 await notify(repo.ownerId, {
226 kind: "gate_failed",
227 title: `GateTest failed on ${payload.repository}`,
228 body: payload.summary || "GateTest reported failure via callback",
229 repositoryId: repo.id,
230 });
231 } catch (err) {
232 console.error("[hooks/gatetest] notify failed:", err);
233 }
234 }
235
236 try {
237 await audit({
238 userId: null,
239 action: "gate_callback",
240 repositoryId: repo.id,
241 metadata: {
242 gateName: "GateTest",
243 sha: payload.sha,
244 status: normalisedStatus,
245 source: "gatetest-callback",
246 },
247 });
248 } catch {
249 /* swallow */
250 }
251
252 return c.json({ ok: true, gateRunId });
253});
254
255/**
256 * GET /api/hooks/gatetest/recent
257 * Small read-only endpoint GateTest can hit to verify connectivity +
258 * see the 10 most recent gate runs for sanity checks.
259 */
260hooks.get("/api/hooks/gatetest/recent", async (c) => {
261 const rawBody = "";
262 const auth = verifyGateTestAuth(c, rawBody);
263 if (!auth.ok) {
264 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
265 }
266
267 try {
268 const rows = await db
269 .select({
270 id: gateRuns.id,
271 gateName: gateRuns.gateName,
272 status: gateRuns.status,
273 summary: gateRuns.summary,
274 createdAt: gateRuns.createdAt,
275 })
276 .from(gateRuns)
277 .orderBy(desc(gateRuns.createdAt))
278 .limit(10);
279 return c.json({ ok: true, runs: rows });
280 } catch (err) {
281 console.error("[hooks/gatetest] recent failed:", err);
282 return c.json({ ok: false, error: "DB error" }, 500);
283 }
284});
285
286/**
287 * GET /api/hooks/ping
288 * Unauthenticated liveness — for GateTest to probe reachability without
289 * needing credentials. Returns 200 + version info.
290 */
291hooks.get("/api/hooks/ping", (c) => {
292 return c.json({
293 ok: true,
294 service: "gluecron",
295 hooks: ["gatetest", "gatetest/recent", "api/v1/gate-runs (backup)"],
296 timestamp: new Date().toISOString(),
297 });
298});
299
300// ---------------------------------------------------------------------------
301// Backup API: personal-access-token path
302//
303// If for any reason the shared-secret callback path is unavailable,
304// GateTest can authenticate with a standard GlueCron personal access token
305// and POST the same payload to /api/v1/gate-runs.
306//
307// Advantages of the backup path:
308// - no new secrets to provision (reuses existing PAT infra)
309// - scoped per-user (audit trail points at a real account)
310// - revocable from /settings/tokens
311//
312// Trade-off: slightly heavier auth (DB lookup per call), so prefer
313// /api/hooks/gatetest for high-volume traffic.
314// ---------------------------------------------------------------------------
315
316async function hashPat(token: string): Promise<string> {
317 const data = new TextEncoder().encode(token);
318 const hash = await crypto.subtle.digest("SHA-256", data);
319 return Array.from(new Uint8Array(hash))
320 .map((b) => b.toString(16).padStart(2, "0"))
321 .join("");
322}
323
324async function verifyPatAuth(
325 c: any
326): Promise<{ ok: boolean; userId?: string; error?: string }> {
327 const auth = c.req.header("authorization") || "";
328 const rawToken =
329 (auth.startsWith("Bearer ") ? auth.slice(7) : "").trim() ||
330 (c.req.header("x-api-token") || "").trim();
331 if (!rawToken) {
332 return { ok: false, error: "Missing Bearer token" };
333 }
334 if (!rawToken.startsWith("glc_")) {
335 return { ok: false, error: "Invalid token format" };
336 }
337 try {
338 const hashed = await hashPat(rawToken);
339 const [row] = await db
340 .select({ id: apiTokens.id, userId: apiTokens.userId, expiresAt: apiTokens.expiresAt })
341 .from(apiTokens)
342 .where(eq(apiTokens.tokenHash, hashed))
343 .limit(1);
344 if (!row) return { ok: false, error: "Unknown token" };
345 if (row.expiresAt && row.expiresAt < new Date()) {
346 return { ok: false, error: "Token expired" };
347 }
348 // Update last-used timestamp (fire and forget)
349 db.update(apiTokens)
350 .set({ lastUsedAt: new Date() })
351 .where(eq(apiTokens.id, row.id))
352 .catch(() => {});
353 return { ok: true, userId: row.userId };
354 } catch {
355 return { ok: false, error: "Auth lookup failed" };
356 }
357}
358
359/**
360 * POST /api/v1/gate-runs
361 * Backup path — accepts a personal access token and records a gate run.
362 * Identical payload shape to /api/hooks/gatetest.
363 */
364hooks.post("/api/v1/gate-runs", async (c) => {
365 const rawBody = await c.req.text();
366
367 const auth = await verifyPatAuth(c);
368 if (!auth.ok) {
369 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
370 }
371
372 let payload: GateTestPayload & { gateName?: string };
373 try {
374 payload = JSON.parse(rawBody);
375 } catch {
376 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
377 }
378
379 if (!payload.repository || !payload.sha || !payload.status) {
380 return c.json(
381 { ok: false, error: "Required: repository, sha, status" },
382 400
383 );
384 }
385
386 const repo = await resolveRepo(payload.repository);
387 if (!repo) {
388 return c.json(
389 { ok: false, error: `Unknown repository: ${payload.repository}` },
390 404
391 );
392 }
393
394 // Scope check: the PAT's owner must own the repo OR have admin/write scope.
395 // For MVP, require the token owner to match the repo owner.
396 if (repo.ownerId !== auth.userId) {
397 return c.json(
398 { ok: false, error: "Token does not own this repository" },
399 403
400 );
401 }
402
403 const normalisedStatus =
404 payload.status === "passed" || payload.status === "success"
405 ? "passed"
406 : payload.status === "failed"
407 ? "failed"
408 : "failed";
409
410 const pullRequestId = await resolvePullRequestId(
411 repo.id,
412 payload.pullRequestNumber
413 );
414
415 let gateRunId: string | null = null;
416 try {
417 const [row] = await db
418 .insert(gateRuns)
419 .values({
420 repositoryId: repo.id,
421 pullRequestId: pullRequestId || undefined,
422 commitSha: payload.sha,
423 ref: payload.ref || "refs/heads/main",
424 gateName: payload.gateName || "GateTest",
425 status: normalisedStatus,
426 summary: payload.summary || `${payload.gateName || "GateTest"} reported ${normalisedStatus}`,
427 details: payload.details ? JSON.stringify(payload.details) : null,
428 durationMs: payload.durationMs,
429 completedAt: new Date(),
430 })
431 .returning({ id: gateRuns.id });
432 gateRunId = row?.id || null;
433 } catch (err) {
434 console.error("[hooks/backup] insert failed:", err);
435 return c.json({ ok: false, error: "Failed to record gate run" }, 500);
436 }
437
438 if (normalisedStatus === "failed") {
439 try {
440 await notify(repo.ownerId, {
441 kind: "gate_failed",
442 title: `${payload.gateName || "GateTest"} failed on ${payload.repository}`,
443 body: payload.summary || "Gate failure reported via backup API",
444 repositoryId: repo.id,
445 });
446 } catch {
447 /* swallow */
448 }
449 }
450
451 try {
452 await audit({
453 userId: auth.userId,
454 action: "gate_callback_backup",
455 repositoryId: repo.id,
456 metadata: {
457 gateName: payload.gateName || "GateTest",
458 sha: payload.sha,
459 status: normalisedStatus,
460 source: "pat-api",
461 },
462 });
463 } catch {
464 /* swallow */
465 }
466
467 return c.json({ ok: true, gateRunId });
468});
469
470/**
471 * GET /api/v1/gate-runs?repository=owner/name&limit=20
472 * Backup read path — list recent gate runs for a repo (PAT-authed).
473 */
474hooks.get("/api/v1/gate-runs", async (c) => {
475 const auth = await verifyPatAuth(c);
476 if (!auth.ok) {
477 return c.json({ ok: false, error: auth.error || "Unauthorized" }, 401);
478 }
479
480 const repoFull = c.req.query("repository") || "";
481 const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") || 20)));
482 if (!repoFull) {
483 return c.json({ ok: false, error: "Query param 'repository' required" }, 400);
484 }
485
486 const repo = await resolveRepo(repoFull);
487 if (!repo) return c.json({ ok: false, error: "Unknown repository" }, 404);
488 if (repo.ownerId !== auth.userId) {
489 return c.json({ ok: false, error: "Forbidden" }, 403);
490 }
491
492 try {
493 const rows = await db
494 .select()
495 .from(gateRuns)
496 .where(eq(gateRuns.repositoryId, repo.id))
497 .orderBy(desc(gateRuns.createdAt))
498 .limit(limit);
499 return c.json({ ok: true, runs: rows });
500 } catch {
501 return c.json({ ok: false, error: "DB error" }, 500);
502 }
503});
504
505export default hooks;
Addedsrc/routes/insights.tsx+457−0View fileUnifiedSplit
1/**
2 * Repo insights + milestones.
3 *
4 * GET /:owner/:repo/insights — contributors, commit activity, gate health, AI-generated summary
5 * GET /:owner/:repo/milestones — list
6 * POST /:owner/:repo/milestones — create
7 * POST /:owner/:repo/milestones/:id/close — close
8 * POST /:owner/:repo/milestones/:id/reopen — reopen
9 * POST /:owner/:repo/milestones/:id/delete — delete
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq, sql } from "drizzle-orm";
14import { db } from "../db";
15import {
16 gateRuns,
17 issues,
18 milestones,
19 pullRequests,
20 repositories,
21 users,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { getUnreadCount } from "../lib/unread";
28import { listCommits } from "../git/repository";
29
30const insights = new Hono<AuthEnv>();
31insights.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50// ---------- Insights ----------
51
52insights.get("/:owner/:repo/insights", async (c) => {
53 const user = c.get("user");
54 const { owner, repo } = c.req.param();
55 const repoRow = await loadRepo(owner, repo);
56 if (!repoRow) return c.notFound();
57
58 const unread = user ? await getUnreadCount(user.id) : 0;
59
60 // Commit activity — last 200 commits on default
61 const commits = await listCommits(
62 owner,
63 repo,
64 repoRow.defaultBranch,
65 200
66 );
67
68 // Contributors by commit count
69 const byAuthor = new Map<string, number>();
70 for (const c0 of commits) {
71 byAuthor.set(c0.author, (byAuthor.get(c0.author) || 0) + 1);
72 }
73 const contributors = [...byAuthor.entries()]
74 .sort((a, b) => b[1] - a[1])
75 .slice(0, 10);
76
77 // Commits per day (last 30)
78 const dayCounts = new Map<string, number>();
79 for (const c0 of commits) {
80 const day = c0.date.slice(0, 10);
81 dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
82 }
83 const days: Array<{ date: string; count: number }> = [];
84 const now = new Date();
85 for (let i = 29; i >= 0; i--) {
86 const d = new Date(now);
87 d.setDate(d.getDate() - i);
88 const key = d.toISOString().slice(0, 10);
89 days.push({ date: key, count: dayCounts.get(key) || 0 });
90 }
91 const maxDay = Math.max(1, ...days.map((d) => d.count));
92
93 // Gate health 30d
94 const gateStats = await db
95 .select({
96 status: gateRuns.status,
97 c: sql<number>`count(*)::int`,
98 })
99 .from(gateRuns)
100 .where(eq(gateRuns.repositoryId, repoRow.id))
101 .groupBy(gateRuns.status);
102
103 const statTotals: Record<string, number> = {};
104 let totalRuns = 0;
105 for (const r of gateStats) {
106 statTotals[r.status] = r.c;
107 totalRuns += r.c;
108 }
109 const greenRate =
110 totalRuns === 0
111 ? 100
112 : Math.round(
113 (((statTotals.passed || 0) +
114 (statTotals.repaired || 0) +
115 (statTotals.skipped || 0)) /
116 totalRuns) *
117 100
118 );
119
120 // Issues + PR counts
121 const [issueStats] = await db
122 .select({
123 open: sql<number>`count(*) filter (where ${issues.state} = 'open')::int`,
124 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')::int`,
125 })
126 .from(issues)
127 .where(eq(issues.repositoryId, repoRow.id));
128
129 const [prStats] = await db
130 .select({
131 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')::int`,
132 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
133 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
134 })
135 .from(pullRequests)
136 .where(eq(pullRequests.repositoryId, repoRow.id));
137
138 return c.html(
139 <Layout
140 title={`Insights — ${owner}/${repo}`}
141 user={user}
142 notificationCount={unread}
143 >
144 <RepoHeader
145 owner={owner}
146 repo={repo}
147 starCount={repoRow.starCount}
148 forkCount={repoRow.forkCount}
149 currentUser={user?.username || null}
150 />
151 <RepoNav owner={owner} repo={repo} active="insights" />
152 <h3 style="margin-bottom: 16px">Insights</h3>
153
154 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
155 <div class="panel" style="padding: 16px">
156 <div style="font-size: 28px; font-weight: 700; color: var(--green)">
157 {greenRate}%
158 </div>
159 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
160 Green rate
161 </div>
162 </div>
163 <div class="panel" style="padding: 16px">
164 <div style="font-size: 28px; font-weight: 700">{commits.length}</div>
165 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
166 Recent commits
167 </div>
168 </div>
169 <div class="panel" style="padding: 16px">
170 <div style="font-size: 28px; font-weight: 700">
171 {(prStats?.open || 0) + (prStats?.merged || 0) + (prStats?.closed || 0)}
172 </div>
173 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
174 Pull requests
175 </div>
176 </div>
177 <div class="panel" style="padding: 16px">
178 <div style="font-size: 28px; font-weight: 700">
179 {(issueStats?.open || 0) + (issueStats?.closed || 0)}
180 </div>
181 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
182 Issues
183 </div>
184 </div>
185 </div>
186
187 <div class="dashboard-section">
188 <h3>Commit activity (last 30 days)</h3>
189 <div class="panel" style="padding: 16px">
190 <div style="display: flex; align-items: flex-end; gap: 2px; height: 80px">
191 {days.map((d) => (
192 <div
193 title={`${d.date}: ${d.count} commits`}
194 style={`flex: 1; background: var(--accent); height: ${Math.max(2, (d.count / maxDay) * 80)}px; border-radius: 2px; opacity: ${d.count === 0 ? 0.2 : 1}`}
195 ></div>
196 ))}
197 </div>
198 <div style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--text-muted)">
199 <span>{days[0].date}</span>
200 <span>{days[days.length - 1].date}</span>
201 </div>
202 </div>
203 </div>
204
205 <div class="dashboard-section">
206 <h3>Top contributors</h3>
207 <div class="panel">
208 {contributors.length === 0 ? (
209 <div class="panel-empty">No contributors yet.</div>
210 ) : (
211 contributors.map(([author, count]) => {
212 const pct = Math.round(
213 (count / contributors[0][1]) * 100
214 );
215 return (
216 <div class="panel-item">
217 <div style="flex: 1">
218 <div style="font-weight: 500">{author}</div>
219 <div
220 style={`height: 6px; background: var(--accent); border-radius: 3px; margin-top: 4px; width: ${pct}%`}
221 ></div>
222 </div>
223 <div style="font-size: 12px; color: var(--text-muted); width: 80px; text-align: right">
224 {count} commit{count !== 1 ? "s" : ""}
225 </div>
226 </div>
227 );
228 })
229 )}
230 </div>
231 </div>
232 </Layout>
233 );
234});
235
236// ---------- Milestones ----------
237
238insights.get("/:owner/:repo/milestones", async (c) => {
239 const user = c.get("user");
240 const { owner, repo } = c.req.param();
241 const repoRow = await loadRepo(owner, repo);
242 if (!repoRow) return c.notFound();
243 const unread = user ? await getUnreadCount(user.id) : 0;
244 const state = c.req.query("state") || "open";
245
246 const rows = await db
247 .select()
248 .from(milestones)
249 .where(
250 and(
251 eq(milestones.repositoryId, repoRow.id),
252 eq(milestones.state, state)
253 )
254 )
255 .orderBy(desc(milestones.createdAt));
256
257 return c.html(
258 <Layout
259 title={`Milestones — ${owner}/${repo}`}
260 user={user}
261 notificationCount={unread}
262 >
263 <RepoHeader
264 owner={owner}
265 repo={repo}
266 starCount={repoRow.starCount}
267 forkCount={repoRow.forkCount}
268 currentUser={user?.username || null}
269 />
270 <RepoNav owner={owner} repo={repo} active="issues" />
271 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
272 <div class="issue-tabs">
273 <a
274 href={`/${owner}/${repo}/milestones?state=open`}
275 class={state === "open" ? "active" : ""}
276 >
277 Open
278 </a>
279 <a
280 href={`/${owner}/${repo}/milestones?state=closed`}
281 class={state === "closed" ? "active" : ""}
282 >
283 Closed
284 </a>
285 </div>
286 {user && user.id === repoRow.ownerId && (
287 <a
288 href={`/${owner}/${repo}/milestones#new`}
289 class="btn btn-primary btn-sm"
290 >
291 + New milestone
292 </a>
293 )}
294 </div>
295
296 {rows.length === 0 ? (
297 <div class="empty-state">
298 <p>No {state} milestones.</p>
299 </div>
300 ) : (
301 <div class="panel" style="margin-bottom: 24px">
302 {rows.map((m) => (
303 <div class="panel-item" style="justify-content: space-between">
304 <div style="flex: 1">
305 <div style="font-weight: 600">{m.title}</div>
306 {m.description && (
307 <div class="meta" style="margin-top: 2px">{m.description}</div>
308 )}
309 <div class="meta" style="margin-top: 2px">
310 {m.dueDate
311 ? `Due ${new Date(m.dueDate).toLocaleDateString()}`
312 : "No due date"}
313 </div>
314 </div>
315 {user && user.id === repoRow.ownerId && (
316 <div style="display: flex; gap: 4px">
317 {m.state === "open" ? (
318 <form
319 method="POST"
320 action={`/${owner}/${repo}/milestones/${m.id}/close`}
321 >
322 <button type="submit" class="btn btn-sm">
323 Close
324 </button>
325 </form>
326 ) : (
327 <form
328 method="POST"
329 action={`/${owner}/${repo}/milestones/${m.id}/reopen`}
330 >
331 <button type="submit" class="btn btn-sm">
332 Reopen
333 </button>
334 </form>
335 )}
336 <form
337 method="POST"
338 action={`/${owner}/${repo}/milestones/${m.id}/delete`}
339 onsubmit="return confirm('Delete this milestone?')"
340 >
341 <button type="submit" class="btn btn-sm btn-danger">
342 Delete
343 </button>
344 </form>
345 </div>
346 )}
347 </div>
348 ))}
349 </div>
350 )}
351
352 {user && user.id === repoRow.ownerId && (
353 <form
354 id="new"
355 method="POST"
356 action={`/${owner}/${repo}/milestones`}
357 class="panel"
358 style="padding: 16px"
359 >
360 <h3 style="margin-bottom: 12px">Create milestone</h3>
361 <div class="form-group">
362 <label>Title</label>
363 <input type="text" name="title" required />
364 </div>
365 <div class="form-group">
366 <label>Description</label>
367 <textarea name="description" rows={3}></textarea>
368 </div>
369 <div class="form-group">
370 <label>Due date (optional)</label>
371 <input type="date" name="dueDate" />
372 </div>
373 <button type="submit" class="btn btn-primary">
374 Create
375 </button>
376 </form>
377 )}
378 </Layout>
379 );
380});
381
382insights.post("/:owner/:repo/milestones", requireAuth, async (c) => {
383 const user = c.get("user")!;
384 const { owner, repo } = c.req.param();
385 const repoRow = await loadRepo(owner, repo);
386 if (!repoRow) return c.notFound();
387 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/milestones`);
388
389 const body = await c.req.parseBody();
390 const title = String(body.title || "").trim();
391 if (!title) return c.redirect(`/${owner}/${repo}/milestones`);
392 const description = String(body.description || "").trim() || null;
393 const dueDateRaw = String(body.dueDate || "").trim();
394 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
395
396 try {
397 await db.insert(milestones).values({
398 repositoryId: repoRow.id,
399 title,
400 description,
401 dueDate,
402 });
403 } catch (err) {
404 console.error("[milestones] create:", err);
405 }
406
407 return c.redirect(`/${owner}/${repo}/milestones`);
408});
409
410insights.post("/:owner/:repo/milestones/:id/close", requireAuth, async (c) => {
411 const user = c.get("user")!;
412 const { owner, repo, id } = c.req.param();
413 const repoRow = await loadRepo(owner, repo);
414 if (!repoRow || repoRow.ownerId !== user.id) {
415 return c.redirect(`/${owner}/${repo}/milestones`);
416 }
417 await db
418 .update(milestones)
419 .set({ state: "closed", closedAt: new Date() })
420 .where(
421 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
422 );
423 return c.redirect(`/${owner}/${repo}/milestones`);
424});
425
426insights.post("/:owner/:repo/milestones/:id/reopen", requireAuth, async (c) => {
427 const user = c.get("user")!;
428 const { owner, repo, id } = c.req.param();
429 const repoRow = await loadRepo(owner, repo);
430 if (!repoRow || repoRow.ownerId !== user.id) {
431 return c.redirect(`/${owner}/${repo}/milestones`);
432 }
433 await db
434 .update(milestones)
435 .set({ state: "open", closedAt: null })
436 .where(
437 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
438 );
439 return c.redirect(`/${owner}/${repo}/milestones?state=closed`);
440});
441
442insights.post("/:owner/:repo/milestones/:id/delete", requireAuth, async (c) => {
443 const user = c.get("user")!;
444 const { owner, repo, id } = c.req.param();
445 const repoRow = await loadRepo(owner, repo);
446 if (!repoRow || repoRow.ownerId !== user.id) {
447 return c.redirect(`/${owner}/${repo}/milestones`);
448 }
449 await db
450 .delete(milestones)
451 .where(
452 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
453 );
454 return c.redirect(`/${owner}/${repo}/milestones`);
455});
456
457export default insights;
Modifiedsrc/routes/issues.tsx+12−0View fileUnifiedSplit
1515} from "../db/schema";
1616import { Layout } from "../views/layout";
1717import { RepoHeader, RepoNav } from "../views/components";
18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
20import { loadIssueTemplate } from "../lib/templates";
1821import { renderMarkdown } from "../lib/markdown";
1922import { softAuth, requireAuth } from "../middleware/auth";
2023import type { AuthEnv } from "../middleware/auth";
181184 const { owner: ownerName, repo: repoName } = c.req.param();
182185 const user = c.get("user")!;
183186 const error = c.req.query("error");
187 const template = await loadIssueTemplate(ownerName, repoName);
184188
185189 return c.html(
186190 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
314318 .where(eq(issueComments.issueId, issue.id))
315319 .orderBy(asc(issueComments.createdAt));
316320
321 // Load reactions for the issue + each comment in parallel.
322 const [issueReactions, ...commentReactions] = await Promise.all([
323 summariseReactions("issue", issue.id, user?.id),
324 ...comments.map((row) =>
325 summariseReactions("issue_comment", row.comment.id, user?.id)
326 ),
327 ]);
328
317329 const canManage =
318330 user &&
319331 (user.id === resolved.owner.id || user.id === issue.authorId);
Addedsrc/routes/marketplace.tsx+542−0View fileUnifiedSplit
1/**
2 * Block H — Marketplace UI + developer-side app management.
3 *
4 * GET /marketplace — public app directory (search)
5 * GET /marketplace/:slug — app detail + install CTA
6 * POST /marketplace/:slug/install — install to user (v1 only)
7 * POST /marketplace/installations/:id/uninstall
8 * — revoke access
9 * GET /settings/apps — list installed apps
10 * GET /developer/apps-new — register a new app
11 * POST /developer/apps-new — create app + bot
12 * GET /developer/apps/:slug/manage — event log + install count
13 * POST /developer/apps/:slug/tokens/new — issue install token (for testing)
14 */
15
16import { Hono } from "hono";
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { apps, appInstallations } from "../db/schema";
20import { Layout } from "../views/layout";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 KNOWN_PERMISSIONS,
25 KNOWN_EVENTS,
26 countInstalls,
27 createApp,
28 getAppBySlug,
29 installApp,
30 issueInstallToken,
31 listEventsForApp,
32 listInstallationsForApp,
33 listInstallationsForTarget,
34 listPublicApps,
35 normalisePermissions,
36 parsePermissions,
37 uninstallApp,
38} from "../lib/marketplace";
39import { audit } from "../lib/notify";
40
41const marketplace = new Hono<AuthEnv>();
42marketplace.use("*", softAuth);
43
44// ---------- Public directory ----------
45
46marketplace.get("/marketplace", async (c) => {
47 const user = c.get("user");
48 const q = c.req.query("q") || "";
49 const list = await listPublicApps(q);
50 return c.html(
51 <Layout title="Marketplace — Gluecron" user={user}>
52 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
53 <h2>Marketplace</h2>
54 {user && (
55 <a href="/developer/apps-new" class="btn btn-sm">
56 + Register app
57 </a>
58 )}
59 </div>
60 <form method="GET" action="/marketplace" style="margin-bottom:16px">
61 <input
62 type="text"
63 name="q"
64 value={q}
65 placeholder="Search apps"
66 style="width:320px"
67 />{" "}
68 <button type="submit" class="btn">
69 Search
70 </button>
71 </form>
72 <div
73 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px"
74 >
75 {list.length === 0 ? (
76 <div class="panel-empty">No apps found.</div>
77 ) : (
78 list.map((a) => (
79 <a
80 href={`/marketplace/${a.slug}`}
81 class="panel"
82 style="padding:16px;color:inherit;text-decoration:none"
83 >
84 <div style="font-size:15px;font-weight:700">{a.name}</div>
85 <div
86 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
87 >
88 {a.description.slice(0, 140) || "No description."}
89 </div>
90 <div
91 style="font-size:11px;color:var(--text-muted);margin-top:8px;text-transform:uppercase"
92 >
93 {parsePermissions(a.permissions).length} permissions
94 </div>
95 </a>
96 ))
97 )}
98 </div>
99 </Layout>
100 );
101});
102
103marketplace.get("/marketplace/:slug", async (c) => {
104 const user = c.get("user");
105 const slug = c.req.param("slug");
106 const app = await getAppBySlug(slug);
107 if (!app || !app.isPublic) return c.notFound();
108 const [installs, perms] = await Promise.all([
109 countInstalls(app.id),
110 Promise.resolve(parsePermissions(app.permissions)),
111 ]);
112 return c.html(
113 <Layout title={`${app.name} — Marketplace`} user={user}>
114 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
115 <h2>{app.name}</h2>
116 <a href="/marketplace" class="btn btn-sm">
117 Back
118 </a>
119 </div>
120 <div class="panel" style="padding:16px;margin-bottom:20px">
121 <p>{app.description || "No description."}</p>
122 {app.homepageUrl && (
123 <p style="margin-top:8px">
124 Homepage: <a href={app.homepageUrl}>{app.homepageUrl}</a>
125 </p>
126 )}
127 <div style="font-size:12px;color:var(--text-muted);margin-top:8px">
128 {installs} install{installs === 1 ? "" : "s"} · bot username{" "}
129 <code>{app.slug}[bot]</code>
130 </div>
131 </div>
132
133 <h3>Permissions</h3>
134 <div class="panel" style="margin-bottom:20px">
135 {perms.length === 0 ? (
136 <div class="panel-empty">No permissions requested.</div>
137 ) : (
138 perms.map((p) => (
139 <div class="panel-item">
140 <code>{p}</code>
141 </div>
142 ))
143 )}
144 </div>
145
146 {user ? (
147 <form method="POST" action={`/marketplace/${slug}/install`}>
148 <div class="form-group">
149 <p
150 style="font-size:13px;color:var(--text-muted);margin-bottom:8px"
151 >
152 Installing grants {perms.length} permission
153 {perms.length === 1 ? "" : "s"} to <strong>{app.name}</strong>{" "}
154 on your personal account.
155 </p>
156 </div>
157 {perms.map((p) => (
158 <label
159 style="display:inline-block;margin-right:10px;font-size:13px"
160 >
161 <input
162 type="checkbox"
163 name="permissions"
164 value={p}
165 checked
166 />{" "}
167 {p}
168 </label>
169 ))}
170 <div style="margin-top:12px">
171 <button type="submit" class="btn btn-primary">
172 Install
173 </button>
174 </div>
175 </form>
176 ) : (
177 <div class="panel" style="padding:16px;text-align:center">
178 <a href={`/login?next=/marketplace/${slug}`}>Sign in to install</a>
179 </div>
180 )}
181 </Layout>
182 );
183});
184
185marketplace.post("/marketplace/:slug/install", requireAuth, async (c) => {
186 const user = c.get("user")!;
187 const slug = c.req.param("slug");
188 const app = await getAppBySlug(slug);
189 if (!app) return c.notFound();
190 const body = await c.req.parseBody({ all: true });
191 const rawPerms = body.permissions;
192 const perms = Array.isArray(rawPerms)
193 ? rawPerms.map(String)
194 : rawPerms
195 ? [String(rawPerms)]
196 : [];
197 const inst = await installApp({
198 appId: app.id,
199 installedBy: user.id,
200 targetType: "user",
201 targetId: user.id,
202 grantedPermissions: perms,
203 });
204 if (inst) {
205 await audit({
206 userId: user.id,
207 action: "marketplace.install",
208 targetType: "app",
209 targetId: app.id,
210 metadata: { grantedPermissions: normalisePermissions(perms) },
211 });
212 }
213 return c.redirect("/settings/apps");
214});
215
216marketplace.post(
217 "/marketplace/installations/:id/uninstall",
218 requireAuth,
219 async (c) => {
220 const user = c.get("user")!;
221 const id = c.req.param("id");
222 // Only the installer can uninstall
223 const [inst] = await db
224 .select()
225 .from(appInstallations)
226 .where(eq(appInstallations.id, id))
227 .limit(1);
228 if (!inst || inst.installedBy !== user.id) {
229 return c.text("forbidden", 403);
230 }
231 const ok = await uninstallApp(id);
232 if (ok) {
233 await audit({
234 userId: user.id,
235 action: "marketplace.uninstall",
236 targetType: "app_installation",
237 targetId: id,
238 });
239 }
240 return c.redirect("/settings/apps");
241 }
242);
243
244// ---------- Personal installs ----------
245
246marketplace.get("/settings/apps", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const installs = await listInstallationsForTarget("user", user.id);
249 return c.html(
250 <Layout title="Installed apps — Gluecron" user={user}>
251 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
252 <h2>Installed apps</h2>
253 <a href="/marketplace" class="btn btn-sm">
254 Browse marketplace
255 </a>
256 </div>
257 <div class="panel">
258 {installs.length === 0 ? (
259 <div class="panel-empty">
260 No apps installed.{" "}
261 <a href="/marketplace">Browse the marketplace</a>.
262 </div>
263 ) : (
264 installs.map((i) => (
265 <div class="panel-item" style="justify-content:space-between">
266 <div style="flex:1;min-width:0">
267 <a
268 href={i.app ? `/marketplace/${i.app.slug}` : "#"}
269 style="font-weight:600"
270 >
271 {i.app?.name || "(unknown app)"}
272 </a>
273 <div
274 style="font-size:12px;color:var(--text-muted);margin-top:2px"
275 >
276 {parsePermissions(i.grantedPermissions).length} permissions ·
277 installed{" "}
278 {i.createdAt
279 ? new Date(i.createdAt).toLocaleDateString()
280 : ""}
281 </div>
282 </div>
283 <form
284 method="POST"
285 action={`/marketplace/installations/${i.id}/uninstall`}
286 onsubmit="return confirm('Uninstall this app?')"
287 >
288 <button type="submit" class="btn btn-sm btn-danger">
289 Uninstall
290 </button>
291 </form>
292 </div>
293 ))
294 )}
295 </div>
296 </Layout>
297 );
298});
299
300// ---------- Developer UX ----------
301
302marketplace.get("/developer/apps-new", requireAuth, async (c) => {
303 const user = c.get("user")!;
304 return c.html(
305 <Layout title="New app — Marketplace" user={user}>
306 <h2>Register a new app</h2>
307 <form method="POST" action="/developer/apps-new" class="panel" style="padding:16px">
308 <div class="form-group">
309 <label>Name</label>
310 <input type="text" name="name" required style="width:100%" />
311 </div>
312 <div class="form-group">
313 <label>Description</label>
314 <textarea name="description" rows="3" style="width:100%" />
315 </div>
316 <div class="form-group">
317 <label>Homepage URL</label>
318 <input type="url" name="homepageUrl" style="width:100%" />
319 </div>
320 <div class="form-group">
321 <label>Webhook URL (optional)</label>
322 <input type="url" name="webhookUrl" style="width:100%" />
323 </div>
324 <div class="form-group">
325 <label>Permissions</label>
326 <div
327 style="display:grid;grid-template-columns:repeat(2,1fr);gap:4px;font-size:13px"
328 >
329 {KNOWN_PERMISSIONS.map((p) => (
330 <label>
331 <input type="checkbox" name="permissions" value={p} /> {p}
332 </label>
333 ))}
334 </div>
335 </div>
336 <div class="form-group">
337 <label>Events</label>
338 <div
339 style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;font-size:13px"
340 >
341 {KNOWN_EVENTS.map((e) => (
342 <label>
343 <input type="checkbox" name="events" value={e} /> {e}
344 </label>
345 ))}
346 </div>
347 </div>
348 <div class="form-group">
349 <label>
350 <input type="checkbox" name="isPublic" value="1" checked /> List in
351 public marketplace
352 </label>
353 </div>
354 <button type="submit" class="btn btn-primary">
355 Create app
356 </button>
357 </form>
358 </Layout>
359 );
360});
361
362marketplace.post("/developer/apps-new", requireAuth, async (c) => {
363 const user = c.get("user")!;
364 const body = await c.req.parseBody({ all: true });
365 const name = String(body.name || "").trim();
366 if (!name) return c.redirect("/developer/apps-new");
367 const rawPerms = body.permissions;
368 const perms = Array.isArray(rawPerms)
369 ? rawPerms.map(String)
370 : rawPerms
371 ? [String(rawPerms)]
372 : [];
373 const rawEvents = body.events;
374 const events = Array.isArray(rawEvents)
375 ? rawEvents.map(String)
376 : rawEvents
377 ? [String(rawEvents)]
378 : [];
379 const app = await createApp({
380 name,
381 description: String(body.description || ""),
382 homepageUrl: String(body.homepageUrl || "") || undefined,
383 webhookUrl: String(body.webhookUrl || "") || undefined,
384 creatorId: user.id,
385 permissions: perms,
386 defaultEvents: events,
387 isPublic: !!body.isPublic,
388 });
389 if (!app) return c.text("failed to create", 500);
390 await audit({
391 userId: user.id,
392 action: "marketplace.app.create",
393 targetType: "app",
394 targetId: app.id,
395 });
396 return c.redirect(`/developer/apps/${app.slug}/manage`);
397});
398
399marketplace.get("/developer/apps/:slug/manage", requireAuth, async (c) => {
400 const user = c.get("user")!;
401 const slug = c.req.param("slug");
402 const app = await getAppBySlug(slug);
403 if (!app) return c.notFound();
404 if (app.creatorId !== user.id) return c.text("forbidden", 403);
405 const [installs, events] = await Promise.all([
406 listInstallationsForApp(app.id),
407 listEventsForApp(app.id, 20),
408 ]);
409 return c.html(
410 <Layout title={`Manage ${app.name}`} user={user}>
411 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
412 <h2>{app.name} · Developer</h2>
413 <a href={`/marketplace/${app.slug}`} class="btn btn-sm">
414 Public page
415 </a>
416 </div>
417 <div class="panel" style="padding:12px;margin-bottom:20px">
418 <div style="font-size:12px;color:var(--text-muted)">Bot identity</div>
419 <div style="font-family:var(--font-mono)">{app.slug}[bot]</div>
420 {app.webhookSecret && (
421 <div style="margin-top:8px;font-size:12px">
422 <span style="color:var(--text-muted)">Webhook secret:</span>{" "}
423 <code>{app.webhookSecret}</code>
424 </div>
425 )}
426 </div>
427
428 <h3>Installations ({installs.length})</h3>
429 <div class="panel" style="margin-bottom:20px">
430 {installs.length === 0 ? (
431 <div class="panel-empty">No installs yet.</div>
432 ) : (
433 installs.map((i) => (
434 <div class="panel-item" style="justify-content:space-between">
435 <div>
436 {i.targetType}: <code>{i.targetId}</code>
437 </div>
438 <div style="font-size:12px;color:var(--text-muted)">
439 {parsePermissions(i.grantedPermissions).length} perms ·{" "}
440 {i.createdAt
441 ? new Date(i.createdAt).toLocaleDateString()
442 : ""}
443 </div>
444 </div>
445 ))
446 )}
447 </div>
448
449 <h3>Recent events</h3>
450 <div class="panel" style="margin-bottom:20px">
451 {events.length === 0 ? (
452 <div class="panel-empty">No events yet.</div>
453 ) : (
454 events.map((e) => (
455 <div class="panel-item" style="justify-content:space-between">
456 <span>{e.kind}</span>
457 <span style="font-size:12px;color:var(--text-muted)">
458 {e.createdAt
459 ? new Date(e.createdAt).toLocaleString()
460 : ""}
461 </span>
462 </div>
463 ))
464 )}
465 </div>
466
467 <h3>Installation tokens</h3>
468 <form
469 method="POST"
470 action={`/developer/apps/${app.slug}/tokens/new`}
471 class="panel"
472 style="padding:16px"
473 >
474 <p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
475 Issue a bearer token for an existing installation. Use this to test
476 bot API calls. Tokens are shown once and expire after 1 hour.
477 </p>
478 <select name="installationId">
479 {installs.map((i) => (
480 <option value={i.id}>
481 {i.targetType}:{i.targetId.slice(0, 8)}
482 </option>
483 ))}
484 </select>{" "}
485 <button type="submit" class="btn btn-sm" disabled={installs.length === 0}>
486 Issue token
487 </button>
488 </form>
489 </Layout>
490 );
491});
492
493marketplace.post(
494 "/developer/apps/:slug/tokens/new",
495 requireAuth,
496 async (c) => {
497 const user = c.get("user")!;
498 const slug = c.req.param("slug");
499 const app = await getAppBySlug(slug);
500 if (!app) return c.notFound();
501 if (app.creatorId !== user.id) return c.text("forbidden", 403);
502 const body = await c.req.parseBody();
503 const installationId = String(body.installationId || "");
504 if (!installationId) return c.redirect(`/developer/apps/${slug}/manage`);
505 // Validate the installation belongs to this app
506 const [inst] = await db
507 .select()
508 .from(appInstallations)
509 .where(eq(appInstallations.id, installationId))
510 .limit(1);
511 if (!inst || inst.appId !== app.id) return c.text("forbidden", 403);
512 const t = await issueInstallToken(installationId);
513 if (!t) return c.text("failed", 500);
514 await audit({
515 userId: user.id,
516 action: "marketplace.token.issue",
517 targetType: "app_installation",
518 targetId: installationId,
519 });
520 return c.html(
521 <Layout title="Token issued" user={user}>
522 <h2>Token issued</h2>
523 <div class="panel" style="padding:16px">
524 <p>Copy this token now — it won't be shown again.</p>
525 <pre
526 style="font-family:var(--font-mono);background:var(--bg-secondary);padding:12px;border-radius:6px;word-break:break-all"
527 >
528 {t.token}
529 </pre>
530 <p style="font-size:12px;color:var(--text-muted)">
531 Expires at {t.expiresAt.toISOString()}
532 </p>
533 <a href={`/developer/apps/${slug}/manage`} class="btn" style="margin-top:12px">
534 Back
535 </a>
536 </div>
537 </Layout>
538 );
539 }
540);
541
542export default marketplace;
Addedsrc/routes/merge-queue.tsx+491−0View fileUnifiedSplit
1/**
2 * Block E5 — Merge queue UI + actions.
3 *
4 * GET /:owner/:repo/queue — queue history + current state
5 * POST /:owner/:repo/pulls/:n/enqueue — enqueue a PR (requireAuth)
6 * POST /:owner/:repo/queue/:id/dequeue — remove entry (owner OR enqueuer)
7 * POST /:owner/:repo/queue/process-next — owner-only: run the head
8 *
9 * The "process-next" handler is v1 — it just re-runs gates against the base
10 * and, if green, merges by updating the base branch ref. A full background
11 * worker is future work; this keeps the feature usable without a daemon.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 mergeQueueEntries,
19 prComments,
20 pullRequests,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import {
29 enqueuePr,
30 dequeueEntry,
31 listQueueWithPrs,
32 markHeadRunning,
33 completeEntry,
34 peekHead,
35} from "../lib/merge-queue";
36import { runAllGateChecks } from "../lib/gate";
37import { resolveRef, getRepoPath } from "../git/repository";
38import { audit } from "../lib/notify";
39
40const queue = new Hono<AuthEnv>();
41queue.use("*", softAuth);
42
43async function loadRepo(ownerName: string, repoName: string) {
44 try {
45 const [row] = await db
46 .select({
47 id: repositories.id,
48 name: repositories.name,
49 ownerId: repositories.ownerId,
50 defaultBranch: repositories.defaultBranch,
51 starCount: repositories.starCount,
52 forkCount: repositories.forkCount,
53 })
54 .from(repositories)
55 .innerJoin(users, eq(repositories.ownerId, users.id))
56 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
57 .limit(1);
58 return row || null;
59 } catch {
60 return null;
61 }
62}
63
64function relTime(d: Date | string): string {
65 const t = typeof d === "string" ? new Date(d) : d;
66 const diffMs = Date.now() - t.getTime();
67 const mins = Math.floor(diffMs / 60000);
68 if (mins < 1) return "just now";
69 if (mins < 60) return `${mins}m ago`;
70 const hrs = Math.floor(mins / 60);
71 if (hrs < 24) return `${hrs}h ago`;
72 const days = Math.floor(hrs / 24);
73 if (days < 30) return `${days}d ago`;
74 return t.toLocaleDateString();
75}
76
77// ---------- Queue list ----------
78
79queue.get("/:owner/:repo/queue", async (c) => {
80 const user = c.get("user");
81 const { owner, repo } = c.req.param();
82 const repoRow = await loadRepo(owner, repo);
83 if (!repoRow) {
84 return c.html(
85 <Layout title="Not found" user={user}>
86 <div class="empty-state">
87 <h2>Repository not found</h2>
88 </div>
89 </Layout>,
90 404
91 );
92 }
93
94 const entries = await listQueueWithPrs(repoRow.id);
95 const byBranch = new Map<string, typeof entries>();
96 for (const e of entries) {
97 const arr = byBranch.get(e.baseBranch) || [];
98 arr.push(e);
99 byBranch.set(e.baseBranch, arr);
100 }
101
102 const isOwner = !!user && user.id === repoRow.ownerId;
103 const success = c.req.query("success");
104 const error = c.req.query("error");
105
106 const stateBadge = (s: string) => {
107 const style: Record<string, string> = {
108 queued: "background:#30363d;color:#c9d1d9",
109 running: "background:#1f6feb;color:white",
110 merged: "background:#8957e5;color:white",
111 failed: "background:#da3633;color:white",
112 dequeued: "background:#484f58;color:#c9d1d9",
113 };
114 return (
115 <span
116 style={`${style[s] || style.queued};padding:2px 8px;border-radius:3px;font-size:11px;text-transform:uppercase;font-weight:600`}
117 >
118 {s}
119 </span>
120 );
121 };
122
123 return c.html(
124 <Layout title={`Merge queue — ${owner}/${repo}`} user={user}>
125 <RepoHeader
126 owner={owner}
127 repo={repo}
128 starCount={repoRow.starCount}
129 forkCount={repoRow.forkCount}
130 currentUser={user?.username || null}
131 />
132 <RepoNav owner={owner} repo={repo} active="pulls" />
133
134 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
135 <h3>Merge queue</h3>
136 <a href={`/${owner}/${repo}/pulls`} class="btn btn-sm">
137 Back to PRs
138 </a>
139 </div>
140
141 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
142 Serialised merges: PRs queued here re-run gates against the latest base
143 before being merged. This prevents green-in-isolation / red-after-merge
144 races.
145 </p>
146
147 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149
150 {entries.length === 0 ? (
151 <div class="empty-state">
152 <p>Queue is empty. Enqueue a PR from the pull-request page.</p>
153 </div>
154 ) : (
155 Array.from(byBranch.entries()).map(([branch, items]) => {
156 const active = items.filter(
157 (i) => i.state === "queued" || i.state === "running"
158 );
159 return (
160 <div class="panel" style="margin-bottom:20px;overflow:hidden">
161 <div style="padding:12px 14px;background:var(--bg-tertiary);display:flex;justify-content:space-between;align-items:center">
162 <div style="font-weight:600">
163 Base: <code>{branch}</code>{" "}
164 <span style="font-size:12px;color:var(--text-muted);font-weight:400;margin-left:8px">
165 {active.length} active
166 </span>
167 </div>
168 {isOwner && active.length > 0 && (
169 <form
170 method="POST"
171 action={`/${owner}/${repo}/queue/process-next?base=${encodeURIComponent(branch)}`}
172 >
173 <button type="submit" class="btn btn-sm btn-primary">
174 Process next
175 </button>
176 </form>
177 )}
178 </div>
179 {items.map((it) => (
180 <div
181 class="panel-item"
182 style="justify-content:space-between;align-items:flex-start"
183 >
184 <div style="flex:1;min-width:0">
185 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
186 {stateBadge(it.state)}
187 {it.prNumber != null ? (
188 <a
189 href={`/${owner}/${repo}/pulls/${it.prNumber}`}
190 style="font-weight:600"
191 >
192 #{it.prNumber} {it.prTitle}
193 </a>
194 ) : (
195 <span style="color:var(--text-muted)">(PR gone)</span>
196 )}
197 </div>
198 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
199 pos {it.position} ·{" "}
200 {it.prHeadBranch ? <code>{it.prHeadBranch}</code> : ""} ·
201 enqueued {relTime(it.enqueuedAt)}
202 {it.startedAt
203 ? ` · started ${relTime(it.startedAt)}`
204 : ""}
205 {it.finishedAt
206 ? ` · finished ${relTime(it.finishedAt)}`
207 : ""}
208 </div>
209 {it.errorMessage && (
210 <div style="font-size:12px;color:var(--red);margin-top:4px">
211 {it.errorMessage}
212 </div>
213 )}
214 </div>
215 {(it.state === "queued" || it.state === "running") &&
216 user &&
217 (isOwner || user.id === it.enqueuedBy) && (
218 <form
219 method="POST"
220 action={`/${owner}/${repo}/queue/${it.id}/dequeue`}
221 onsubmit="return confirm('Remove from queue?')"
222 >
223 <button type="submit" class="btn btn-sm">
224 Remove
225 </button>
226 </form>
227 )}
228 </div>
229 ))}
230 </div>
231 );
232 })
233 )}
234 </Layout>
235 );
236});
237
238// ---------- Enqueue a PR ----------
239
240queue.post("/:owner/:repo/pulls/:number/enqueue", requireAuth, async (c) => {
241 const user = c.get("user")!;
242 const { owner, repo } = c.req.param();
243 const prNum = parseInt(c.req.param("number"), 10);
244 const repoRow = await loadRepo(owner, repo);
245 if (!repoRow) return c.notFound();
246
247 const [pr] = await db
248 .select()
249 .from(pullRequests)
250 .where(
251 and(
252 eq(pullRequests.repositoryId, repoRow.id),
253 eq(pullRequests.number, prNum)
254 )
255 )
256 .limit(1);
257 if (!pr || pr.state !== "open") {
258 return c.redirect(
259 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
260 "PR must be open to enqueue."
261 )}`
262 );
263 }
264 if (pr.isDraft) {
265 return c.redirect(
266 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
267 "Cannot enqueue a draft PR."
268 )}`
269 );
270 }
271
272 const result = await enqueuePr({
273 repositoryId: repoRow.id,
274 pullRequestId: pr.id,
275 baseBranch: pr.baseBranch,
276 enqueuedBy: user.id,
277 });
278 if (!result.ok) {
279 return c.redirect(
280 `/${owner}/${repo}/pulls/${prNum}?error=${encodeURIComponent(
281 result.reason || "Enqueue failed"
282 )}`
283 );
284 }
285
286 await audit({
287 userId: user.id,
288 repositoryId: repoRow.id,
289 action: "merge_queue.enqueue",
290 targetId: pr.id,
291 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
292 });
293
294 return c.redirect(
295 `/${owner}/${repo}/queue?success=${encodeURIComponent(
296 `PR #${pr.number} enqueued`
297 )}`
298 );
299});
300
301// ---------- Dequeue ----------
302
303queue.post("/:owner/:repo/queue/:id/dequeue", requireAuth, async (c) => {
304 const user = c.get("user")!;
305 const { owner, repo, id } = c.req.param();
306 const repoRow = await loadRepo(owner, repo);
307 if (!repoRow) return c.notFound();
308
309 const [entry] = await db
310 .select()
311 .from(mergeQueueEntries)
312 .where(
313 and(
314 eq(mergeQueueEntries.id, id),
315 eq(mergeQueueEntries.repositoryId, repoRow.id)
316 )
317 )
318 .limit(1);
319 if (!entry) {
320 return c.redirect(
321 `/${owner}/${repo}/queue?error=${encodeURIComponent("Entry not found")}`
322 );
323 }
324 const isOwner = user.id === repoRow.ownerId;
325 if (!isOwner && entry.enqueuedBy !== user.id) {
326 return c.redirect(
327 `/${owner}/${repo}/queue?error=${encodeURIComponent(
328 "Only the enqueuer or a repo owner can remove this entry."
329 )}`
330 );
331 }
332
333 const ok = await dequeueEntry(id);
334 if (!ok) {
335 return c.redirect(
336 `/${owner}/${repo}/queue?error=${encodeURIComponent("Could not remove entry")}`
337 );
338 }
339
340 await audit({
341 userId: user.id,
342 repositoryId: repoRow.id,
343 action: "merge_queue.dequeue",
344 targetId: entry.pullRequestId,
345 });
346
347 return c.redirect(
348 `/${owner}/${repo}/queue?success=${encodeURIComponent("Entry removed")}`
349 );
350});
351
352// ---------- Process next ----------
353
354queue.post("/:owner/:repo/queue/process-next", requireAuth, async (c) => {
355 const user = c.get("user")!;
356 const { owner, repo } = c.req.param();
357 const base = c.req.query("base");
358 const repoRow = await loadRepo(owner, repo);
359 if (!repoRow) return c.notFound();
360 if (repoRow.ownerId !== user.id) {
361 return c.redirect(
362 `/${owner}/${repo}/queue?error=${encodeURIComponent(
363 "Only repo owners can process the queue."
364 )}`
365 );
366 }
367
368 const targetBase = base || repoRow.defaultBranch || "main";
369 const head = await peekHead(repoRow.id, targetBase);
370 if (!head) {
371 return c.redirect(
372 `/${owner}/${repo}/queue?error=${encodeURIComponent(
373 `No queued entries for base ${targetBase}`
374 )}`
375 );
376 }
377
378 const started = await markHeadRunning(repoRow.id, targetBase);
379 if (!started) {
380 return c.redirect(
381 `/${owner}/${repo}/queue?error=${encodeURIComponent(
382 "Could not transition head to running"
383 )}`
384 );
385 }
386
387 // Re-run gates against latest base.
388 const [pr] = await db
389 .select()
390 .from(pullRequests)
391 .where(eq(pullRequests.id, started.pullRequestId))
392 .limit(1);
393 if (!pr) {
394 await completeEntry(started.id, "failed", "Pull request no longer exists.");
395 return c.redirect(
396 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR vanished")}`
397 );
398 }
399 if (pr.state !== "open") {
400 await completeEntry(started.id, "failed", "Pull request is no longer open.");
401 return c.redirect(
402 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR is no longer open")}`
403 );
404 }
405
406 const headSha = await resolveRef(owner, repo, pr.headBranch);
407 if (!headSha) {
408 await completeEntry(started.id, "failed", "Head branch not found.");
409 return c.redirect(
410 `/${owner}/${repo}/queue?error=${encodeURIComponent("Head branch not found")}`
411 );
412 }
413
414 const gateResult = await runAllGateChecks(
415 owner,
416 repo,
417 pr.baseBranch,
418 pr.headBranch,
419 headSha,
420 true
421 );
422 const hardFailures = gateResult.checks.filter(
423 (check) => !check.passed && check.name !== "Merge check"
424 );
425 if (hardFailures.length > 0) {
426 const msg = hardFailures
427 .map((f) => `${f.name}: ${f.details}`)
428 .join("; ");
429 await completeEntry(started.id, "failed", msg);
430 try {
431 await db.insert(prComments).values({
432 pullRequestId: pr.id,
433 authorId: user.id,
434 body: `**Merge queue:** gates failed on latest base — ${msg}`,
435 isAiReview: false,
436 });
437 } catch {}
438 return c.redirect(
439 `/${owner}/${repo}/queue?error=${encodeURIComponent(msg)}`
440 );
441 }
442
443 // Gates passed — merge by updating base ref to head.
444 const repoDir = getRepoPath(owner, repo);
445 const proc = Bun.spawn(
446 [
447 "git",
448 "update-ref",
449 `refs/heads/${pr.baseBranch}`,
450 `refs/heads/${pr.headBranch}`,
451 ],
452 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
453 );
454 const exit = await proc.exited;
455 if (exit !== 0) {
456 await completeEntry(started.id, "failed", "update-ref failed");
457 return c.redirect(
458 `/${owner}/${repo}/queue?error=${encodeURIComponent(
459 "Merge failed — unable to update base ref"
460 )}`
461 );
462 }
463
464 await db
465 .update(pullRequests)
466 .set({
467 state: "merged",
468 mergedAt: new Date(),
469 mergedBy: user.id,
470 updatedAt: new Date(),
471 })
472 .where(eq(pullRequests.id, pr.id));
473
474 await completeEntry(started.id, "merged");
475
476 await audit({
477 userId: user.id,
478 repositoryId: repoRow.id,
479 action: "merge_queue.merged",
480 targetId: pr.id,
481 metadata: { prNumber: pr.number, baseBranch: pr.baseBranch },
482 });
483
484 return c.redirect(
485 `/${owner}/${repo}/queue?success=${encodeURIComponent(
486 `PR #${pr.number} merged via queue`
487 )}`
488 );
489});
490
491export default queue;
Addedsrc/routes/mirrors.tsx+388−0View fileUnifiedSplit
1/**
2 * Block I9 — Repository mirroring.
3 *
4 * GET /:owner/:repo/settings/mirror — config form + recent runs
5 * POST /:owner/:repo/settings/mirror — save upstream URL + interval
6 * POST /:owner/:repo/settings/mirror/delete — remove mirror config
7 * POST /:owner/:repo/settings/mirror/sync — run one sync now (owner-only)
8 * POST /admin/mirrors/sync-all — site admin, run all due mirrors
9 */
10
11import { Hono } from "hono";
12import { and, eq } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader } from "../views/components";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { isSiteAdmin } from "../lib/admin";
20import { audit } from "../lib/notify";
21import {
22 deleteMirror,
23 getMirrorForRepo,
24 listRecentRuns,
25 runMirrorSync,
26 safeUrlForLog,
27 syncAllDue,
28 upsertMirror,
29 validateUpstreamUrl,
30} from "../lib/mirrors";
31
32const mirrors = new Hono<AuthEnv>();
33mirrors.use("*", softAuth);
34
35async function ownerGate(c: any): Promise<
36 | Response
37 | {
38 user: any;
39 ownerName: string;
40 repoName: string;
41 repo: typeof repositories.$inferSelect;
42 }
43> {
44 const user = c.get("user");
45 if (!user) return c.redirect("/login");
46 const { owner: ownerName, repo: repoName } = c.req.param();
47 const [owner] = await db
48 .select()
49 .from(users)
50 .where(eq(users.username, ownerName))
51 .limit(1);
52 if (!owner || owner.id !== user.id) {
53 return c.html(
54 <Layout title="Forbidden" user={user}>
55 <div class="empty-state">
56 <h2>403</h2>
57 <p>Only the repository owner can configure mirroring.</p>
58 </div>
59 </Layout>,
60 403
61 );
62 }
63 const [repo] = await db
64 .select()
65 .from(repositories)
66 .where(
67 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
68 )
69 .limit(1);
70 if (!repo) return c.notFound();
71 return { user, ownerName, repoName, repo };
72}
73
74// ---------- Config page ----------
75
76mirrors.get("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
77 const g = await ownerGate(c);
78 if (g instanceof Response) return g;
79 const { user, ownerName, repoName, repo } = g;
80
81 const mirror = await getMirrorForRepo(repo.id);
82 const runs = mirror ? await listRecentRuns(mirror.id, 20) : [];
83
84 const success = c.req.query("success");
85 const error = c.req.query("error");
86
87 return c.html(
88 <Layout title={`Mirror — ${ownerName}/${repoName}`} user={user}>
89 <RepoHeader owner={ownerName} repo={repoName} />
90 <div class="settings-container" style="max-width:720px">
91 <h2>Mirror settings</h2>
92 <p style="color:var(--text-muted)">
93 Keep this repository in sync with an upstream URL by periodically
94 running <code>git fetch --prune</code>. Only <code>https://</code>,{" "}
95 <code>http://</code>, and <code>git://</code> URLs are accepted —
96 SSH and local paths are not supported.
97 </p>
98
99 {success && (
100 <div class="auth-success">{decodeURIComponent(success)}</div>
101 )}
102 {error && (
103 <div class="auth-error">{decodeURIComponent(error)}</div>
104 )}
105
106 <form
107 method="POST"
108 action={`/${ownerName}/${repoName}/settings/mirror`}
109 class="panel"
110 style="padding:16px;margin:16px 0"
111 >
112 <div class="form-group">
113 <label for="upstream_url">Upstream URL</label>
114 <input
115 type="text"
116 id="upstream_url"
117 name="upstream_url"
118 value={mirror?.upstreamUrl || ""}
119 placeholder="https://github.com/torvalds/linux.git"
120 required
121 style="font-family:var(--font-mono)"
122 />
123 </div>
124 <div class="form-group">
125 <label for="interval_minutes">Sync interval (minutes)</label>
126 <input
127 type="number"
128 id="interval_minutes"
129 name="interval_minutes"
130 value={mirror?.intervalMinutes ?? 1440}
131 min="5"
132 max="43200"
133 style="width:160px"
134 />
135 </div>
136 <label
137 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
138 >
139 <input
140 type="checkbox"
141 name="is_enabled"
142 value="1"
143 checked={mirror ? mirror.isEnabled : true}
144 />
145 <span>Enabled</span>
146 </label>
147 <button type="submit" class="btn btn-primary">
148 {mirror ? "Update mirror" : "Enable mirror"}
149 </button>
150 </form>
151
152 {mirror && (
153 <>
154 <div style="display:flex;gap:8px;margin:12px 0">
155 <form
156 method="POST"
157 action={`/${ownerName}/${repoName}/settings/mirror/sync`}
158 >
159 <button type="submit" class="btn">
160 Sync now
161 </button>
162 </form>
163 <form
164 method="POST"
165 action={`/${ownerName}/${repoName}/settings/mirror/delete`}
166 onsubmit="return confirm('Remove mirror configuration?')"
167 >
168 <button type="submit" class="btn btn-danger">
169 Remove mirror
170 </button>
171 </form>
172 </div>
173
174 <h3 style="margin-top:20px">Last run</h3>
175 <div class="panel" style="padding:12px">
176 {mirror.lastSyncedAt ? (
177 <div>
178 <div
179 style="font-size:12px;color:var(--text-muted);text-transform:uppercase"
180 >
181 {mirror.lastStatus === "ok" ? "Success" : "Error"} —{" "}
182 {new Date(
183 mirror.lastSyncedAt as unknown as string
184 ).toLocaleString()}
185 </div>
186 {mirror.lastError && (
187 <pre
188 style="margin-top:8px;padding:8px;background:var(--bg-subtle);border-radius:4px;font-size:12px;overflow-x:auto;color:var(--red)"
189 >
190 {mirror.lastError}
191 </pre>
192 )}
193 </div>
194 ) : (
195 <div style="color:var(--text-muted)">Never synced.</div>
196 )}
197 </div>
198
199 <h3 style="margin-top:20px">Recent runs</h3>
200 <div class="panel">
201 {runs.length === 0 ? (
202 <div class="panel-empty">No runs yet.</div>
203 ) : (
204 runs.map((r) => (
205 <div
206 class="panel-item"
207 style="justify-content:space-between;flex-wrap:wrap;gap:6px"
208 >
209 <div>
210 <span
211 style={`font-size:11px;text-transform:uppercase;margin-right:8px;color:${
212 r.status === "ok"
213 ? "var(--green)"
214 : r.status === "error"
215 ? "var(--red)"
216 : "var(--text-muted)"
217 }`}
218 >
219 {r.status}
220 </span>
221 <span
222 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
223 >
224 {new Date(
225 r.startedAt as unknown as string
226 ).toLocaleString()}
227 </span>
228 </div>
229 {r.message && (
230 <span
231 style="font-size:12px;color:var(--text-muted);max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
232 title={r.message}
233 >
234 {r.message.split("\n")[0]}
235 </span>
236 )}
237 </div>
238 ))
239 )}
240 </div>
241 <p
242 style="font-size:12px;color:var(--text-muted);margin-top:12px"
243 >
244 Upstream (logged, credentials redacted):{" "}
245 <code>{safeUrlForLog(mirror.upstreamUrl)}</code>
246 </p>
247 </>
248 )}
249 </div>
250 </Layout>
251 );
252});
253
254// ---------- Save config ----------
255
256mirrors.post("/:owner/:repo/settings/mirror", requireAuth, async (c) => {
257 const g = await ownerGate(c);
258 if (g instanceof Response) return g;
259 const { user, ownerName, repoName, repo } = g;
260 const body = await c.req.parseBody();
261 const upstreamUrl = String(body.upstream_url || "").trim();
262 const intervalRaw = Number(body.interval_minutes || 1440);
263 const interval = Math.max(5, Math.min(43200, Math.floor(intervalRaw || 1440)));
264 const isEnabled = String(body.is_enabled || "") === "1";
265
266 const v = validateUpstreamUrl(upstreamUrl);
267 if (!v.ok) {
268 return c.redirect(
269 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
270 v.error || "Invalid URL"
271 )}`
272 );
273 }
274
275 const result = await upsertMirror({
276 repositoryId: repo.id,
277 upstreamUrl,
278 intervalMinutes: interval,
279 isEnabled,
280 });
281 if (!result.ok) {
282 return c.redirect(
283 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
284 result.error
285 )}`
286 );
287 }
288
289 await audit({
290 userId: user.id,
291 repositoryId: repo.id,
292 action: "mirror.configure",
293 metadata: {
294 upstream: safeUrlForLog(upstreamUrl),
295 intervalMinutes: interval,
296 isEnabled,
297 },
298 });
299
300 return c.redirect(
301 `/${ownerName}/${repoName}/settings/mirror?success=${encodeURIComponent(
302 "Mirror configuration saved."
303 )}`
304 );
305});
306
307// ---------- Delete ----------
308
309mirrors.post("/:owner/:repo/settings/mirror/delete", requireAuth, async (c) => {
310 const g = await ownerGate(c);
311 if (g instanceof Response) return g;
312 const { user, ownerName, repoName, repo } = g;
313
314 await deleteMirror(repo.id);
315 await audit({
316 userId: user.id,
317 repositoryId: repo.id,
318 action: "mirror.delete",
319 });
320
321 return c.redirect(
322 `/${ownerName}/${repoName}/settings/mirror?success=${encodeURIComponent(
323 "Mirror removed."
324 )}`
325 );
326});
327
328// ---------- Sync now ----------
329
330mirrors.post("/:owner/:repo/settings/mirror/sync", requireAuth, async (c) => {
331 const g = await ownerGate(c);
332 if (g instanceof Response) return g;
333 const { user, ownerName, repoName, repo } = g;
334 const mirror = await getMirrorForRepo(repo.id);
335 if (!mirror) {
336 return c.redirect(
337 `/${ownerName}/${repoName}/settings/mirror?error=${encodeURIComponent(
338 "No mirror configured"
339 )}`
340 );
341 }
342
343 const result = await runMirrorSync(mirror.id);
344 await audit({
345 userId: user.id,
346 repositoryId: repo.id,
347 action: "mirror.sync",
348 metadata: { ok: result.ok, exitCode: result.exitCode },
349 });
350 const msg = result.ok
351 ? "Mirror sync completed."
352 : `Sync failed: ${result.message.split("\n")[0]}`;
353 return c.redirect(
354 `/${ownerName}/${repoName}/settings/mirror?${
355 result.ok ? "success" : "error"
356 }=${encodeURIComponent(msg)}`
357 );
358});
359
360// ---------- Admin: sync all due ----------
361
362mirrors.post("/admin/mirrors/sync-all", requireAuth, async (c) => {
363 const user = c.get("user")!;
364 if (!(await isSiteAdmin(user.id))) {
365 return c.html(
366 <Layout title="Forbidden" user={user}>
367 <div class="empty-state">
368 <h2>403</h2>
369 <p>Site admin only.</p>
370 </div>
371 </Layout>,
372 403
373 );
374 }
375 const summary = await syncAllDue();
376 await audit({
377 userId: user.id,
378 action: "admin.mirrors.sync-all",
379 metadata: summary,
380 });
381 return c.redirect(
382 `/admin?message=${encodeURIComponent(
383 `Mirror sync: ${summary.total} due, ${summary.ok} ok, ${summary.failed} failed.`
384 )}`
385 );
386});
387
388export default mirrors;
Addedsrc/routes/oauth.tsx+780−0View fileUnifiedSplit
1/**
2 * OAuth 2.0 provider endpoints (Block B6).
3 *
4 * GET /oauth/authorize consent screen (authed)
5 * POST /oauth/authorize/decision approve/deny → redirect with code (authed)
6 * POST /oauth/token code or refresh → access+refresh tokens
7 * POST /oauth/revoke revoke access or refresh token
8 * GET /settings/authorizations list apps the user has granted (authed)
9 * POST /settings/authorizations/:appId/revoke user-initiated revoke
10 *
11 * Developer-facing app management lives in `src/routes/developer-apps.tsx`.
12 */
13
14import { Hono } from "hono";
15import { and, eq, gt, isNull } from "drizzle-orm";
16import { db } from "../db";
17import {
18 oauthApps,
19 oauthAuthorizations,
20 oauthAccessTokens,
21 users,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import {
27 generateAuthCode,
28 generateAccessToken,
29 generateRefreshToken,
30 sha256Hex,
31 verifyPkce,
32 parseScopes,
33 parseRedirectUris,
34 redirectUriAllowed,
35 timingSafeEqual,
36 ACCESS_TOKEN_TTL_MS,
37 REFRESH_TOKEN_TTL_MS,
38 AUTH_CODE_TTL_MS,
39 SUPPORTED_SCOPES,
40} from "../lib/oauth";
41import { audit } from "../lib/notify";
42
43const oauth = new Hono<AuthEnv>();
44
45oauth.use("/oauth/authorize", requireAuth);
46oauth.use("/oauth/authorize/decision", requireAuth);
47oauth.use("/settings/authorizations", requireAuth);
48oauth.use("/settings/authorizations/*", requireAuth);
49
50// --- helpers ----------------------------------------------------------------
51
52function appendQuery(url: string, params: Record<string, string | undefined>) {
53 const sep = url.includes("?") ? "&" : "?";
54 const parts: string[] = [];
55 for (const [k, v] of Object.entries(params)) {
56 if (v === undefined || v === null) continue;
57 parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
58 }
59 if (parts.length === 0) return url;
60 return url + sep + parts.join("&");
61}
62
63function errorPage(title: string, message: string) {
64 return (
65 <Layout title={title}>
66 <div class="empty-state">
67 <h2>{title}</h2>
68 <p>{message}</p>
69 <a href="/" style="margin-top: 12px; display: inline-block">
70 Go home
71 </a>
72 </div>
73 </Layout>
74 );
75}
76
77type OauthApp = typeof oauthApps.$inferSelect;
78
79async function loadAppByClientId(clientId: string): Promise<OauthApp | null> {
80 try {
81 const [row] = await db
82 .select()
83 .from(oauthApps)
84 .where(eq(oauthApps.clientId, clientId))
85 .limit(1);
86 return row || null;
87 } catch (err) {
88 console.error("[oauth] loadApp:", err);
89 return null;
90 }
91}
92
93/**
94 * Extracts client_id + client_secret from either the request body or an
95 * `Authorization: Basic` header. Returns `null` if neither is present.
96 */
97function extractClientCreds(
98 authHeader: string | undefined,
99 body: Record<string, unknown>
100): { clientId?: string; clientSecret?: string } {
101 if (authHeader && authHeader.toLowerCase().startsWith("basic ")) {
102 try {
103 const decoded = atob(authHeader.slice(6).trim());
104 const idx = decoded.indexOf(":");
105 if (idx > 0) {
106 return {
107 clientId: decoded.slice(0, idx),
108 clientSecret: decoded.slice(idx + 1),
109 };
110 }
111 } catch {
112 /* fall through */
113 }
114 }
115 const cid = body.client_id ? String(body.client_id) : undefined;
116 const csec = body.client_secret ? String(body.client_secret) : undefined;
117 return { clientId: cid, clientSecret: csec };
118}
119
120async function authenticateClient(
121 app: OauthApp,
122 providedSecret: string | undefined
123): Promise<boolean> {
124 if (!app.confidential) return true; // public clients auth via PKCE
125 if (!providedSecret) return false;
126 const hash = await sha256Hex(providedSecret);
127 return timingSafeEqual(hash, app.clientSecretHash);
128}
129
130// --- GET /oauth/authorize ---------------------------------------------------
131
132oauth.get("/oauth/authorize", async (c) => {
133 const user = c.get("user")!;
134 const q = c.req.query();
135 const clientId = q.client_id || "";
136 const redirectUri = q.redirect_uri || "";
137 const responseType = q.response_type || "";
138 const scopeParam = q.scope || "";
139 const state = q.state || "";
140 const codeChallenge = q.code_challenge || "";
141 const codeChallengeMethod = q.code_challenge_method || "";
142
143 if (!clientId) {
144 return c.html(errorPage("OAuth error", "Missing client_id."), 400);
145 }
146 const app = await loadAppByClientId(clientId);
147 if (!app) {
148 return c.html(errorPage("OAuth error", "Unknown client."), 400);
149 }
150 if (app.revokedAt) {
151 return c.html(errorPage("OAuth error", "This application has been revoked."), 400);
152 }
153
154 const registered = parseRedirectUris(app.redirectUris);
155 if (!redirectUriAllowed(redirectUri, registered)) {
156 return c.html(
157 errorPage(
158 "OAuth error",
159 "redirect_uri does not match any registered callback for this app."
160 ),
161 400
162 );
163 }
164
165 // Beyond this point errors redirect back to redirect_uri with ?error=...
166 if (responseType !== "code") {
167 return c.redirect(
168 appendQuery(redirectUri, {
169 error: "unsupported_response_type",
170 error_description: "response_type must be 'code'",
171 state: state || undefined,
172 })
173 );
174 }
175 if (!app.confidential && !codeChallenge) {
176 return c.redirect(
177 appendQuery(redirectUri, {
178 error: "invalid_request",
179 error_description: "PKCE code_challenge is required for public clients",
180 state: state || undefined,
181 })
182 );
183 }
184
185 const scopes = parseScopes(scopeParam);
186
187 // Look up the app owner for the consent screen.
188 let ownerName = "unknown";
189 try {
190 const [ownerRow] = await db
191 .select({ username: users.username })
192 .from(users)
193 .where(eq(users.id, app.ownerId))
194 .limit(1);
195 if (ownerRow) ownerName = ownerRow.username;
196 } catch {
197 /* non-fatal */
198 }
199
200 return c.html(
201 <Layout title="Authorize application" user={user}>
202 <div class="auth-container">
203 <h2>Authorize {app.name}</h2>
204 <p style="color: var(--text-muted); font-size: 13px">
205 <strong>{app.name}</strong> by <code>{ownerName}</code> wants
206 access to your gluecron account as <strong>{user.username}</strong>.
207 </p>
208 {app.description && (
209 <p style="color: var(--text-muted); font-size: 13px">
210 {app.description}
211 </p>
212 )}
213 <div
214 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin: 16px 0; background: var(--bg-secondary)"
215 >
216 <strong>Requested scopes</strong>
217 {scopes.length === 0 ? (
218 <p style="color: var(--text-muted); font-size: 12px; margin: 8px 0 0">
219 No scopes — this app will only be able to identify you.
220 </p>
221 ) : (
222 <ul style="margin: 8px 0 0 16px; font-size: 13px">
223 {scopes.map((s) => (
224 <li>
225 <code>{s}</code>
226 </li>
227 ))}
228 </ul>
229 )}
230 </div>
231 <p style="color: var(--text-muted); font-size: 12px">
232 You can revoke access at any time from{" "}
233 <a href="/settings/authorizations">Authorized applications</a>.
234 </p>
235 <form method="POST" action="/oauth/authorize/decision">
236 <input type="hidden" name="client_id" value={clientId} />
237 <input type="hidden" name="redirect_uri" value={redirectUri} />
238 <input type="hidden" name="response_type" value={responseType} />
239 <input type="hidden" name="scope" value={scopes.join(" ")} />
240 <input type="hidden" name="state" value={state} />
241 <input type="hidden" name="code_challenge" value={codeChallenge} />
242 <input
243 type="hidden"
244 name="code_challenge_method"
245 value={codeChallengeMethod}
246 />
247 <div style="display: flex; gap: 8px">
248 <button type="submit" name="decision" value="approve" class="btn btn-primary">
249 Authorize
250 </button>
251 <button type="submit" name="decision" value="deny" class="btn">
252 Cancel
253 </button>
254 </div>
255 </form>
256 </div>
257 </Layout>
258 );
259});
260
261// --- POST /oauth/authorize/decision -----------------------------------------
262
263oauth.post("/oauth/authorize/decision", async (c) => {
264 const user = c.get("user")!;
265 const body = await c.req.parseBody();
266 const clientId = String(body.client_id || "");
267 const redirectUri = String(body.redirect_uri || "");
268 const scopeParam = String(body.scope || "");
269 const state = String(body.state || "");
270 const decision = String(body.decision || "");
271 const codeChallenge = String(body.code_challenge || "");
272 const codeChallengeMethod = String(body.code_challenge_method || "");
273
274 const app = await loadAppByClientId(clientId);
275 if (!app || app.revokedAt) {
276 return c.html(errorPage("OAuth error", "Unknown or revoked client."), 400);
277 }
278 const registered = parseRedirectUris(app.redirectUris);
279 if (!redirectUriAllowed(redirectUri, registered)) {
280 return c.html(errorPage("OAuth error", "Invalid redirect_uri."), 400);
281 }
282
283 if (decision !== "approve") {
284 return c.redirect(
285 appendQuery(redirectUri, {
286 error: "access_denied",
287 error_description: "User denied the request",
288 state: state || undefined,
289 })
290 );
291 }
292
293 const scopes = parseScopes(scopeParam);
294 const code = generateAuthCode();
295 const codeHash = await sha256Hex(code);
296
297 try {
298 await db.insert(oauthAuthorizations).values({
299 appId: app.id,
300 userId: user.id,
301 codeHash,
302 redirectUri,
303 scopes: scopes.join(" "),
304 codeChallenge: codeChallenge || null,
305 codeChallengeMethod: codeChallengeMethod || null,
306 expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS),
307 });
308 await audit({
309 userId: user.id,
310 action: "oauth.authorize",
311 targetType: "oauth_app",
312 targetId: app.id,
313 metadata: { scopes: scopes.join(" ") },
314 });
315 return c.redirect(
316 appendQuery(redirectUri, {
317 code,
318 state: state || undefined,
319 })
320 );
321 } catch (err) {
322 console.error("[oauth] authorize/decision:", err);
323 return c.redirect(
324 appendQuery(redirectUri, {
325 error: "server_error",
326 error_description: "Service unavailable",
327 state: state || undefined,
328 })
329 );
330 }
331});
332
333// --- POST /oauth/token ------------------------------------------------------
334
335oauth.post("/oauth/token", async (c) => {
336 // Accept either form-encoded or JSON bodies.
337 let body: Record<string, unknown> = {};
338 const contentType = (c.req.header("content-type") || "").toLowerCase();
339 try {
340 if (contentType.includes("application/json")) {
341 body = (await c.req.json()) as Record<string, unknown>;
342 } else {
343 body = (await c.req.parseBody()) as Record<string, unknown>;
344 }
345 } catch {
346 return c.json(
347 { error: "invalid_request", error_description: "Malformed body" },
348 400
349 );
350 }
351
352 const grantType = body.grant_type ? String(body.grant_type) : "";
353 const authHeader = c.req.header("authorization");
354 const creds = extractClientCreds(authHeader, body);
355
356 if (!creds.clientId) {
357 return c.json(
358 { error: "invalid_client", error_description: "Missing client_id" },
359 401
360 );
361 }
362 const app = await loadAppByClientId(creds.clientId);
363 if (!app || app.revokedAt) {
364 return c.json(
365 { error: "invalid_client", error_description: "Unknown client" },
366 401
367 );
368 }
369 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
370 if (!clientAuthOk) {
371 return c.json(
372 { error: "invalid_client", error_description: "Client authentication failed" },
373 401
374 );
375 }
376
377 try {
378 if (grantType === "authorization_code") {
379 const code = body.code ? String(body.code) : "";
380 const redirectUri = body.redirect_uri ? String(body.redirect_uri) : "";
381 const codeVerifier = body.code_verifier ? String(body.code_verifier) : "";
382 if (!code || !redirectUri) {
383 return c.json(
384 { error: "invalid_request", error_description: "code and redirect_uri required" },
385 400
386 );
387 }
388 const codeHash = await sha256Hex(code);
389 const [authRow] = await db
390 .select()
391 .from(oauthAuthorizations)
392 .where(eq(oauthAuthorizations.codeHash, codeHash))
393 .limit(1);
394 if (!authRow) {
395 return c.json({ error: "invalid_grant", error_description: "Unknown code" }, 400);
396 }
397 if (authRow.usedAt) {
398 return c.json(
399 { error: "invalid_grant", error_description: "Code already used" },
400 400
401 );
402 }
403 if (new Date(authRow.expiresAt) < new Date()) {
404 return c.json({ error: "invalid_grant", error_description: "Code expired" }, 400);
405 }
406 if (authRow.appId !== app.id) {
407 return c.json(
408 { error: "invalid_grant", error_description: "Code does not belong to client" },
409 400
410 );
411 }
412 if (!timingSafeEqual(authRow.redirectUri, redirectUri)) {
413 return c.json(
414 { error: "invalid_grant", error_description: "redirect_uri mismatch" },
415 400
416 );
417 }
418 if (authRow.codeChallenge) {
419 const ok = await verifyPkce({
420 challenge: authRow.codeChallenge,
421 method: authRow.codeChallengeMethod,
422 verifier: codeVerifier,
423 });
424 if (!ok) {
425 return c.json(
426 { error: "invalid_grant", error_description: "PKCE verification failed" },
427 400
428 );
429 }
430 }
431
432 // Single-use: mark used immediately.
433 await db
434 .update(oauthAuthorizations)
435 .set({ usedAt: new Date() })
436 .where(eq(oauthAuthorizations.id, authRow.id));
437
438 const accessToken = generateAccessToken();
439 const refreshToken = generateRefreshToken();
440 const accessHash = await sha256Hex(accessToken);
441 const refreshHash = await sha256Hex(refreshToken);
442
443 await db.insert(oauthAccessTokens).values({
444 appId: app.id,
445 userId: authRow.userId,
446 accessTokenHash: accessHash,
447 refreshTokenHash: refreshHash,
448 scopes: authRow.scopes,
449 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
450 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
451 });
452 await audit({
453 userId: authRow.userId,
454 action: "oauth.token.issue",
455 targetType: "oauth_app",
456 targetId: app.id,
457 });
458 return c.json({
459 access_token: accessToken,
460 token_type: "bearer",
461 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
462 refresh_token: refreshToken,
463 scope: authRow.scopes,
464 });
465 }
466
467 if (grantType === "refresh_token") {
468 const refreshToken = body.refresh_token ? String(body.refresh_token) : "";
469 if (!refreshToken) {
470 return c.json(
471 { error: "invalid_request", error_description: "refresh_token required" },
472 400
473 );
474 }
475 const refreshHash = await sha256Hex(refreshToken);
476 const [tokenRow] = await db
477 .select()
478 .from(oauthAccessTokens)
479 .where(eq(oauthAccessTokens.refreshTokenHash, refreshHash))
480 .limit(1);
481 if (!tokenRow || tokenRow.revokedAt) {
482 return c.json(
483 { error: "invalid_grant", error_description: "Unknown refresh_token" },
484 400
485 );
486 }
487 if (tokenRow.appId !== app.id) {
488 return c.json(
489 { error: "invalid_grant", error_description: "Token does not belong to client" },
490 400
491 );
492 }
493 if (
494 tokenRow.refreshExpiresAt &&
495 new Date(tokenRow.refreshExpiresAt) < new Date()
496 ) {
497 return c.json(
498 { error: "invalid_grant", error_description: "refresh_token expired" },
499 400
500 );
501 }
502
503 // Narrow scopes if the client explicitly requested a subset.
504 let newScopes = tokenRow.scopes;
505 if (body.scope) {
506 const requested = parseScopes(String(body.scope));
507 const originalSet = new Set(
508 tokenRow.scopes.split(/\s+/).filter(Boolean)
509 );
510 const narrowed = requested.filter((s) => originalSet.has(s));
511 newScopes = narrowed.join(" ");
512 }
513
514 // Rotate: revoke old, issue new.
515 await db
516 .update(oauthAccessTokens)
517 .set({ revokedAt: new Date() })
518 .where(eq(oauthAccessTokens.id, tokenRow.id));
519
520 const accessToken = generateAccessToken();
521 const newRefresh = generateRefreshToken();
522 await db.insert(oauthAccessTokens).values({
523 appId: app.id,
524 userId: tokenRow.userId,
525 accessTokenHash: await sha256Hex(accessToken),
526 refreshTokenHash: await sha256Hex(newRefresh),
527 scopes: newScopes,
528 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
529 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
530 });
531 await audit({
532 userId: tokenRow.userId,
533 action: "oauth.token.refresh",
534 targetType: "oauth_app",
535 targetId: app.id,
536 });
537 return c.json({
538 access_token: accessToken,
539 token_type: "bearer",
540 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
541 refresh_token: newRefresh,
542 scope: newScopes,
543 });
544 }
545
546 return c.json(
547 {
548 error: "unsupported_grant_type",
549 error_description: `grant_type '${grantType}' not supported`,
550 },
551 400
552 );
553 } catch (err) {
554 console.error("[oauth] token:", err);
555 return c.json(
556 { error: "server_error", error_description: "Service unavailable" },
557 503
558 );
559 }
560});
561
562// --- POST /oauth/revoke (RFC 7009) ------------------------------------------
563
564oauth.post("/oauth/revoke", async (c) => {
565 let body: Record<string, unknown> = {};
566 const contentType = (c.req.header("content-type") || "").toLowerCase();
567 try {
568 if (contentType.includes("application/json")) {
569 body = (await c.req.json()) as Record<string, unknown>;
570 } else {
571 body = (await c.req.parseBody()) as Record<string, unknown>;
572 }
573 } catch {
574 // Per RFC 7009 we still respond 200 to unknown tokens — but a malformed
575 // body indicates a misbehaving client, so 400 is acceptable here.
576 return c.json({ error: "invalid_request" }, 400);
577 }
578
579 const token = body.token ? String(body.token) : "";
580 const authHeader = c.req.header("authorization");
581 const creds = extractClientCreds(authHeader, body);
582
583 if (!creds.clientId) {
584 return c.json({ error: "invalid_client" }, 401);
585 }
586 const app = await loadAppByClientId(creds.clientId);
587 if (!app) {
588 return c.json({ error: "invalid_client" }, 401);
589 }
590 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
591 if (!clientAuthOk) {
592 return c.json({ error: "invalid_client" }, 401);
593 }
594
595 if (!token) {
596 // RFC 7009: server responds as if successful.
597 return c.body(null, 200);
598 }
599
600 try {
601 const hash = await sha256Hex(token);
602 // Try access token first, then refresh token.
603 const [asAccess] = await db
604 .select()
605 .from(oauthAccessTokens)
606 .where(eq(oauthAccessTokens.accessTokenHash, hash))
607 .limit(1);
608 const [asRefresh] = asAccess
609 ? []
610 : await db
611 .select()
612 .from(oauthAccessTokens)
613 .where(eq(oauthAccessTokens.refreshTokenHash, hash))
614 .limit(1);
615 const row = asAccess || asRefresh;
616 if (row && row.appId === app.id && !row.revokedAt) {
617 await db
618 .update(oauthAccessTokens)
619 .set({ revokedAt: new Date() })
620 .where(eq(oauthAccessTokens.id, row.id));
621 await audit({
622 userId: row.userId,
623 action: "oauth.token.revoke",
624 targetType: "oauth_app",
625 targetId: app.id,
626 });
627 }
628 } catch (err) {
629 console.error("[oauth] revoke:", err);
630 // Still 200 per RFC 7009 — we don't want to leak whether the token existed.
631 }
632 return c.body(null, 200);
633});
634
635// --- GET /settings/authorizations -------------------------------------------
636
637oauth.get("/settings/authorizations", async (c) => {
638 const user = c.get("user")!;
639 const success = c.req.query("success");
640 const error = c.req.query("error");
641
642 type Row = {
643 app: typeof oauthApps.$inferSelect | null;
644 token: typeof oauthAccessTokens.$inferSelect;
645 };
646 let rows: Row[] = [];
647 try {
648 const raw = await db
649 .select()
650 .from(oauthAccessTokens)
651 .leftJoin(oauthApps, eq(oauthAccessTokens.appId, oauthApps.id))
652 .where(
653 and(
654 eq(oauthAccessTokens.userId, user.id),
655 isNull(oauthAccessTokens.revokedAt),
656 gt(oauthAccessTokens.expiresAt, new Date())
657 )
658 );
659 rows = raw.map((r: any) => ({
660 app: r.oauth_apps,
661 token: r.oauth_access_tokens,
662 }));
663 } catch (err) {
664 console.error("[oauth] authorizations list:", err);
665 }
666
667 // Group by appId — show each app once with the most recent token's data.
668 const byApp = new Map<string, Row>();
669 for (const r of rows) {
670 const existing = byApp.get(r.token.appId);
671 if (
672 !existing ||
673 new Date(r.token.createdAt) > new Date(existing.token.createdAt)
674 ) {
675 byApp.set(r.token.appId, r);
676 }
677 }
678 const grouped = Array.from(byApp.values());
679
680 return c.html(
681 <Layout title="Authorized applications" user={user}>
682 <div class="settings-container">
683 <div class="breadcrumb">
684 <a href="/settings">settings</a>
685 <span>/</span>
686 <span>authorized applications</span>
687 </div>
688 <h2>Authorized applications</h2>
689 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
690 {success && (
691 <div class="auth-success">{decodeURIComponent(success)}</div>
692 )}
693 <p style="color: var(--text-muted); font-size: 13px">
694 Apps that have been granted access to your gluecron account.
695 Revoking immediately invalidates every access + refresh token
696 issued to that app for your user.
697 </p>
698 <div
699 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-top: 16px"
700 >
701 {grouped.length === 0 ? (
702 <div
703 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
704 >
705 No authorized applications.
706 </div>
707 ) : (
708 grouped.map(({ app, token }) => (
709 <div
710 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; justify-content: space-between; align-items: center"
711 >
712 <div>
713 <strong>{app?.name || "Unknown app"}</strong>
714 <div
715 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
716 >
717 scopes: <code>{token.scopes || "(none)"}</code>
718 {" · "}authorised{" "}
719 {new Date(token.createdAt).toLocaleDateString()}
720 {token.lastUsedAt &&
721 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
722 </div>
723 </div>
724 <form
725 method="POST"
726 action={`/settings/authorizations/${token.appId}/revoke`}
727 onsubmit="return confirm('Revoke access for this application?')"
728 >
729 <button type="submit" class="btn btn-sm btn-danger">
730 Revoke
731 </button>
732 </form>
733 </div>
734 ))
735 )}
736 </div>
737 <p style="margin-top: 16px; font-size: 12px; color: var(--text-muted)">
738 Building an OAuth app?{" "}
739 <a href="/settings/applications">Register one</a>.
740 </p>
741 </div>
742 </Layout>
743 );
744});
745
746// --- POST /settings/authorizations/:appId/revoke ----------------------------
747
748oauth.post("/settings/authorizations/:appId/revoke", async (c) => {
749 const user = c.get("user")!;
750 const appId = c.req.param("appId");
751 try {
752 await db
753 .update(oauthAccessTokens)
754 .set({ revokedAt: new Date() })
755 .where(
756 and(
757 eq(oauthAccessTokens.userId, user.id),
758 eq(oauthAccessTokens.appId, appId),
759 isNull(oauthAccessTokens.revokedAt)
760 )
761 );
762 await audit({
763 userId: user.id,
764 action: "oauth.user_revoke",
765 targetType: "oauth_app",
766 targetId: appId,
767 });
768 return c.redirect("/settings/authorizations?success=Revoked");
769 } catch (err) {
770 console.error("[oauth] user revoke:", err);
771 return c.redirect(
772 "/settings/authorizations?error=Service+unavailable"
773 );
774 }
775});
776
777export default oauth;
778
779// re-export for test visibility
780export { SUPPORTED_SCOPES };
Addedsrc/routes/org-insights.tsx+346−0View fileUnifiedSplit
1/**
2 * Block F2 — Org-wide insights.
3 *
4 * GET /orgs/:slug/insights — rollup across every repo owned by the org:
5 * gate green-rate, open/merged PR counts, open
6 * issue count, recent gate activity, per-repo
7 * rows sorted by activity.
8 *
9 * No new tables — computed live from existing `repositories`, `gate_runs`,
10 * `pull_requests`, `issues`.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, gte, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 gateRuns,
18 issues,
19 organizations,
20 orgMembers,
21 pullRequests,
22 repositories,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27
28const orgInsights = new Hono<AuthEnv>();
29orgInsights.use("*", softAuth);
30
31export interface OrgInsightsSummary {
32 repoCount: number;
33 gateRunsTotal: number;
34 gatePassed: number;
35 gateFailed: number;
36 gateRepaired: number;
37 greenRate: number; // 0..1
38 openIssues: number;
39 openPrs: number;
40 mergedPrs30d: number;
41 perRepo: Array<{
42 id: string;
43 name: string;
44 runs: number;
45 greenRate: number;
46 openPrs: number;
47 openIssues: number;
48 }>;
49}
50
51export async function computeOrgInsights(
52 orgId: string
53): Promise<OrgInsightsSummary> {
54 const empty: OrgInsightsSummary = {
55 repoCount: 0,
56 gateRunsTotal: 0,
57 gatePassed: 0,
58 gateFailed: 0,
59 gateRepaired: 0,
60 greenRate: 0,
61 openIssues: 0,
62 openPrs: 0,
63 mergedPrs30d: 0,
64 perRepo: [],
65 };
66
67 try {
68 const repos = await db
69 .select({ id: repositories.id, name: repositories.name })
70 .from(repositories)
71 .where(eq(repositories.orgId, orgId));
72 if (repos.length === 0) return empty;
73
74 const repoIds = repos.map((r) => r.id);
75 const idList = sql.raw(
76 repoIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(",")
77 );
78
79 // Aggregate gate runs across repos
80 const gateRows = await db
81 .select({
82 repoId: gateRuns.repositoryId,
83 status: gateRuns.status,
84 n: sql<number>`count(*)::int`,
85 })
86 .from(gateRuns)
87 .where(sql`${gateRuns.repositoryId} IN (${idList})`)
88 .groupBy(gateRuns.repositoryId, gateRuns.status);
89
90 const totals = {
91 passed: 0,
92 failed: 0,
93 repaired: 0,
94 skipped: 0,
95 } as Record<string, number>;
96 const byRepo = new Map<
97 string,
98 { runs: number; passed: number; failed: number; repaired: number }
99 >();
100 for (const r of gateRows) {
101 const n = Number(r.n);
102 totals[r.status] = (totals[r.status] || 0) + n;
103 const b = byRepo.get(r.repoId) || {
104 runs: 0,
105 passed: 0,
106 failed: 0,
107 repaired: 0,
108 };
109 b.runs += n;
110 if (r.status === "passed") b.passed += n;
111 else if (r.status === "failed") b.failed += n;
112 else if (r.status === "repaired") b.repaired += n;
113 byRepo.set(r.repoId, b);
114 }
115 const gateRunsTotal = Object.values(totals).reduce((a, b) => a + b, 0);
116 const gatePassed = totals.passed || 0;
117 const gateFailed = totals.failed || 0;
118 const gateRepaired = totals.repaired || 0;
119 const greenRate = gateRunsTotal
120 ? (gatePassed + gateRepaired) / gateRunsTotal
121 : 0;
122
123 // Open issues/PRs across org repos
124 const issueRows = await db
125 .select({
126 repoId: issues.repositoryId,
127 state: issues.state,
128 n: sql<number>`count(*)::int`,
129 })
130 .from(issues)
131 .where(sql`${issues.repositoryId} IN (${idList})`)
132 .groupBy(issues.repositoryId, issues.state);
133
134 const openIssuesByRepo = new Map<string, number>();
135 let openIssues = 0;
136 for (const r of issueRows) {
137 if (r.state === "open") {
138 openIssuesByRepo.set(r.repoId, Number(r.n));
139 openIssues += Number(r.n);
140 }
141 }
142
143 const prRows = await db
144 .select({
145 repoId: pullRequests.repositoryId,
146 state: pullRequests.state,
147 n: sql<number>`count(*)::int`,
148 })
149 .from(pullRequests)
150 .where(sql`${pullRequests.repositoryId} IN (${idList})`)
151 .groupBy(pullRequests.repositoryId, pullRequests.state);
152
153 const openPrsByRepo = new Map<string, number>();
154 let openPrs = 0;
155 for (const r of prRows) {
156 if (r.state === "open") {
157 openPrsByRepo.set(r.repoId, Number(r.n));
158 openPrs += Number(r.n);
159 }
160 }
161
162 // Merged PRs in last 30d
163 const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
164 const [mergedRow] = await db
165 .select({ n: sql<number>`count(*)::int` })
166 .from(pullRequests)
167 .where(
168 and(
169 sql`${pullRequests.repositoryId} IN (${idList})`,
170 eq(pullRequests.state, "merged"),
171 gte(pullRequests.mergedAt, since)
172 )
173 );
174
175 const perRepo = repos.map((r) => {
176 const b = byRepo.get(r.id) || {
177 runs: 0,
178 passed: 0,
179 failed: 0,
180 repaired: 0,
181 };
182 const green = b.runs
183 ? (b.passed + b.repaired) / b.runs
184 : 0;
185 return {
186 id: r.id,
187 name: r.name,
188 runs: b.runs,
189 greenRate: green,
190 openPrs: openPrsByRepo.get(r.id) || 0,
191 openIssues: openIssuesByRepo.get(r.id) || 0,
192 };
193 });
194 perRepo.sort((a, b) => b.runs - a.runs);
195
196 return {
197 repoCount: repos.length,
198 gateRunsTotal,
199 gatePassed,
200 gateFailed,
201 gateRepaired,
202 greenRate,
203 openIssues,
204 openPrs,
205 mergedPrs30d: Number(mergedRow?.n || 0),
206 perRepo,
207 };
208 } catch {
209 return empty;
210 }
211}
212
213async function loadOrg(slug: string) {
214 try {
215 const [o] = await db
216 .select()
217 .from(organizations)
218 .where(eq(organizations.slug, slug))
219 .limit(1);
220 return o || null;
221 } catch {
222 return null;
223 }
224}
225
226async function isOrgMember(orgId: string, userId: string): Promise<boolean> {
227 try {
228 const [row] = await db
229 .select({ id: orgMembers.id })
230 .from(orgMembers)
231 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
232 .limit(1);
233 return !!row;
234 } catch {
235 return false;
236 }
237}
238
239orgInsights.get("/orgs/:slug/insights", requireAuth, async (c) => {
240 const user = c.get("user")!;
241 const slug = c.req.param("slug");
242 const org = await loadOrg(slug);
243 if (!org) return c.notFound();
244 const member = await isOrgMember(org.id, user.id);
245 if (!member) return c.redirect(`/orgs/${slug}`);
246
247 const summary = await computeOrgInsights(org.id);
248 const pct = (n: number) => Math.round(n * 100);
249
250 return c.html(
251 <Layout title={`${org.name} — Insights`} user={user}>
252 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
253 <h2>{org.name} · Insights</h2>
254 <a href={`/orgs/${slug}`} class="btn btn-sm">
255 Back to {slug}
256 </a>
257 </div>
258
259 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
260 <div class="panel" style="padding:12px;text-align:center">
261 <div style="font-size:22px;font-weight:700">{summary.repoCount}</div>
262 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
263 Repos
264 </div>
265 </div>
266 <div class="panel" style="padding:12px;text-align:center">
267 <div style="font-size:22px;font-weight:700;color:var(--green)">
268 {pct(summary.greenRate)}%
269 </div>
270 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
271 Green rate
272 </div>
273 </div>
274 <div class="panel" style="padding:12px;text-align:center">
275 <div style="font-size:22px;font-weight:700;color:#79c0ff">
276 {summary.openPrs}
277 </div>
278 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
279 Open PRs
280 </div>
281 </div>
282 <div class="panel" style="padding:12px;text-align:center">
283 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
284 {summary.mergedPrs30d}
285 </div>
286 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
287 Merged 30d
288 </div>
289 </div>
290 </div>
291
292 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px">
293 <div class="panel" style="padding:12px;text-align:center">
294 <div style="font-size:18px;font-weight:600">{summary.gateRunsTotal}</div>
295 <div style="font-size:11px;color:var(--text-muted)">Total gate runs</div>
296 </div>
297 <div class="panel" style="padding:12px;text-align:center">
298 <div style="font-size:18px;font-weight:600;color:var(--green)">
299 {summary.gatePassed}
300 </div>
301 <div style="font-size:11px;color:var(--text-muted)">Passed</div>
302 </div>
303 <div class="panel" style="padding:12px;text-align:center">
304 <div style="font-size:18px;font-weight:600;color:#bc8cff">
305 {summary.gateRepaired}
306 </div>
307 <div style="font-size:11px;color:var(--text-muted)">Repaired</div>
308 </div>
309 <div class="panel" style="padding:12px;text-align:center">
310 <div style="font-size:18px;font-weight:600;color:var(--red)">
311 {summary.gateFailed}
312 </div>
313 <div style="font-size:11px;color:var(--text-muted)">Failed</div>
314 </div>
315 </div>
316
317 <h3>Per-repo breakdown</h3>
318 <div class="panel" style="margin-bottom:20px">
319 {summary.perRepo.length === 0 ? (
320 <div class="panel-empty">This org has no repositories yet.</div>
321 ) : (
322 summary.perRepo.map((r) => (
323 <div class="panel-item" style="justify-content:space-between">
324 <div style="flex:1;min-width:0">
325 <a href={`/${slug}/${r.name}`} style="font-weight:600">
326 {slug}/{r.name}
327 </a>
328 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
329 {r.runs} runs · {r.openPrs} open PRs · {r.openIssues} open
330 issues
331 </div>
332 </div>
333 <span
334 style={`font-family:var(--font-mono);color:${r.greenRate >= 0.9 ? "var(--green)" : r.greenRate >= 0.7 ? "#f0b72f" : "var(--red)"}`}
335 >
336 {r.runs > 0 ? `${pct(r.greenRate)}%` : "—"}
337 </span>
338 </div>
339 ))
340 )}
341 </div>
342 </Layout>
343 );
344});
345
346export default orgInsights;
Addedsrc/routes/packages-api.ts+581−0View fileUnifiedSplit
1/**
2 * npm-compatible package registry HTTP endpoints (Block C2).
3 *
4 * Two surfaces on one sub-app:
5 * 1) /api/packages/... — JSON helpers the UI uses
6 * 2) /npm/... — the actual npm client protocol
7 *
8 * The npm client URL-encodes scoped names like `@acme/foo` as
9 * `@acme%2Ffoo`, and also may send them un-encoded. Both work because we
10 * parse the full tail of the path ourselves instead of relying on Hono's
11 * parameter extraction (which struggles with `@` and `/` in names).
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 packages,
19 packageVersions,
20 packageTags,
21 repositories,
22 users,
23} from "../db/schema";
24import type { Package, PackageVersion } from "../db/schema";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { audit } from "../lib/notify";
28import {
29 parsePackageName,
30 computeShasum,
31 computeIntegrity,
32 buildPackument,
33 resolveRepoFromPackageJson,
34 tarballFilename,
35} from "../lib/packages";
36
37const api = new Hono<AuthEnv>();
38api.use("*", softAuth);
39
40// ---------------------------------------------------------------------------
41// Helpers
42// ---------------------------------------------------------------------------
43
44type NpmPublishBody = {
45 name?: string;
46 "dist-tags"?: Record<string, string>;
47 versions?: Record<string, Record<string, unknown>>;
48 _attachments?: Record<
49 string,
50 { content_type?: string; data: string; length?: number }
51 >;
52};
53
54/**
55 * Extract the package name and optional trailing segment from a path like
56 * /npm/@scope/foo
57 * /npm/@scope%2Ffoo
58 * /npm/foo
59 * /npm/@scope/foo/-/foo-1.0.0.tgz
60 * /npm/foo/-/foo-1.0.0.tgz
61 * /npm/foo/-rev/42
62 */
63function parseNpmPath(path: string): {
64 nameRaw: string;
65 tail: string | null;
66 revTail: string | null;
67} {
68 const rest = path.replace(/^\/+/, "").replace(/^npm\/+/, "");
69
70 // Split on "/-/" (tarball endpoint) first.
71 const dashIdx = rest.indexOf("/-/");
72 if (dashIdx !== -1) {
73 return {
74 nameRaw: rest.slice(0, dashIdx),
75 tail: rest.slice(dashIdx + 3),
76 revTail: null,
77 };
78 }
79 // "-rev" style (npm unpublish).
80 const revIdx = rest.indexOf("/-rev/");
81 if (revIdx !== -1) {
82 return {
83 nameRaw: rest.slice(0, revIdx),
84 tail: null,
85 revTail: rest.slice(revIdx + 6),
86 };
87 }
88 return { nameRaw: rest, tail: null, revTail: null };
89}
90
91function baseUrlFrom(req: Request): string {
92 try {
93 const u = new URL(req.url);
94 return `${u.protocol}//${u.host}`;
95 } catch {
96 return "";
97 }
98}
99
100async function loadRepo(owner: string, repo: string) {
101 const [row] = await db
102 .select({
103 id: repositories.id,
104 name: repositories.name,
105 ownerId: repositories.ownerId,
106 visibility: repositories.visibility,
107 })
108 .from(repositories)
109 .innerJoin(users, eq(repositories.ownerId, users.id))
110 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
111 .limit(1);
112 return row || null;
113}
114
115async function loadPackage(
116 repoId: string,
117 scope: string | null,
118 name: string
119): Promise<Package | null> {
120 const conds = [
121 eq(packages.repositoryId, repoId),
122 eq(packages.ecosystem, "npm"),
123 eq(packages.name, name),
124 ];
125 const rows = await db
126 .select()
127 .from(packages)
128 .where(and(...conds))
129 .limit(20);
130 // Manual scope equality because drizzle's eq + null is awkward.
131 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
132 return match || null;
133}
134
135async function loadPackageByName(
136 scope: string | null,
137 name: string
138): Promise<Package | null> {
139 const rows = await db
140 .select()
141 .from(packages)
142 .where(and(eq(packages.ecosystem, "npm"), eq(packages.name, name)))
143 .limit(50);
144 const match = rows.find((p) => (p.scope ?? null) === (scope ?? null));
145 return match || null;
146}
147
148// ---------------------------------------------------------------------------
149// UI-facing JSON helpers
150// ---------------------------------------------------------------------------
151
152api.get("/api/packages/:owner/:repo", async (c) => {
153 const { owner, repo } = c.req.param();
154 try {
155 const repoRow = await loadRepo(owner, repo);
156 if (!repoRow) return c.json({ error: "repo not found" }, 404);
157 const rows = await db
158 .select()
159 .from(packages)
160 .where(
161 and(
162 eq(packages.repositoryId, repoRow.id),
163 eq(packages.ecosystem, "npm")
164 )
165 )
166 .orderBy(desc(packages.updatedAt));
167 return c.json({ packages: rows });
168 } catch (err) {
169 console.error("[packages] list:", err);
170 return c.json({ error: "service unavailable" }, 503);
171 }
172});
173
174api.get("/api/packages/:owner/:repo/:pkgName{.+}", async (c) => {
175 const { owner, repo, pkgName } = c.req.param();
176 const parsed = parsePackageName(pkgName);
177 if (!parsed) return c.json({ error: "invalid package name" }, 400);
178 try {
179 const repoRow = await loadRepo(owner, repo);
180 if (!repoRow) return c.json({ error: "repo not found" }, 404);
181 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
182 if (!pkg) return c.json({ error: "package not found" }, 404);
183 const versions = await db
184 .select()
185 .from(packageVersions)
186 .where(eq(packageVersions.packageId, pkg.id))
187 .orderBy(desc(packageVersions.publishedAt));
188 const tags = await db
189 .select()
190 .from(packageTags)
191 .where(eq(packageTags.packageId, pkg.id));
192 return c.json({ package: pkg, versions, tags });
193 } catch (err) {
194 console.error("[packages] detail:", err);
195 return c.json({ error: "service unavailable" }, 503);
196 }
197});
198
199// Yank endpoint (owner-only; marks a version as yanked but leaves it
200// downloadable so existing installs don't break — matches npm semantics).
201api.post(
202 "/api/packages/:owner/:repo/:pkgName/:version/yank",
203 requireAuth,
204 async (c) => {
205 const user = c.get("user")!;
206 const { owner, repo, pkgName, version } = c.req.param();
207 const parsed = parsePackageName(pkgName);
208 if (!parsed) return c.json({ error: "invalid package name" }, 400);
209 try {
210 const repoRow = await loadRepo(owner, repo);
211 if (!repoRow) return c.json({ error: "repo not found" }, 404);
212 if (repoRow.ownerId !== user.id) {
213 return c.json({ error: "forbidden" }, 403);
214 }
215 const pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
216 if (!pkg) return c.json({ error: "package not found" }, 404);
217
218 await db
219 .update(packageVersions)
220 .set({ yanked: true, yankedReason: "yanked by owner" })
221 .where(
222 and(
223 eq(packageVersions.packageId, pkg.id),
224 eq(packageVersions.version, version)
225 )
226 );
227
228 await audit({
229 userId: user.id,
230 repositoryId: repoRow.id,
231 action: "package.yank",
232 targetType: "package_version",
233 targetId: pkg.id,
234 metadata: { version, name: parsed.full },
235 });
236
237 return c.json({ ok: true });
238 } catch (err) {
239 console.error("[packages] yank:", err);
240 return c.json({ error: "service unavailable" }, 503);
241 }
242 }
243);
244
245// ---------------------------------------------------------------------------
246// npm protocol: packument + tarball
247// ---------------------------------------------------------------------------
248
249api.get("/npm/*", async (c) => {
250 const { nameRaw, tail } = parseNpmPath(c.req.path);
251 if (!nameRaw) return c.json({ error: "not found" }, 404);
252
253 const parsed = parsePackageName(nameRaw);
254 if (!parsed) return c.json({ error: "invalid package name" }, 400);
255
256 try {
257 const pkg = await loadPackageByName(parsed.scope, parsed.name);
258 if (!pkg) return c.json({ error: "not found" }, 404);
259
260 // Tarball request?
261 if (tail) {
262 const filename = decodeURIComponent(tail);
263 const versions = await db
264 .select()
265 .from(packageVersions)
266 .where(eq(packageVersions.packageId, pkg.id));
267
268 // Match by filename → version. Filename is `<name>-<version>.tgz`.
269 const match = versions.find(
270 (v) => tarballFilename(parsed, v.version) === filename
271 );
272 if (!match || !match.tarball) {
273 return c.json({ error: "tarball not found" }, 404);
274 }
275 const bytes = Buffer.from(match.tarball, "base64");
276 return new Response(bytes, {
277 status: 200,
278 headers: {
279 "Content-Type": "application/octet-stream",
280 "Content-Length": String(bytes.length),
281 "Cache-Control": "public, max-age=31536000, immutable",
282 },
283 });
284 }
285
286 // Packument request.
287 const versions = await db
288 .select()
289 .from(packageVersions)
290 .where(eq(packageVersions.packageId, pkg.id))
291 .orderBy(desc(packageVersions.publishedAt));
292 const tags = await db
293 .select()
294 .from(packageTags)
295 .where(eq(packageTags.packageId, pkg.id));
296
297 const doc = buildPackument(pkg, versions, tags, baseUrlFrom(c.req.raw));
298 return c.json(doc);
299 } catch (err) {
300 console.error("[packages] npm get:", err);
301 return c.json({ error: "service unavailable" }, 503);
302 }
303});
304
305// ---------------------------------------------------------------------------
306// npm protocol: publish (`npm publish` → PUT /<name>)
307// ---------------------------------------------------------------------------
308
309api.put("/npm/*", requireAuth, async (c) => {
310 const user = c.get("user")!;
311 const { nameRaw } = parseNpmPath(c.req.path);
312 const parsed = parsePackageName(nameRaw);
313 if (!parsed) {
314 return c.json({ error: "invalid package name" }, 400);
315 }
316
317 let body: NpmPublishBody;
318 try {
319 body = await c.req.json<NpmPublishBody>();
320 } catch {
321 return c.json({ error: "invalid JSON body" }, 400);
322 }
323
324 const versionsObj = body.versions || {};
325 const versionKeys = Object.keys(versionsObj);
326 if (versionKeys.length === 0) {
327 return c.json({ error: "no version in payload" }, 400);
328 }
329 // npm always sends exactly one version per publish.
330 const version = versionKeys[0];
331 const versionMeta = versionsObj[version] || {};
332
333 const attachments = body._attachments || {};
334 const attachKeys = Object.keys(attachments);
335 if (attachKeys.length === 0) {
336 return c.json({ error: "no tarball attachment" }, 400);
337 }
338 const attachment = attachments[attachKeys[0]];
339 if (!attachment || !attachment.data) {
340 return c.json({ error: "empty tarball attachment" }, 400);
341 }
342
343 const tarballBytes = Buffer.from(attachment.data, "base64");
344 if (tarballBytes.length === 0) {
345 return c.json({ error: "tarball decoded to zero bytes" }, 400);
346 }
347
348 // Resolve owner+repo from the metadata's repository.url.
349 const repoRef = resolveRepoFromPackageJson(versionMeta);
350 if (!repoRef) {
351 return c.json(
352 {
353 error:
354 "repository.url must point to a gluecron repo you own (e.g. http://host/:owner/:repo.git)",
355 },
356 400
357 );
358 }
359
360 try {
361 const repoRow = await loadRepo(repoRef.owner, repoRef.repo);
362 if (!repoRow) {
363 return c.json(
364 { error: `repo ${repoRef.owner}/${repoRef.repo} not found` },
365 404
366 );
367 }
368 if (repoRow.ownerId !== user.id) {
369 return c.json(
370 { error: "you do not own the repository named in repository.url" },
371 403
372 );
373 }
374
375 // Upsert the package row.
376 let pkg = await loadPackage(repoRow.id, parsed.scope, parsed.name);
377 if (!pkg) {
378 const description =
379 typeof versionMeta.description === "string"
380 ? (versionMeta.description as string)
381 : null;
382 const homepage =
383 typeof versionMeta.homepage === "string"
384 ? (versionMeta.homepage as string)
385 : null;
386 const license =
387 typeof versionMeta.license === "string"
388 ? (versionMeta.license as string)
389 : null;
390 const readme =
391 typeof (body as Record<string, unknown>).readme === "string"
392 ? ((body as Record<string, unknown>).readme as string)
393 : typeof versionMeta.readme === "string"
394 ? (versionMeta.readme as string)
395 : null;
396
397 const [inserted] = await db
398 .insert(packages)
399 .values({
400 repositoryId: repoRow.id,
401 ecosystem: "npm",
402 scope: parsed.scope,
403 name: parsed.name,
404 description,
405 readme,
406 homepage,
407 license,
408 visibility: repoRow.visibility === "private" ? "private" : "public",
409 })
410 .returning();
411 pkg = inserted;
412 }
413 if (!pkg) {
414 return c.json({ error: "failed to create package" }, 503);
415 }
416
417 // Reject duplicate version.
418 const [existing] = await db
419 .select()
420 .from(packageVersions)
421 .where(
422 and(
423 eq(packageVersions.packageId, pkg.id),
424 eq(packageVersions.version, version)
425 )
426 )
427 .limit(1);
428 if (existing) {
429 return c.json(
430 {
431 error: `You cannot publish over the previously published version ${version}.`,
432 },
433 409
434 );
435 }
436
437 const shasum = computeShasum(tarballBytes);
438 const integrity = computeIntegrity(tarballBytes);
439
440 const [insertedVersion] = await db
441 .insert(packageVersions)
442 .values({
443 packageId: pkg.id,
444 version,
445 shasum,
446 integrity,
447 sizeBytes: tarballBytes.length,
448 metadata: JSON.stringify(versionMeta),
449 tarball: tarballBytes.toString("base64"),
450 publishedBy: user.id,
451 })
452 .returning();
453
454 // Upsert "latest" dist-tag (and any other tags from the payload).
455 const distTags = body["dist-tags"] || { latest: version };
456 for (const [tag, tagVersion] of Object.entries(distTags)) {
457 if (tagVersion !== version) continue; // Only set tags pointing at this publish.
458 const [existingTag] = await db
459 .select()
460 .from(packageTags)
461 .where(
462 and(
463 eq(packageTags.packageId, pkg.id),
464 eq(packageTags.tag, tag)
465 )
466 )
467 .limit(1);
468 if (existingTag) {
469 await db
470 .update(packageTags)
471 .set({ versionId: insertedVersion.id, updatedAt: new Date() })
472 .where(eq(packageTags.id, existingTag.id));
473 } else {
474 await db.insert(packageTags).values({
475 packageId: pkg.id,
476 tag,
477 versionId: insertedVersion.id,
478 });
479 }
480 }
481
482 // Update package bookkeeping fields on every publish (license/description
483 // may evolve version-to-version; we keep the most recent).
484 await db
485 .update(packages)
486 .set({
487 updatedAt: new Date(),
488 description:
489 typeof versionMeta.description === "string"
490 ? (versionMeta.description as string)
491 : pkg.description,
492 homepage:
493 typeof versionMeta.homepage === "string"
494 ? (versionMeta.homepage as string)
495 : pkg.homepage,
496 license:
497 typeof versionMeta.license === "string"
498 ? (versionMeta.license as string)
499 : pkg.license,
500 })
501 .where(eq(packages.id, pkg.id));
502
503 await audit({
504 userId: user.id,
505 repositoryId: repoRow.id,
506 action: "package.publish",
507 targetType: "package_version",
508 targetId: insertedVersion.id,
509 metadata: {
510 name: parsed.full,
511 version,
512 size: tarballBytes.length,
513 },
514 });
515
516 return c.json({ ok: true, id: parsed.full, version }, 201);
517 } catch (err) {
518 console.error("[packages] publish:", err);
519 return c.json({ error: "service unavailable" }, 503);
520 }
521});
522
523// npm unpublish: DELETE /npm/<name>/-rev/<rev> — we treat this as a yank.
524api.delete("/npm/*", requireAuth, async (c) => {
525 const user = c.get("user")!;
526 const { nameRaw, revTail } = parseNpmPath(c.req.path);
527 const parsed = parsePackageName(nameRaw);
528 if (!parsed) return c.json({ error: "invalid package name" }, 400);
529 if (!revTail) {
530 return c.json(
531 { error: "unpublish without rev is not supported" },
532 400
533 );
534 }
535
536 try {
537 const pkg = await loadPackageByName(parsed.scope, parsed.name);
538 if (!pkg) return c.json({ error: "not found" }, 404);
539 const [repoRow] = await db
540 .select()
541 .from(repositories)
542 .where(eq(repositories.id, pkg.repositoryId))
543 .limit(1);
544 if (!repoRow || repoRow.ownerId !== user.id) {
545 return c.json({ error: "forbidden" }, 403);
546 }
547
548 // Yank the latest version.
549 const [latest] = await db
550 .select()
551 .from(packageVersions)
552 .where(eq(packageVersions.packageId, pkg.id))
553 .orderBy(desc(packageVersions.publishedAt))
554 .limit(1);
555 if (latest) {
556 await db
557 .update(packageVersions)
558 .set({ yanked: true, yankedReason: "unpublished" })
559 .where(eq(packageVersions.id, latest.id));
560 }
561
562 await audit({
563 userId: user.id,
564 repositoryId: repoRow.id,
565 action: "package.unpublish",
566 targetType: "package",
567 targetId: pkg.id,
568 metadata: { name: parsed.full },
569 });
570
571 return c.json({ ok: true });
572 } catch (err) {
573 console.error("[packages] unpublish:", err);
574 return c.json({ error: "service unavailable" }, 503);
575 }
576});
577
578export default api;
579
580// Re-export helpers internally for the UI route.
581export type { Package, PackageVersion };
Addedsrc/routes/packages.tsx+442−0View fileUnifiedSplit
1/**
2 * Packages UI — lists packages for a repo, per-package detail (Block C2).
3 *
4 * GET /:owner/:repo/packages — list of published packages
5 * GET /:owner/:repo/packages/:pkg{.+} — detail + version list + install help
6 *
7 * Packages doesn't yet have a tab in RepoNav — we render with active="code"
8 * so the page still lays out correctly.
9 */
10
11import { Hono } from "hono";
12import { and, desc, eq } from "drizzle-orm";
13import { db } from "../db";
14import {
15 packages,
16 packageVersions,
17 packageTags,
18 repositories,
19 users,
20} from "../db/schema";
21import type { Package, PackageVersion, PackageTag } from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getUnreadCount } from "../lib/unread";
27import { parsePackageName } from "../lib/packages";
28
29const ui = new Hono<AuthEnv>();
30ui.use("*", softAuth);
31
32async function loadRepo(owner: string, repo: string) {
33 const [row] = await db
34 .select({
35 id: repositories.id,
36 name: repositories.name,
37 ownerId: repositories.ownerId,
38 defaultBranch: repositories.defaultBranch,
39 starCount: repositories.starCount,
40 forkCount: repositories.forkCount,
41 })
42 .from(repositories)
43 .innerJoin(users, eq(repositories.ownerId, users.id))
44 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
45 .limit(1);
46 return row || null;
47}
48
49function relTime(d: Date | string | null): string {
50 if (!d) return "";
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diff = Date.now() - t.getTime();
53 const mins = Math.floor(diff / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63function fullPkgName(pkg: { scope: string | null; name: string }): string {
64 return pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
65}
66
67function humanSize(bytes: number): string {
68 if (bytes < 1024) return `${bytes} B`;
69 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
70 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
71}
72
73// ---------------------------------------------------------------------------
74// List page
75// ---------------------------------------------------------------------------
76
77ui.get("/:owner/:repo/packages", async (c) => {
78 const user = c.get("user");
79 const { owner, repo } = c.req.param();
80 const repoRow = await loadRepo(owner, repo);
81 if (!repoRow) return c.notFound();
82
83 let rows: (Package & { latestVersion: string | null })[] = [];
84 try {
85 const pkgs = await db
86 .select()
87 .from(packages)
88 .where(
89 and(
90 eq(packages.repositoryId, repoRow.id),
91 eq(packages.ecosystem, "npm")
92 )
93 )
94 .orderBy(desc(packages.updatedAt));
95
96 // Fetch the "latest" tag for each package.
97 const latest = await Promise.all(
98 pkgs.map(async (p) => {
99 try {
100 const [tag] = await db
101 .select({
102 version: packageVersions.version,
103 })
104 .from(packageTags)
105 .innerJoin(
106 packageVersions,
107 eq(packageTags.versionId, packageVersions.id)
108 )
109 .where(
110 and(
111 eq(packageTags.packageId, p.id),
112 eq(packageTags.tag, "latest")
113 )
114 )
115 .limit(1);
116 return tag?.version || null;
117 } catch {
118 return null;
119 }
120 })
121 );
122 rows = pkgs.map((p, i) => ({ ...p, latestVersion: latest[i] }));
123 } catch (err) {
124 console.error("[packages] ui list:", err);
125 return c.text("Service unavailable", 503);
126 }
127
128 const unread = user ? await getUnreadCount(user.id) : 0;
129 const host = new URL(c.req.url).host;
130 const registryUrl = `${new URL(c.req.url).protocol}//${host}/npm/`;
131
132 return c.html(
133 <Layout
134 title={`Packages — ${owner}/${repo}`}
135 user={user}
136 notificationCount={unread}
137 >
138 <RepoHeader
139 owner={owner}
140 repo={repo}
141 starCount={repoRow.starCount}
142 forkCount={repoRow.forkCount}
143 currentUser={user?.username || null}
144 />
145 <RepoNav owner={owner} repo={repo} active="code" />
146
147 <div style="max-width: 900px">
148 <h2 style="margin: 0 0 16px 0">Packages</h2>
149
150 {rows.length === 0 ? (
151 <div class="empty-state">
152 <p style="margin-bottom: 12px">
153 No npm packages published from this repository yet.
154 </p>
155 <div
156 style="text-align: left; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; font-size: 13px; max-width: 640px; margin: 0 auto"
157 >
158 <strong>To publish:</strong>
159 <ol style="padding-left: 20px; margin: 8px 0 0 0; line-height: 1.6">
160 <li>
161 Create a personal access token at{" "}
162 <a href="/settings/tokens">/settings/tokens</a>.
163 </li>
164 <li>
165 Add to your <code>.npmrc</code>:
166 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 8px 12px; border-radius: 4px; font-size: 12px; margin: 6px 0">
167 registry={registryUrl}
168 {"\n"}
169 //{host}/npm/:_authToken=YOUR_PAT
170 </pre>
171 </li>
172 <li>
173 In <code>package.json</code>, point{" "}
174 <code>repository.url</code> at this repo
175 (<code>
176 {`http://${host}/${owner}/${repo}.git`}
177 </code>
178 ).
179 </li>
180 <li>
181 Run <code>npm publish</code>.
182 </li>
183 </ol>
184 </div>
185 </div>
186 ) : (
187 <div class="panel" style="overflow: hidden">
188 {rows.map((p) => {
189 const fullName = fullPkgName(p);
190 return (
191 <a
192 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
193 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
194 >
195 <div style="display: flex; justify-content: space-between; align-items: baseline; gap: 12px">
196 <div style="flex: 1; min-width: 0">
197 <div style="font-weight: 600">{fullName}</div>
198 {p.description && (
199 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
200 {p.description}
201 </div>
202 )}
203 </div>
204 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
205 {p.latestVersion ? (
206 <span>
207 <code>{p.latestVersion}</code>
208 </span>
209 ) : (
210 <span>no versions</span>
211 )}
212 {" · "}
213 <span>{relTime(p.updatedAt)}</span>
214 </div>
215 </div>
216 </a>
217 );
218 })}
219 </div>
220 )}
221 </div>
222 </Layout>
223 );
224});
225
226// ---------------------------------------------------------------------------
227// Detail page
228// ---------------------------------------------------------------------------
229
230ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
231 const user = c.get("user");
232 const { owner, repo, pkgName } = c.req.param();
233 const parsed = parsePackageName(pkgName);
234 if (!parsed) {
235 return c.text("Invalid package name", 400);
236 }
237
238 const repoRow = await loadRepo(owner, repo);
239 if (!repoRow) return c.notFound();
240
241 let pkg: Package | null = null;
242 let versions: PackageVersion[] = [];
243 let tags: PackageTag[] = [];
244 try {
245 const candidates = await db
246 .select()
247 .from(packages)
248 .where(
249 and(
250 eq(packages.repositoryId, repoRow.id),
251 eq(packages.ecosystem, "npm"),
252 eq(packages.name, parsed.name)
253 )
254 )
255 .limit(10);
256 pkg =
257 candidates.find((p) => (p.scope ?? null) === (parsed.scope ?? null)) ||
258 null;
259 if (pkg) {
260 versions = await db
261 .select()
262 .from(packageVersions)
263 .where(eq(packageVersions.packageId, pkg.id))
264 .orderBy(desc(packageVersions.publishedAt));
265 tags = await db
266 .select()
267 .from(packageTags)
268 .where(eq(packageTags.packageId, pkg.id));
269 }
270 } catch (err) {
271 console.error("[packages] ui detail:", err);
272 return c.text("Service unavailable", 503);
273 }
274
275 if (!pkg) return c.notFound();
276
277 const unread = user ? await getUnreadCount(user.id) : 0;
278 const fullName = fullPkgName(pkg);
279 const latestTag = tags.find((t) => t.tag === "latest");
280 const latestVersion =
281 latestTag && versions.find((v) => v.id === latestTag.versionId);
282 const isOwner = !!user && user.id === repoRow.ownerId;
283 const host = new URL(c.req.url).host;
284
285 return c.html(
286 <Layout
287 title={`${fullName} — ${owner}/${repo}`}
288 user={user}
289 notificationCount={unread}
290 >
291 <RepoHeader
292 owner={owner}
293 repo={repo}
294 starCount={repoRow.starCount}
295 forkCount={repoRow.forkCount}
296 currentUser={user?.username || null}
297 />
298 <RepoNav owner={owner} repo={repo} active="code" />
299
300 <div style="max-width: 900px">
301 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px">
302 <a href={`/${owner}/${repo}/packages`}>Packages</a>
303 {" / "}
304 <span>{fullName}</span>
305 </div>
306 <h2 style="margin: 0 0 4px 0">{fullName}</h2>
307 {pkg.description && (
308 <p style="color: var(--text-muted); margin: 0 0 16px 0">
309 {pkg.description}
310 </p>
311 )}
312
313 <div style="display: grid; grid-template-columns: 1fr 280px; gap: 24px">
314 <div>
315 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 8px 0">
316 Install
317 </h3>
318 <pre style="background: #0b0d0f; color: #c7ccd1; padding: 10px 14px; border-radius: 6px; font-size: 13px; overflow-x: auto">
319 npm install {fullName}
320 {latestVersion ? `@${latestVersion.version}` : ""}
321 </pre>
322
323 {pkg.readme && (
324 <>
325 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
326 Readme
327 </h3>
328 <pre
329 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; white-space: pre-wrap; font-size: 13px; line-height: 1.5"
330 >
331 {pkg.readme}
332 </pre>
333 </>
334 )}
335
336 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 20px 0 8px 0">
337 Versions
338 </h3>
339 {versions.length === 0 ? (
340 <div class="empty-state">
341 <p>No versions yet.</p>
342 </div>
343 ) : (
344 <div class="panel" style="overflow: hidden">
345 {versions.map((v) => (
346 <div
347 style="padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 12px"
348 >
349 <div style="flex: 1; min-width: 0">
350 <div style="font-weight: 500">
351 <code>{v.version}</code>
352 {v.yanked && (
353 <span
354 style="margin-left: 8px; font-size: 11px; color: var(--red); text-transform: uppercase"
355 >
356 yanked
357 </span>
358 )}
359 </div>
360 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
361 {humanSize(v.sizeBytes)} · published{" "}
362 {relTime(v.publishedAt)}
363 {v.shasum && (
364 <>
365 {" · sha1 "}
366 <code style="font-size: 11px">
367 {v.shasum.slice(0, 12)}
368 </code>
369 </>
370 )}
371 </div>
372 </div>
373 <a
374 class="btn btn-sm"
375 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
376 >
377 Download
378 </a>
379 {isOwner && !v.yanked && (
380 <form
381 method="POST"
382 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
383 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
384 style="margin: 0"
385 >
386 <button
387 type="submit"
388 class="btn btn-sm btn-danger"
389 >
390 Yank
391 </button>
392 </form>
393 )}
394 </div>
395 ))}
396 </div>
397 )}
398 </div>
399
400 <aside>
401 <div class="panel" style="padding: 12px 14px">
402 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
403 Registry
404 </div>
405 <code style="font-size: 12px">http://{host}/npm/</code>
406
407 {pkg.homepage && (
408 <>
409 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
410 Homepage
411 </div>
412 <a href={pkg.homepage} style="font-size: 13px; word-break: break-all">
413 {pkg.homepage}
414 </a>
415 </>
416 )}
417 {pkg.license && (
418 <>
419 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
420 License
421 </div>
422 <div style="font-size: 13px">{pkg.license}</div>
423 </>
424 )}
425 <div style="font-size: 12px; color: var(--text-muted); margin: 10px 0 4px 0">
426 Repository
427 </div>
428 <a
429 href={`/${owner}/${repo}`}
430 style="font-size: 13px; word-break: break-all"
431 >
432 {owner}/{repo}
433 </a>
434 </div>
435 </aside>
436 </div>
437 </div>
438 </Layout>
439 );
440});
441
442export default ui;
Addedsrc/routes/pages.tsx+518−0View fileUnifiedSplit
1/**
2 * Block C3 — Pages / static hosting routes.
3 *
4 * GET /:owner/:repo/pages/* — serve a static file from the
5 * latest successful gh-pages
6 * deployment
7 * GET /:owner/:repo/settings/pages — settings UI (owner-only)
8 * POST /:owner/:repo/settings/pages — upsert settings
9 * POST /:owner/:repo/settings/pages/redeploy — manual redeploy trigger
10 *
11 * The serving endpoint reads blobs directly out of the bare git repo at the
12 * commit sha of the most recent pages_deployments row for that repo. There is
13 * no on-disk export — the git store IS the CDN.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq } from "drizzle-orm";
18import { db } from "../db";
19import {
20 pagesDeployments,
21 pagesSettings,
22 repositories,
23 users,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getBlob, getRawBlob, resolveRef } from "../git/repository";
30import { audit } from "../lib/notify";
31import { getUnreadCount } from "../lib/unread";
32import { config } from "../lib/config";
33import {
34 contentTypeFor,
35 onPagesPush,
36 resolvePagesPath,
37} from "../lib/pages";
38
39const pagesRoute = new Hono<AuthEnv>();
40pagesRoute.use("*", softAuth);
41
42interface LoadedRepo {
43 id: string;
44 name: string;
45 ownerId: string;
46 ownerUsername: string;
47}
48
49async function loadRepo(
50 owner: string,
51 repo: string
52): Promise<LoadedRepo | null> {
53 try {
54 const [row] = await db
55 .select({
56 id: repositories.id,
57 name: repositories.name,
58 ownerId: repositories.ownerId,
59 ownerUsername: users.username,
60 })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
64 .limit(1);
65 return row || null;
66 } catch {
67 return null;
68 }
69}
70
71async function getEffectiveSettings(repositoryId: string) {
72 try {
73 const [row] = await db
74 .select()
75 .from(pagesSettings)
76 .where(eq(pagesSettings.repositoryId, repositoryId))
77 .limit(1);
78 if (row) return row;
79 } catch {
80 /* fall through to defaults */
81 }
82 // Synthesise defaults when the row doesn't exist.
83 return {
84 repositoryId,
85 enabled: true,
86 sourceBranch: "gh-pages",
87 sourceDir: "/",
88 customDomain: null as string | null,
89 updatedAt: new Date(),
90 };
91}
92
93// ---------------------------------------------------------------------------
94// Serve: GET /:owner/:repo/pages/*
95// ---------------------------------------------------------------------------
96
97pagesRoute.get("/:owner/:repo/pages/*", async (c) => {
98 const { owner, repo } = c.req.param();
99
100 // Hono gives us the full path via c.req.path; extract whatever sits after
101 // the "/pages/" segment. This is the only path component we treat as the
102 // user-facing URL.
103 const full = c.req.path;
104 const marker = `/${owner}/${repo}/pages/`;
105 const idx = full.indexOf(marker);
106 const urlRest = idx >= 0 ? full.slice(idx + marker.length) : "";
107
108 const repoRow = await loadRepo(owner, repo);
109 if (!repoRow) {
110 return c.text("No Pages site published for this repository.", 404);
111 }
112
113 const settings = await getEffectiveSettings(repoRow.id);
114 if (!settings.enabled) {
115 return c.text("No Pages site published for this repository.", 404);
116 }
117
118 let deployment:
119 | { commitSha: string; createdAt: Date; status: string }
120 | null = null;
121 try {
122 const [row] = await db
123 .select({
124 commitSha: pagesDeployments.commitSha,
125 createdAt: pagesDeployments.createdAt,
126 status: pagesDeployments.status,
127 })
128 .from(pagesDeployments)
129 .where(
130 and(
131 eq(pagesDeployments.repositoryId, repoRow.id),
132 eq(pagesDeployments.status, "success")
133 )
134 )
135 .orderBy(desc(pagesDeployments.createdAt))
136 .limit(1);
137 deployment = row || null;
138 } catch {
139 return c.text("Service unavailable", 503);
140 }
141
142 if (!deployment) {
143 return c.text(
144 "No Pages site published for this repository. Push to the configured source branch to publish.",
145 404
146 );
147 }
148
149 const candidates = resolvePagesPath(urlRest, settings.sourceDir);
150
151 for (const candidate of candidates) {
152 // Try as text first — getBlob fills in isBinary for us.
153 const blob = await getBlob(owner, repo, deployment.commitSha, candidate);
154 if (!blob) continue;
155
156 const headers: Record<string, string> = {
157 "Content-Type": contentTypeFor(candidate),
158 "Cache-Control": "public, max-age=60",
159 "X-Gluecron-Pages-Sha": deployment.commitSha.slice(0, 7),
160 };
161
162 if (blob.isBinary) {
163 // getBlob blanks the content for binary — re-read the raw bytes.
164 const raw = await getRawBlob(
165 owner,
166 repo,
167 deployment.commitSha,
168 candidate
169 );
170 if (!raw) continue;
171 return new Response(raw, { status: 200, headers });
172 }
173
174 return new Response(blob.content, { status: 200, headers });
175 }
176
177 return c.text("Not found in Pages site.", 404);
178});
179
180// ---------------------------------------------------------------------------
181// Settings UI: GET /:owner/:repo/settings/pages
182// ---------------------------------------------------------------------------
183
184pagesRoute.get(
185 "/:owner/:repo/settings/pages",
186 requireAuth,
187 async (c) => {
188 const { owner: ownerName, repo: repoName } = c.req.param();
189 const user = c.get("user")!;
190 const success = c.req.query("success");
191 const error = c.req.query("error");
192 const info = c.req.query("info");
193
194 const repoRow = await loadRepo(ownerName, repoName);
195 if (!repoRow) return c.notFound();
196 if (repoRow.ownerId !== user.id) {
197 return c.html(
198 <Layout title="Unauthorized" user={user}>
199 <div class="empty-state">
200 <h2>Unauthorized</h2>
201 <p>Only the repository owner can configure Pages.</p>
202 </div>
203 </Layout>,
204 403
205 );
206 }
207
208 const settings = await getEffectiveSettings(repoRow.id);
209
210 let recent: Array<{
211 id: string;
212 ref: string;
213 commitSha: string;
214 status: string;
215 createdAt: Date;
216 }> = [];
217 try {
218 recent = await db
219 .select({
220 id: pagesDeployments.id,
221 ref: pagesDeployments.ref,
222 commitSha: pagesDeployments.commitSha,
223 status: pagesDeployments.status,
224 createdAt: pagesDeployments.createdAt,
225 })
226 .from(pagesDeployments)
227 .where(eq(pagesDeployments.repositoryId, repoRow.id))
228 .orderBy(desc(pagesDeployments.createdAt))
229 .limit(10);
230 } catch {
231 /* fall through; render with empty list */
232 }
233
234 const unread = await getUnreadCount(user.id);
235 const siteUrl = `${config.appBaseUrl}/${ownerName}/${repoName}/pages/`;
236
237 return c.html(
238 <Layout
239 title={`Pages — ${ownerName}/${repoName}`}
240 user={user}
241 notificationCount={unread}
242 >
243 <RepoHeader owner={ownerName} repo={repoName} />
244 <RepoNav owner={ownerName} repo={repoName} active="code" />
245 <div style="max-width: 720px">
246 <h2 style="margin-bottom: 16px">Pages</h2>
247 {success && (
248 <div class="auth-success">{decodeURIComponent(success)}</div>
249 )}
250 {info && <div class="auth-success">{decodeURIComponent(info)}</div>}
251 {error && (
252 <div class="auth-error">{decodeURIComponent(error)}</div>
253 )}
254
255 <p style="color: var(--text-muted); margin-bottom: 20px">
256 Publish a static site from this repository. Push to the source
257 branch and every successful push becomes a new deployment.
258 </p>
259
260 <div
261 style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 24px; background: var(--bg-muted)"
262 >
263 <div style="font-size: 13px; color: var(--text-muted)">
264 Your site is published at:
265 </div>
266 <div style="margin-top: 4px">
267 <a href={siteUrl}>{siteUrl}</a>
268 </div>
269 </div>
270
271 <form
272 method="POST"
273 action={`/${ownerName}/${repoName}/settings/pages`}
274 >
275 <div class="form-group">
276 <label>
277 <input
278 type="checkbox"
279 name="enabled"
280 value="1"
281 checked={settings.enabled}
282 />
283 {" "}Enable GitHub Pages
284 </label>
285 </div>
286 <div class="form-group">
287 <label for="source_branch">Source branch</label>
288 <input
289 type="text"
290 id="source_branch"
291 name="source_branch"
292 value={settings.sourceBranch}
293 placeholder="gh-pages"
294 />
295 </div>
296 <div class="form-group">
297 <label for="source_dir">Source directory</label>
298 <input
299 type="text"
300 id="source_dir"
301 name="source_dir"
302 value={settings.sourceDir}
303 placeholder="/"
304 />
305 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
306 Use "/" to serve from the repo root, or e.g. "/docs".
307 </div>
308 </div>
309 <div class="form-group">
310 <label for="custom_domain">Custom domain (optional)</label>
311 <input
312 type="text"
313 id="custom_domain"
314 name="custom_domain"
315 value={settings.customDomain || ""}
316 placeholder="example.com"
317 />
318 </div>
319 <button type="submit" class="btn btn-primary">
320 Save
321 </button>
322 </form>
323
324 <div style="margin-top: 32px">
325 <div
326 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px"
327 >
328 <h3>Recent deployments</h3>
329 <form
330 method="POST"
331 action={`/${ownerName}/${repoName}/settings/pages/redeploy`}
332 style="display: inline"
333 >
334 <button type="submit" class="btn">
335 Redeploy from HEAD
336 </button>
337 </form>
338 </div>
339 {recent.length === 0 ? (
340 <div class="empty-state">
341 <p>
342 No deployments yet — push to{" "}
343 <code>{settings.sourceBranch}</code> to publish.
344 </p>
345 </div>
346 ) : (
347 <table
348 style="width: 100%; border-collapse: collapse; font-size: 13px"
349 >
350 <thead>
351 <tr style="text-align: left; color: var(--text-muted)">
352 <th style="padding: 6px 0">When</th>
353 <th>Ref</th>
354 <th>Commit</th>
355 <th>Status</th>
356 </tr>
357 </thead>
358 <tbody>
359 {recent.map((d) => (
360 <tr style="border-top: 1px solid var(--border)">
361 <td style="padding: 6px 0">
362 {new Date(d.createdAt).toISOString()}
363 </td>
364 <td>
365 <code>{d.ref}</code>
366 </td>
367 <td>
368 <code>{d.commitSha.slice(0, 7)}</code>
369 </td>
370 <td
371 style={`color: ${d.status === "success" ? "var(--green)" : "var(--red)"}`}
372 >
373 {d.status}
374 </td>
375 </tr>
376 ))}
377 </tbody>
378 </table>
379 )}
380 </div>
381 </div>
382 </Layout>
383 );
384 }
385);
386
387// ---------------------------------------------------------------------------
388// Save settings: POST /:owner/:repo/settings/pages
389// ---------------------------------------------------------------------------
390
391pagesRoute.post(
392 "/:owner/:repo/settings/pages",
393 requireAuth,
394 async (c) => {
395 const { owner: ownerName, repo: repoName } = c.req.param();
396 const user = c.get("user")!;
397 const body = await c.req.parseBody();
398
399 const repoRow = await loadRepo(ownerName, repoName);
400 if (!repoRow) return c.notFound();
401 if (repoRow.ownerId !== user.id) {
402 return c.redirect(`/${ownerName}/${repoName}`);
403 }
404
405 const enabled = body.enabled === "1" || body.enabled === "on";
406 const sourceBranch =
407 String(body.source_branch || "gh-pages").trim() || "gh-pages";
408 let sourceDir = String(body.source_dir || "/").trim() || "/";
409 if (!sourceDir.startsWith("/")) sourceDir = `/${sourceDir}`;
410 const customDomainRaw = String(body.custom_domain || "").trim();
411 const customDomain = customDomainRaw === "" ? null : customDomainRaw;
412
413 try {
414 const [existing] = await db
415 .select({ repositoryId: pagesSettings.repositoryId })
416 .from(pagesSettings)
417 .where(eq(pagesSettings.repositoryId, repoRow.id))
418 .limit(1);
419 if (existing) {
420 await db
421 .update(pagesSettings)
422 .set({
423 enabled,
424 sourceBranch,
425 sourceDir,
426 customDomain,
427 updatedAt: new Date(),
428 })
429 .where(eq(pagesSettings.repositoryId, repoRow.id));
430 } else {
431 await db.insert(pagesSettings).values({
432 repositoryId: repoRow.id,
433 enabled,
434 sourceBranch,
435 sourceDir,
436 customDomain,
437 });
438 }
439 } catch (err) {
440 console.error("[pages] save settings:", err);
441 return c.redirect(
442 `/${ownerName}/${repoName}/settings/pages?error=${encodeURIComponent("Could not save settings")}`
443 );
444 }
445
446 await audit({
447 userId: user.id,
448 repositoryId: repoRow.id,
449 action: "pages.settings.update",
450 metadata: { enabled, sourceBranch, sourceDir, customDomain },
451 });
452
453 return c.redirect(
454 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Pages settings saved")}`
455 );
456 }
457);
458
459// ---------------------------------------------------------------------------
460// Manual redeploy: POST /:owner/:repo/settings/pages/redeploy
461// ---------------------------------------------------------------------------
462
463pagesRoute.post(
464 "/:owner/:repo/settings/pages/redeploy",
465 requireAuth,
466 async (c) => {
467 const { owner: ownerName, repo: repoName } = c.req.param();
468 const user = c.get("user")!;
469
470 const repoRow = await loadRepo(ownerName, repoName);
471 if (!repoRow) return c.notFound();
472 if (repoRow.ownerId !== user.id) {
473 return c.redirect(`/${ownerName}/${repoName}`);
474 }
475
476 const settings = await getEffectiveSettings(repoRow.id);
477 const branch = settings.sourceBranch || "gh-pages";
478 const ref = `refs/heads/${branch}`;
479
480 // Try to resolve the current head of the source branch. If the branch
481 // doesn't exist yet, tell the owner to push something to it instead of
482 // recording a bogus deployment row.
483 const sha = await resolveRef(ownerName, repoName, ref);
484 if (!sha) {
485 await audit({
486 userId: user.id,
487 repositoryId: repoRow.id,
488 action: "pages.redeploy",
489 metadata: { ref, result: "no-branch" },
490 });
491 return c.redirect(
492 `/${ownerName}/${repoName}/settings/pages?info=${encodeURIComponent(`Branch ${branch} has no commits yet — push to it to deploy.`)}`
493 );
494 }
495
496 await onPagesPush({
497 ownerLogin: ownerName,
498 repoName,
499 repositoryId: repoRow.id,
500 ref,
501 newSha: sha,
502 triggeredByUserId: user.id,
503 });
504
505 await audit({
506 userId: user.id,
507 repositoryId: repoRow.id,
508 action: "pages.redeploy",
509 metadata: { ref, sha },
510 });
511
512 return c.redirect(
513 `/${ownerName}/${repoName}/settings/pages?success=${encodeURIComponent("Redeploy recorded")}`
514 );
515 }
516);
517
518export default pagesRoute;
Addedsrc/routes/passkeys.tsx+428−0View fileUnifiedSplit
1/**
2 * WebAuthn passkey routes (Block B5).
3 *
4 * Registration (authed):
5 * POST /api/passkeys/register/options → challenge + pubkey-cred-params
6 * POST /api/passkeys/register/verify → save credential
7 * GET /settings/passkeys → list + add + rename + delete
8 * POST /settings/passkeys/:id/delete
9 * POST /settings/passkeys/:id/rename
10 *
11 * Authentication (unauthed):
12 * POST /api/passkeys/auth/options → challenge (username optional)
13 * POST /api/passkeys/auth/verify → issues full session on success
14 *
15 * The browser-side glue lives in `/views/components.tsx`
16 * (`PasskeyScript`) — vanilla JS using the native `navigator.credentials` API.
17 */
18
19import { Hono } from "hono";
20import { setCookie } from "hono/cookie";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { users, userPasskeys, sessions } from "../db/schema";
24import type { AuthEnv } from "../middleware/auth";
25import { requireAuth } from "../middleware/auth";
26import { Layout } from "../views/layout";
27import {
28 startRegistration,
29 finishRegistration,
30 startAuthentication,
31 finishAuthentication,
32} from "../lib/webauthn";
33import {
34 generateSessionToken,
35 sessionCookieOptions,
36 sessionExpiry,
37} from "../lib/auth";
38import { audit } from "../lib/notify";
39
40const passkeys = new Hono<AuthEnv>();
41
42passkeys.use("/settings/passkeys", requireAuth);
43passkeys.use("/settings/passkeys/*", requireAuth);
44passkeys.use("/api/passkeys/register/*", requireAuth);
45
46// --- Settings UI ------------------------------------------------------------
47
48passkeys.get("/settings/passkeys", async (c) => {
49 const user = c.get("user")!;
50 const error = c.req.query("error");
51 const success = c.req.query("success");
52
53 let keys: (typeof userPasskeys.$inferSelect)[] = [];
54 try {
55 keys = await db
56 .select()
57 .from(userPasskeys)
58 .where(eq(userPasskeys.userId, user.id));
59 } catch (err) {
60 console.error("[passkeys] list:", err);
61 }
62
63 return c.html(
64 <Layout title="Passkeys" user={user}>
65 <div class="settings-container">
66 <div class="breadcrumb">
67 <a href="/settings">settings</a>
68 <span>/</span>
69 <span>passkeys</span>
70 </div>
71 <h2>Passkeys</h2>
72 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
73 {success && (
74 <div class="auth-success">{decodeURIComponent(success)}</div>
75 )}
76 <p style="color: var(--text-muted); font-size: 13px">
77 Passkeys are a phishing-resistant replacement for passwords. Your
78 device stores the private key and never shares it — sign-in is a
79 single Touch ID / Face ID / security-key tap.
80 </p>
81
82 <div style="margin: 16px 0">
83 <button
84 type="button"
85 id="pk-add-btn"
86 class="btn btn-primary"
87 >
88 Add a passkey
89 </button>
90 <span
91 id="pk-add-status"
92 style="color: var(--text-muted); font-size: 13px; margin-left: 8px"
93 />
94 </div>
95
96 <div
97 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
98 >
99 {keys.length === 0 ? (
100 <div
101 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
102 >
103 No passkeys registered yet.
104 </div>
105 ) : (
106 keys.map((k) => (
107 <div
108 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
109 >
110 <div>
111 <strong>{k.name}</strong>
112 <div
113 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
114 >
115 added {new Date(k.createdAt).toLocaleDateString()}
116 {k.lastUsedAt &&
117 ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}`}
118 </div>
119 </div>
120 <div style="display: flex; gap: 6px">
121 <form
122 method="POST"
123 action={`/settings/passkeys/${k.id}/rename`}
124 style="display: flex; gap: 4px"
125 >
126 <input
127 type="text"
128 name="name"
129 defaultValue={k.name}
130 maxLength={60}
131 style="width: 160px"
132 />
133 <button type="submit" class="btn btn-sm">
134 save
135 </button>
136 </form>
137 <form
138 method="POST"
139 action={`/settings/passkeys/${k.id}/delete`}
140 onsubmit="return confirm('Remove this passkey?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 remove
144 </button>
145 </form>
146 </div>
147 </div>
148 ))
149 )}
150 </div>
151
152 <script
153 dangerouslySetInnerHTML={{
154 __html: /* js */ `
155 (function () {
156 const btn = document.getElementById('pk-add-btn');
157 const status = document.getElementById('pk-add-status');
158 if (!btn) return;
159 function b64uToBuf(s) {
160 s = s.replace(/-/g,'+').replace(/_/g,'/');
161 while (s.length % 4) s += '=';
162 const bin = atob(s);
163 const buf = new Uint8Array(bin.length);
164 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
165 return buf.buffer;
166 }
167 function bufToB64u(buf) {
168 const bytes = new Uint8Array(buf);
169 let bin = '';
170 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
171 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
172 }
173 btn.addEventListener('click', async function () {
174 if (!window.PublicKeyCredential) {
175 status.textContent = 'Passkeys not supported in this browser.';
176 return;
177 }
178 status.textContent = 'Preparing…';
179 try {
180 const optsRes = await fetch('/api/passkeys/register/options', {
181 method: 'POST',
182 headers: { 'content-type': 'application/json' },
183 body: '{}'
184 });
185 if (!optsRes.ok) throw new Error('options failed');
186 const { options, sessionKey } = await optsRes.json();
187 options.challenge = b64uToBuf(options.challenge);
188 options.user.id = b64uToBuf(options.user.id);
189 if (options.excludeCredentials) {
190 options.excludeCredentials = options.excludeCredentials.map(function (c) {
191 return Object.assign({}, c, { id: b64uToBuf(c.id) });
192 });
193 }
194 status.textContent = 'Touch your authenticator…';
195 const cred = await navigator.credentials.create({ publicKey: options });
196 const resp = {
197 id: cred.id,
198 rawId: bufToB64u(cred.rawId),
199 type: cred.type,
200 response: {
201 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
202 attestationObject: bufToB64u(cred.response.attestationObject),
203 transports: cred.response.getTransports ? cred.response.getTransports() : []
204 },
205 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
206 };
207 const verifyRes = await fetch('/api/passkeys/register/verify', {
208 method: 'POST',
209 headers: { 'content-type': 'application/json' },
210 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
211 });
212 if (!verifyRes.ok) {
213 const j = await verifyRes.json().catch(() => ({}));
214 throw new Error(j.error || 'verify failed');
215 }
216 status.textContent = 'Saved. Reloading…';
217 window.location.reload();
218 } catch (e) {
219 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
220 }
221 });
222 })();
223 `,
224 }}
225 />
226 </div>
227 </Layout>
228 );
229});
230
231passkeys.post("/settings/passkeys/:id/delete", async (c) => {
232 const user = c.get("user")!;
233 const id = c.req.param("id");
234 try {
235 const [row] = await db
236 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
237 .from(userPasskeys)
238 .where(eq(userPasskeys.id, id))
239 .limit(1);
240 if (!row || row.userId !== user.id) {
241 return c.redirect("/settings/passkeys?error=Not+found");
242 }
243 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
244 await audit({
245 userId: user.id,
246 action: "passkey.delete",
247 targetType: "passkey",
248 targetId: id,
249 });
250 return c.redirect("/settings/passkeys?success=Passkey+removed");
251 } catch (err) {
252 console.error("[passkeys] delete:", err);
253 return c.redirect("/settings/passkeys?error=Service+unavailable");
254 }
255});
256
257passkeys.post("/settings/passkeys/:id/rename", async (c) => {
258 const user = c.get("user")!;
259 const id = c.req.param("id");
260 const body = await c.req.parseBody();
261 const name = String(body.name || "").trim().slice(0, 60);
262 if (!name) {
263 return c.redirect("/settings/passkeys?error=Name+required");
264 }
265 try {
266 const [row] = await db
267 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
268 .from(userPasskeys)
269 .where(eq(userPasskeys.id, id))
270 .limit(1);
271 if (!row || row.userId !== user.id) {
272 return c.redirect("/settings/passkeys?error=Not+found");
273 }
274 await db
275 .update(userPasskeys)
276 .set({ name })
277 .where(eq(userPasskeys.id, id));
278 return c.redirect("/settings/passkeys?success=Renamed");
279 } catch (err) {
280 console.error("[passkeys] rename:", err);
281 return c.redirect("/settings/passkeys?error=Service+unavailable");
282 }
283});
284
285// --- Registration JSON endpoints (authed) -----------------------------------
286
287passkeys.post("/api/passkeys/register/options", async (c) => {
288 const user = c.get("user")!;
289 try {
290 const existing = await db
291 .select({ credentialId: userPasskeys.credentialId })
292 .from(userPasskeys)
293 .where(eq(userPasskeys.userId, user.id));
294 const { options, sessionKey } = await startRegistration({
295 userId: user.id,
296 userName: user.username,
297 userDisplayName: user.displayName || user.username,
298 excludeCredentialIds: existing.map((e) => e.credentialId),
299 });
300 return c.json({ options, sessionKey });
301 } catch (err) {
302 console.error("[passkeys] register/options:", err);
303 return c.json({ error: "Service unavailable" }, 503);
304 }
305});
306
307passkeys.post("/api/passkeys/register/verify", async (c) => {
308 const user = c.get("user")!;
309 let body: { sessionKey: string; response: any };
310 try {
311 body = await c.req.json();
312 } catch {
313 return c.json({ error: "Invalid JSON" }, 400);
314 }
315 if (!body.sessionKey || !body.response) {
316 return c.json({ error: "sessionKey and response required" }, 400);
317 }
318 const result = await finishRegistration({
319 sessionKey: body.sessionKey,
320 response: body.response,
321 });
322 if (!result.ok) return c.json({ error: result.error }, 400);
323
324 try {
325 const transports = Array.isArray(body.response?.response?.transports)
326 ? JSON.stringify(body.response.response.transports)
327 : null;
328 await db.insert(userPasskeys).values({
329 userId: user.id,
330 credentialId: result.credentialId,
331 publicKey: result.publicKey,
332 counter: result.counter,
333 transports,
334 });
335 await audit({
336 userId: user.id,
337 action: "passkey.create",
338 targetType: "passkey",
339 metadata: { credentialId: result.credentialId },
340 });
341 return c.json({ ok: true });
342 } catch (err: any) {
343 if (String(err?.message || err).includes("user_passkeys")) {
344 return c.json({ error: "Credential already registered" }, 409);
345 }
346 console.error("[passkeys] register save:", err);
347 return c.json({ error: "Service unavailable" }, 503);
348 }
349});
350
351// --- Authentication JSON endpoints (unauthed) -------------------------------
352
353passkeys.post("/api/passkeys/auth/options", async (c) => {
354 let body: { username?: string };
355 try {
356 body = await c.req.json();
357 } catch {
358 body = {};
359 }
360 try {
361 let userId: string | undefined;
362 let allowCreds: string[] = [];
363 if (body.username) {
364 const [u] = await db
365 .select({ id: users.id })
366 .from(users)
367 .where(eq(users.username, body.username.trim().toLowerCase()))
368 .limit(1);
369 if (u) {
370 userId = u.id;
371 const rows = await db
372 .select({ credentialId: userPasskeys.credentialId })
373 .from(userPasskeys)
374 .where(eq(userPasskeys.userId, u.id));
375 allowCreds = rows.map((r) => r.credentialId);
376 }
377 }
378 const { options, sessionKey } = await startAuthentication({
379 userId,
380 allowCredentialIds: allowCreds,
381 });
382 return c.json({ options, sessionKey });
383 } catch (err) {
384 console.error("[passkeys] auth/options:", err);
385 return c.json({ error: "Service unavailable" }, 503);
386 }
387});
388
389passkeys.post("/api/passkeys/auth/verify", async (c) => {
390 let body: { sessionKey: string; response: any };
391 try {
392 body = await c.req.json();
393 } catch {
394 return c.json({ error: "Invalid JSON" }, 400);
395 }
396 if (!body.sessionKey || !body.response) {
397 return c.json({ error: "sessionKey and response required" }, 400);
398 }
399 const result = await finishAuthentication({
400 sessionKey: body.sessionKey,
401 response: body.response,
402 });
403 if (!result.ok) return c.json({ error: result.error }, 400);
404
405 try {
406 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
407 const token = generateSessionToken();
408 await db.insert(sessions).values({
409 userId: result.userId,
410 token,
411 expiresAt: sessionExpiry(),
412 requires2fa: false,
413 });
414 setCookie(c, "session", token, sessionCookieOptions());
415 await audit({
416 userId: result.userId,
417 action: "passkey.login",
418 targetType: "passkey",
419 metadata: { credentialId: result.credentialId },
420 });
421 return c.json({ ok: true });
422 } catch (err) {
423 console.error("[passkeys] auth/verify:", err);
424 return c.json({ error: "Service unavailable" }, 503);
425 }
426});
427
428export default passkeys;
Addedsrc/routes/projects.tsx+601−0View fileUnifiedSplit
1/**
2 * Block E1 — Projects / Kanban boards scoped to a repo.
3 *
4 * Each project has ordered columns ("To Do" / "In Progress" / "Done" by
5 * default) and items (notes or linked issues/PRs). Items belong to exactly
6 * one column at a time. Simple v1: positions are recomputed via "max+1".
7 *
8 * Never throws — all DB paths wrapped in try/catch.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, asc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 projects,
16 projectColumns,
17 projectItems,
18 repositories,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
27
28const projectRoutes = new Hono<AuthEnv>();
29
30async function resolveRepo(ownerName: string, repoName: string) {
31 try {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(
43 eq(repositories.ownerId, owner.id),
44 eq(repositories.name, repoName)
45 )
46 )
47 .limit(1);
48 if (!repo) return null;
49 return { owner, repo };
50 } catch {
51 return null;
52 }
53}
54
55function notFound(user: any, label = "Not found") {
56 return (
57 <Layout title={label} user={user}>
58 <div class="empty-state">
59 <h2>{label}</h2>
60 </div>
61 </Layout>
62 );
63}
64
65// List
66projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
71 const { repo } = resolved;
72
73 let rows: any[] = [];
74 try {
75 rows = await db
76 .select({
77 p: projects,
78 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
79 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
80 })
81 .from(projects)
82 .where(eq(projects.repositoryId, repo.id))
83 .orderBy(desc(projects.updatedAt));
84 } catch {
85 rows = [];
86 }
87
88 return c.html(
89 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
90 <RepoHeader owner={ownerName} repo={repoName} />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
92 <h2 style="margin: 0;">Projects</h2>
93 {user && (
94 <a
95 href={`/${ownerName}/${repoName}/projects/new`}
96 class="btn btn-primary"
97 >
98 New project
99 </a>
100 )}
101 </div>
102 {rows.length === 0 ? (
103 <div class="empty-state">
104 <p>No projects yet.</p>
105 </div>
106 ) : (
107 <table class="file-table">
108 <tbody>
109 {rows.map((r) => (
110 <tr>
111 <td style="width: 40px; color: var(--text-muted);">
112 #{r.p.number}
113 </td>
114 <td>
115 <a
116 href={`/${ownerName}/${repoName}/projects/${r.p.number}`}
117 >
118 <strong>{r.p.title}</strong>
119 </a>
120 {r.p.state === "closed" && <span class="badge">closed</span>}
121 {r.p.description && (
122 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
123 {r.p.description}
124 </div>
125 )}
126 </td>
127 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
128 {r.columnCount} cols · {r.itemCount} items
129 </td>
130 </tr>
131 ))}
132 </tbody>
133 </table>
134 )}
135 </Layout>
136 );
137});
138
139// New form
140projectRoutes.get(
141 "/:owner/:repo/projects/new",
142 requireAuth,
143 async (c) => {
144 const { owner: ownerName, repo: repoName } = c.req.param();
145 const user = c.get("user");
146 const resolved = await resolveRepo(ownerName, repoName);
147 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
148 return c.html(
149 <Layout title="New project" user={user}>
150 <RepoHeader owner={ownerName} repo={repoName} />
151 <h2 style="margin-top: 20px;">Create a project</h2>
152 <form
153 method="POST"
154 action={`/${ownerName}/${repoName}/projects`}
155 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
156 >
157 <input
158 type="text"
159 name="title"
160 placeholder="Title"
161 required
162 style="padding: 8px;"
163 />
164 <textarea
165 name="description"
166 rows={4}
167 placeholder="Description (optional)"
168 style="padding: 8px; font-family: inherit;"
169 ></textarea>
170 <button type="submit" class="btn btn-primary">
171 Create
172 </button>
173 </form>
174 </Layout>
175 );
176 }
177);
178
179// Create
180projectRoutes.post(
181 "/:owner/:repo/projects",
182 requireAuth,
183 async (c) => {
184 const { owner: ownerName, repo: repoName } = c.req.param();
185 const user = c.get("user")!;
186 const resolved = await resolveRepo(ownerName, repoName);
187 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
188
189 const form = await c.req.formData();
190 const title = (form.get("title") as string || "").trim();
191 const description = (form.get("description") as string || "").trim();
192
193 if (!title) {
194 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
195 }
196
197 try {
198 const [row] = await db
199 .insert(projects)
200 .values({
201 repositoryId: resolved.repo.id,
202 ownerId: user.id,
203 title,
204 description,
205 })
206 .returning({ id: projects.id, number: projects.number });
207 // Seed default columns
208 await db.insert(projectColumns).values(
209 DEFAULT_COLUMNS.map((name, i) => ({
210 projectId: row.id,
211 name,
212 position: i,
213 }))
214 );
215 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
216 } catch {
217 return c.redirect(`/${ownerName}/${repoName}/projects`);
218 }
219 }
220);
221
222// Board view
223projectRoutes.get(
224 "/:owner/:repo/projects/:number",
225 softAuth,
226 async (c) => {
227 const { owner: ownerName, repo: repoName } = c.req.param();
228 const user = c.get("user");
229 const numParam = Number(c.req.param("number"));
230 const resolved = await resolveRepo(ownerName, repoName);
231 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
232
233 let project: any = null;
234 let columns: any[] = [];
235 let items: any[] = [];
236 try {
237 const [row] = await db
238 .select()
239 .from(projects)
240 .where(
241 and(
242 eq(projects.repositoryId, resolved.repo.id),
243 eq(projects.number, numParam)
244 )
245 )
246 .limit(1);
247 if (row) {
248 project = row;
249 columns = await db
250 .select()
251 .from(projectColumns)
252 .where(eq(projectColumns.projectId, row.id))
253 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
254 items = await db
255 .select()
256 .from(projectItems)
257 .where(eq(projectItems.projectId, row.id))
258 .orderBy(asc(projectItems.position));
259 }
260 } catch {
261 // leave nulls
262 }
263
264 if (!project) return c.html(notFound(user, "Project not found"), 404);
265
266 const isOwner = user && user.id === resolved.repo.ownerId;
267 const itemsByCol: Record<string, any[]> = {};
268 for (const col of columns) itemsByCol[col.id] = [];
269 for (const it of items) {
270 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
271 }
272
273 return c.html(
274 <Layout
275 title={`${project.title} — project #${project.number}`}
276 user={user}
277 >
278 <RepoHeader owner={ownerName} repo={repoName} />
279 <style>{`
280 .kanban { display: flex; gap: 16px; overflow-x: auto; padding: 16px 0; }
281 .kcol { background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; min-width: 260px; flex-shrink: 0; padding: 12px; }
282 .kcol h4 { margin: 0 0 12px; display: flex; justify-content: space-between; }
283 .kcard { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; margin-bottom: 8px; font-size: 13px; }
284 .kcard form { display: inline; }
285 `}</style>
286 <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
287 <h1 style="margin: 0;">
288 {project.title}{" "}
289 <span style="color: var(--text-muted);">#{project.number}</span>
290 {project.state === "closed" && <span class="badge">closed</span>}
291 </h1>
292 {user && (
293 <form
294 method="POST"
295 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
296 style="display: inline;"
297 >
298 <button type="submit" class="btn">
299 {project.state === "open" ? "Close" : "Reopen"}
300 </button>
301 </form>
302 )}
303 </div>
304 {project.description && (
305 <div style="color: var(--text-muted); margin-top: 4px;">
306 {project.description}
307 </div>
308 )}
309 <div class="kanban">
310 {columns.map((col) => (
311 <div class="kcol">
312 <h4>
313 <span>{col.name}</span>
314 <span style="color: var(--text-muted); font-size: 13px;">
315 {(itemsByCol[col.id] || []).length}
316 </span>
317 </h4>
318 {(itemsByCol[col.id] || []).map((it) => (
319 <div class="kcard">
320 <div>
321 <strong>{it.title || "(untitled)"}</strong>
322 </div>
323 {it.note && (
324 <div style="color: var(--text-muted); margin-top: 4px;">
325 {it.note}
326 </div>
327 )}
328 {user && (
329 <div style="margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap;">
330 {columns
331 .filter((oc) => oc.id !== col.id)
332 .map((oc) => (
333 <form
334 method="POST"
335 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
336 >
337 <input
338 type="hidden"
339 name="column_id"
340 value={oc.id}
341 />
342 <button
343 type="submit"
344 class="btn"
345 style="font-size: 11px; padding: 2px 6px;"
346 >
347 → {oc.name}
348 </button>
349 </form>
350 ))}
351 <form
352 method="POST"
353 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
354 >
355 <button
356 type="submit"
357 class="btn"
358 style="font-size: 11px; padding: 2px 6px;"
359 >
360 ×
361 </button>
362 </form>
363 </div>
364 )}
365 </div>
366 ))}
367 {user && (
368 <form
369 method="POST"
370 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
371 style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;"
372 >
373 <input type="hidden" name="column_id" value={col.id} />
374 <input
375 type="text"
376 name="title"
377 placeholder="New card title"
378 required
379 style="padding: 4px; font-size: 12px;"
380 />
381 <button
382 type="submit"
383 class="btn"
384 style="font-size: 12px; padding: 4px;"
385 >
386 + Add card
387 </button>
388 </form>
389 )}
390 </div>
391 ))}
392 {user && (
393 <div class="kcol" style="background: transparent; border-style: dashed;">
394 <form
395 method="POST"
396 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
397 style="display: flex; flex-direction: column; gap: 8px;"
398 >
399 <input
400 type="text"
401 name="name"
402 placeholder="New column"
403 required
404 style="padding: 6px;"
405 />
406 <button type="submit" class="btn">
407 + Add column
408 </button>
409 </form>
410 </div>
411 )}
412 </div>
413 </Layout>
414 );
415 }
416);
417
418// Add column
419projectRoutes.post(
420 "/:owner/:repo/projects/:number/columns",
421 requireAuth,
422 async (c) => {
423 const { owner: ownerName, repo: repoName } = c.req.param();
424 const numParam = Number(c.req.param("number"));
425 const resolved = await resolveRepo(ownerName, repoName);
426 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
427
428 const form = await c.req.formData();
429 const name = (form.get("name") as string || "").trim();
430 if (!name) {
431 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
432 }
433
434 try {
435 const [row] = await db
436 .select()
437 .from(projects)
438 .where(
439 and(
440 eq(projects.repositoryId, resolved.repo.id),
441 eq(projects.number, numParam)
442 )
443 )
444 .limit(1);
445 if (row) {
446 const [maxPos] = await db
447 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
448 .from(projectColumns)
449 .where(eq(projectColumns.projectId, row.id));
450 await db.insert(projectColumns).values({
451 projectId: row.id,
452 name,
453 position: Number(maxPos?.p || -1) + 1,
454 });
455 }
456 } catch {
457 // swallow
458 }
459 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
460 }
461);
462
463// Add item
464projectRoutes.post(
465 "/:owner/:repo/projects/:number/items",
466 requireAuth,
467 async (c) => {
468 const { owner: ownerName, repo: repoName } = c.req.param();
469 const numParam = Number(c.req.param("number"));
470 const resolved = await resolveRepo(ownerName, repoName);
471 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
472
473 const form = await c.req.formData();
474 const columnId = (form.get("column_id") as string || "").trim();
475 const title = (form.get("title") as string || "").trim();
476 const note = (form.get("note") as string || "").trim();
477 if (!columnId || !title) {
478 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
479 }
480
481 try {
482 const [row] = await db
483 .select()
484 .from(projects)
485 .where(
486 and(
487 eq(projects.repositoryId, resolved.repo.id),
488 eq(projects.number, numParam)
489 )
490 )
491 .limit(1);
492 if (row) {
493 const [maxPos] = await db
494 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
495 .from(projectItems)
496 .where(eq(projectItems.columnId, columnId));
497 await db.insert(projectItems).values({
498 projectId: row.id,
499 columnId,
500 title,
501 note,
502 position: Number(maxPos?.p || -1) + 1,
503 });
504 }
505 } catch {
506 // swallow
507 }
508 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
509 }
510);
511
512// Move item
513projectRoutes.post(
514 "/:owner/:repo/projects/:number/items/:itemId/move",
515 requireAuth,
516 async (c) => {
517 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
518 const numParam = Number(c.req.param("number"));
519 const resolved = await resolveRepo(ownerName, repoName);
520 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
521
522 const form = await c.req.formData();
523 const columnId = (form.get("column_id") as string || "").trim();
524 if (!columnId) {
525 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
526 }
527
528 try {
529 const [maxPos] = await db
530 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
531 .from(projectItems)
532 .where(eq(projectItems.columnId, columnId));
533 await db
534 .update(projectItems)
535 .set({
536 columnId,
537 position: Number(maxPos?.p || -1) + 1,
538 updatedAt: new Date(),
539 })
540 .where(eq(projectItems.id, itemId));
541 } catch {
542 // swallow
543 }
544 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
545 }
546);
547
548// Delete item
549projectRoutes.post(
550 "/:owner/:repo/projects/:number/items/:itemId/delete",
551 requireAuth,
552 async (c) => {
553 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
554 const numParam = Number(c.req.param("number"));
555 try {
556 await db.delete(projectItems).where(eq(projectItems.id, itemId));
557 } catch {
558 // swallow
559 }
560 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
561 }
562);
563
564// Toggle close
565projectRoutes.post(
566 "/:owner/:repo/projects/:number/close",
567 requireAuth,
568 async (c) => {
569 const { owner: ownerName, repo: repoName } = c.req.param();
570 const numParam = Number(c.req.param("number"));
571 const resolved = await resolveRepo(ownerName, repoName);
572 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
573
574 try {
575 const [row] = await db
576 .select()
577 .from(projects)
578 .where(
579 and(
580 eq(projects.repositoryId, resolved.repo.id),
581 eq(projects.number, numParam)
582 )
583 )
584 .limit(1);
585 if (row) {
586 await db
587 .update(projects)
588 .set({
589 state: row.state === "open" ? "closed" : "open",
590 updatedAt: new Date(),
591 })
592 .where(eq(projects.id, row.id));
593 }
594 } catch {
595 // swallow
596 }
597 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
598 }
599);
600
601export default projectRoutes;
Addedsrc/routes/protected-tags.tsx+218−0View fileUnifiedSplit
1/**
2 * Block E7 — Protected tags settings UI.
3 *
4 * GET /:owner/:repo/settings/protected-tags — CRUD list
5 * POST /:owner/:repo/settings/protected-tags — create
6 * POST /:owner/:repo/settings/protected-tags/:id/delete — remove
7 */
8
9import { Hono } from "hono";
10import { and, eq } from "drizzle-orm";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { Layout } from "../views/layout";
14import { RepoHeader, RepoNav } from "../views/components";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
17import {
18 addProtectedTag,
19 listProtectedTags,
20 removeProtectedTag,
21} from "../lib/protected-tags";
22import { audit } from "../lib/notify";
23
24const protectedTagsRoutes = new Hono<AuthEnv>();
25protectedTagsRoutes.use("*", softAuth);
26
27async function loadRepo(ownerName: string, repoName: string) {
28 try {
29 const [row] = await db
30 .select({
31 id: repositories.id,
32 name: repositories.name,
33 ownerId: repositories.ownerId,
34 starCount: repositories.starCount,
35 forkCount: repositories.forkCount,
36 })
37 .from(repositories)
38 .innerJoin(users, eq(repositories.ownerId, users.id))
39 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
40 .limit(1);
41 return row || null;
42 } catch {
43 return null;
44 }
45}
46
47protectedTagsRoutes.get(
48 "/:owner/:repo/settings/protected-tags",
49 requireAuth,
50 async (c) => {
51 const user = c.get("user")!;
52 const { owner, repo } = c.req.param();
53 const repoRow = await loadRepo(owner, repo);
54 if (!repoRow) return c.notFound();
55 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
56
57 const tags = await listProtectedTags(repoRow.id);
58 const success = c.req.query("success");
59 const error = c.req.query("error");
60
61 return c.html(
62 <Layout title={`Protected tags — ${owner}/${repo}`} user={user}>
63 <RepoHeader
64 owner={owner}
65 repo={repo}
66 starCount={repoRow.starCount}
67 forkCount={repoRow.forkCount}
68 currentUser={user.username}
69 />
70 <RepoNav owner={owner} repo={repo} active="gates" />
71
72 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
73 <h3>Protected tags</h3>
74 <a href={`/${owner}/${repo}/settings`} class="btn btn-sm">
75 Back to settings
76 </a>
77 </div>
78
79 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
80 Mark tag patterns as protected. Only repo owners can create, update,
81 or delete tags matching one of these patterns. Supports globs:
82 <code>v*</code>, <code>release-*</code>, <code>**</code>.
83 </p>
84
85 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
86 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
87
88 <div class="panel" style="margin-bottom:16px">
89 {tags.length === 0 ? (
90 <div class="panel-empty">No protected tag patterns.</div>
91 ) : (
92 tags.map((t) => (
93 <div class="panel-item" style="justify-content:space-between">
94 <div>
95 <code
96 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
97 >
98 {t.pattern}
99 </code>
100 <div
101 class="meta"
102 style="margin-top:4px;font-size:12px;color:var(--text-muted)"
103 >
104 Added{" "}
105 {t.createdAt
106 ? new Date(t.createdAt as unknown as string).toLocaleDateString()
107 : ""}
108 </div>
109 </div>
110 <form
111 method="POST"
112 action={`/${owner}/${repo}/settings/protected-tags/${t.id}/delete`}
113 onsubmit="return confirm('Remove protection for this pattern?')"
114 >
115 <button type="submit" class="btn btn-sm btn-danger">
116 Remove
117 </button>
118 </form>
119 </div>
120 ))
121 )}
122 </div>
123
124 <form
125 method="POST"
126 action={`/${owner}/${repo}/settings/protected-tags`}
127 class="panel"
128 style="padding:16px"
129 >
130 <div class="form-group">
131 <label>Pattern</label>
132 <input
133 type="text"
134 name="pattern"
135 required
136 placeholder="v* or release-*"
137 style="font-family:var(--font-mono)"
138 />
139 </div>
140 <button type="submit" class="btn btn-primary">
141 Protect pattern
142 </button>
143 </form>
144 </Layout>
145 );
146 }
147);
148
149protectedTagsRoutes.post(
150 "/:owner/:repo/settings/protected-tags",
151 requireAuth,
152 async (c) => {
153 const user = c.get("user")!;
154 const { owner, repo } = c.req.param();
155 const repoRow = await loadRepo(owner, repo);
156 if (!repoRow) return c.notFound();
157 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
158
159 const body = await c.req.parseBody();
160 const pattern = String(body.pattern || "").trim();
161 if (!pattern) {
162 return c.redirect(
163 `/${owner}/${repo}/settings/protected-tags?error=${encodeURIComponent("Pattern required")}`
164 );
165 }
166
167 const created = await addProtectedTag({
168 repositoryId: repoRow.id,
169 pattern,
170 createdBy: user.id,
171 });
172
173 if (created) {
174 await audit({
175 userId: user.id,
176 repositoryId: repoRow.id,
177 action: "protected_tags.create",
178 metadata: { pattern },
179 });
180 }
181
182 return c.redirect(
183 `/${owner}/${repo}/settings/protected-tags?success=${encodeURIComponent(
184 created ? `Pattern '${pattern}' protected` : "Could not save pattern"
185 )}`
186 );
187 }
188);
189
190protectedTagsRoutes.post(
191 "/:owner/:repo/settings/protected-tags/:id/delete",
192 requireAuth,
193 async (c) => {
194 const user = c.get("user")!;
195 const { owner, repo, id } = c.req.param();
196 const repoRow = await loadRepo(owner, repo);
197 if (!repoRow) return c.notFound();
198 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}`);
199
200 const ok = await removeProtectedTag(repoRow.id, id);
201 if (ok) {
202 await audit({
203 userId: user.id,
204 repositoryId: repoRow.id,
205 action: "protected_tags.delete",
206 targetId: id,
207 });
208 }
209
210 return c.redirect(
211 `/${owner}/${repo}/settings/protected-tags?success=${encodeURIComponent(
212 ok ? "Pattern removed" : "Nothing removed"
213 )}`
214 );
215 }
216);
217
218export default protectedTagsRoutes;
Modifiedsrc/routes/pulls.tsx+356−33View fileUnifiedSplit
1010 prComments,
1111 repositories,
1212 users,
13 issues,
14 issueComments,
1315} from "../db/schema";
1416import { Layout } from "../views/layout";
1517import { RepoHeader, DiffView } from "../views/components";
18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
20import { loadPrTemplate } from "../lib/templates";
1621import { renderMarkdown } from "../lib/markdown";
1722import { softAuth, requireAuth } from "../middleware/auth";
1823import type { AuthEnv } from "../middleware/auth";
1924import {
2025 listBranches,
2126 getRepoPath,
27 resolveRef,
2228} from "../git/repository";
2329import type { GitDiffFile } from "../git/repository";
2430import { html } from "hono/html";
94100 const resolved = await resolveRepo(ownerName, repoName);
95101 if (!resolved) return c.notFound();
96102
103 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
104 const stateFilter =
105 state === "draft"
106 ? and(
107 eq(pullRequests.state, "open"),
108 eq(pullRequests.isDraft, true)
109 )
110 : eq(pullRequests.state, state);
111
97112 const prList = await db
98113 .select({
99114 pr: pullRequests,
102117 .from(pullRequests)
103118 .innerJoin(users, eq(pullRequests.authorId, users.id))
104119 .where(
105 and(
106 eq(pullRequests.repositoryId, resolved.repo.id),
107 eq(pullRequests.state, state)
108 )
120 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
109121 )
110122 .orderBy(desc(pullRequests.createdAt));
111123
112124 const [counts] = await db
113125 .select({
114126 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
127 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
115128 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
116129 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
117130 })
185198 const branches = await listBranches(ownerName, repoName);
186199 const error = c.req.query("error");
187200 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
201 const template = await loadPrTemplate(ownerName, repoName);
188202
189203 return c.html(
190204 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
269283 const resolved = await resolveRepo(ownerName, repoName);
270284 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
271285
286 const isDraft = String(body.draft || "") === "1";
287
272288 const [pr] = await db
273289 .insert(pullRequests)
274290 .values({
278294 body: prBody || null,
279295 baseBranch,
280296 headBranch,
297 isDraft,
281298 })
282299 .returning();
283300
301 // Skip AI review on drafts — it runs again when the PR is marked ready.
302 if (!isDraft && isAiReviewEnabled()) {
303 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
304 (err) => console.error("[ai-review] Failed:", err)
305 );
306 }
307
308 // D3 — fire-and-forget AI triage: suggest labels/reviewers on the PR.
309 triggerPrTriage({
310 ownerName,
311 repoName,
312 repositoryId: resolved.repo.id,
313 prId: pr.id,
314 prAuthorId: user.id,
315 title,
316 body: prBody,
317 baseBranch,
318 headBranch,
319 }).catch((err) => console.error("[pr-triage] Failed:", err));
320
284321 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
285322 }
286323);
324361 .where(eq(prComments.pullRequestId, pr.id))
325362 .orderBy(asc(prComments.createdAt));
326363
364 // Reactions for the PR body + each comment, in parallel.
365 const [prReactions, ...prCommentReactions] = await Promise.all([
366 summariseReactions("pr", pr.id, user?.id),
367 ...comments.map((row) =>
368 summariseReactions("pr_comment", row.comment.id, user?.id)
369 ),
370 ]);
371
327372 const canManage =
328373 user &&
329374 (user.id === resolved.owner.id || user.id === pr.authorId);
330375
376 const error = c.req.query("error");
377
378 // Get gate check status for open PRs
379 let gateChecks: GateCheckResult[] = [];
380 if (pr.state === "open") {
381 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
382 if (headSha) {
383 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
384 const aiApproved = aiComments.length === 0 || aiComments.some(
385 ({ comment }) => comment.body.includes("**Approved**")
386 );
387 const gateResult = await runAllGateChecks(
388 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
389 );
390 gateChecks = gateResult.checks;
391 }
392 }
393
331394 // Get diff for "Files changed" tab
332395 let diffRaw = "";
333396 let diffFiles: GitDiffFile[] = [];
423486 />
424487 )}
425488
426 {comments.map(({ comment, author: commentAuthor }) => (
489 {comments.map(({ comment, author: commentAuthor }, i) => (
427490 <div
428491 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
429492 >
450513 </div>
451514 ))}
452515
516 {error && (
517 <div class="auth-error" style="margin-top: 16px; padding: 12px; background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); color: var(--red)">
518 {decodeURIComponent(error)}
519 </div>
520 )}
521
522 {pr.state === "open" && gateChecks.length > 0 && (
523 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
524 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
525 {gateChecks.map((check) => (
526 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
527 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
528 {check.passed ? "\u2713" : "\u2717"}
529 </span>
530 <strong style="font-size: 13px">{check.name}</strong>
531 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
532 </div>
533 ))}
534 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
535 {gateChecks.every((c) => c.passed)
536 ? "All checks passed — ready to merge"
537 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
538 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
539 : "Some checks failed — resolve issues before merging"}
540 </div>
541 </div>
542 )}
543
453544 {user && pr.state === "open" && (
454545 <div style="margin-top:20px">
455546 <Form
541632 }
542633);
543634
544// Merge PR
635// Merge PR — with green gate enforcement and auto conflict resolution
545636pulls.post(
546637 "/:owner/:repo/pulls/:number/merge",
547638 softAuth,
569660 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
570661 }
571662
572 // Perform git merge
573 const repoDir = getRepoPath(ownerName, repoName);
574 const mergeProc = Bun.spawn(
575 [
576 "git",
577 "merge-base",
578 "--is-ancestor",
579 pr.baseBranch,
580 pr.headBranch,
581 ],
582 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
663 // Draft PRs cannot be merged — must be marked ready first.
664 if (pr.isDraft) {
665 return c.redirect(
666 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
667 "This PR is a draft. Mark it as ready for review before merging."
668 )}`
669 );
670 }
671
672 // Resolve head SHA
673 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
674 if (!headSha) {
675 return c.redirect(
676 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
677 );
678 }
679
680 // Check if AI review approved this PR
681 const aiComments = await db
682 .select()
683 .from(prComments)
684 .where(
685 and(
686 eq(prComments.pullRequestId, pr.id),
687 eq(prComments.isAiReview, true)
688 )
689 );
690 const aiApproved = aiComments.length === 0 || aiComments.some(
691 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
583692 );
584 await mergeProc.exited;
585
586 // Use git update-ref for fast-forward or create merge commit
587 const ffProc = Bun.spawn(
588 [
589 "git",
590 "update-ref",
591 `refs/heads/${pr.baseBranch}`,
592 `refs/heads/${pr.headBranch}`,
593 ],
594 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
693
694 // Run all green gate checks (GateTest + mergeability + AI review)
695 const gateResult = await runAllGateChecks(
696 ownerName,
697 repoName,
698 pr.baseBranch,
699 pr.headBranch,
700 headSha,
701 aiApproved
595702 );
596 const ffExit = await ffProc.exited;
597703
598 if (ffExit !== 0) {
599 // Fallback: try creating a merge commit via a temporary checkout
600 // For now, just report the error
704 // If GateTest or AI review failed (hard blocks), reject the merge
705 const hardFailures = gateResult.checks.filter(
706 (check) => !check.passed && check.name !== "Merge check"
707 );
708 if (hardFailures.length > 0) {
709 const errorMsg = hardFailures
710 .map((f) => `${f.name}: ${f.details}`)
711 .join("; ");
601712 return c.redirect(
602 `/${ownerName}/${repoName}/pulls/${prNum}?error=merge_conflict`
713 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
603714 );
604715 }
605716
717 // D5 — Branch-protection enforcement. Looks up the matching rule for the
718 // base branch and blocks the merge if requireAiApproval / requireGreenGates
719 // / requireHumanReview / requiredApprovals are not satisfied. Independent
720 // of repo-global settings, so owners can lock specific branches down
721 // further than the repo default.
722 const protectionRule = await matchProtection(
723 resolved.repo.id,
724 pr.baseBranch
725 );
726 if (protectionRule) {
727 const humanApprovals = await countHumanApprovals(pr.id);
728 const required = await listRequiredChecks(protectionRule.id);
729 const passingNames = required.length > 0
730 ? await passingCheckNames(resolved.repo.id, headSha)
731 : [];
732 const decision = evaluateProtection(
733 protectionRule,
734 {
735 aiApproved,
736 humanApprovalCount: humanApprovals,
737 gateResultGreen: hardFailures.length === 0,
738 hasFailedGates: hardFailures.length > 0,
739 passingCheckNames: passingNames,
740 },
741 required.map((r) => r.checkName)
742 );
743 if (!decision.allowed) {
744 return c.redirect(
745 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
746 decision.reasons.join(" ")
747 )}`
748 );
749 }
750 }
751
752 // Attempt the merge — with auto conflict resolution if needed
753 const repoDir = getRepoPath(ownerName, repoName);
754 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
755 const hasConflicts = mergeCheck && !mergeCheck.passed;
756
757 if (hasConflicts && isAiReviewEnabled()) {
758 // Use Claude to auto-resolve conflicts
759 const mergeResult = await mergeWithAutoResolve(
760 ownerName,
761 repoName,
762 pr.baseBranch,
763 pr.headBranch,
764 `Merge pull request #${pr.number}: ${pr.title}`
765 );
766
767 if (!mergeResult.success) {
768 return c.redirect(
769 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
770 );
771 }
772
773 // Post a comment about the auto-resolution
774 if (mergeResult.resolvedFiles.length > 0) {
775 await db.insert(prComments).values({
776 pullRequestId: pr.id,
777 authorId: user.id,
778 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
779 isAiReview: true,
780 });
781 }
782 } else {
783 // Standard merge — fast-forward or clean merge
784 const ffProc = Bun.spawn(
785 [
786 "git",
787 "update-ref",
788 `refs/heads/${pr.baseBranch}`,
789 `refs/heads/${pr.headBranch}`,
790 ],
791 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
792 );
793 const ffExit = await ffProc.exited;
794
795 if (ffExit !== 0) {
796 return c.redirect(
797 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
798 );
799 }
800 }
801
606802 await db
607803 .update(pullRequests)
608804 .set({
613809 })
614810 .where(eq(pullRequests.id, pr.id));
615811
812 // J7 — closing keywords. Scan PR title + body for "closes #N" style refs
813 // and auto-close each matching open issue with a back-link comment. Bounded
814 // to the same repo for v1 (cross-repo refs ignored). Failures never block
815 // the merge redirect.
816 try {
817 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
818 const refs = extractClosingRefsMulti([pr.title, pr.body]);
819 for (const n of refs) {
820 const [issue] = await db
821 .select()
822 .from(issues)
823 .where(
824 and(
825 eq(issues.repositoryId, resolved.repo.id),
826 eq(issues.number, n)
827 )
828 )
829 .limit(1);
830 if (!issue || issue.state !== "open") continue;
831 await db
832 .update(issues)
833 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
834 .where(eq(issues.id, issue.id));
835 await db.insert(issueComments).values({
836 issueId: issue.id,
837 authorId: user.id,
838 body: `Closed by pull request #${pr.number}.`,
839 });
840 }
841 } catch {
842 // Never block the merge on close-keyword failures.
843 }
844
845 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
846 }
847);
848
849// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
850// hasn't run yet on this PR.
851pulls.post(
852 "/:owner/:repo/pulls/:number/ready",
853 softAuth,
854 requireAuth,
855 async (c) => {
856 const { owner: ownerName, repo: repoName } = c.req.param();
857 const prNum = parseInt(c.req.param("number"), 10);
858 const user = c.get("user")!;
859
860 const resolved = await resolveRepo(ownerName, repoName);
861 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
862
863 const [pr] = await db
864 .select()
865 .from(pullRequests)
866 .where(
867 and(
868 eq(pullRequests.repositoryId, resolved.repo.id),
869 eq(pullRequests.number, prNum)
870 )
871 )
872 .limit(1);
873 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
874
875 // Only the author or repo owner can toggle draft state.
876 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
877 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
878 }
879
880 if (pr.state === "open" && pr.isDraft) {
881 await db
882 .update(pullRequests)
883 .set({ isDraft: false, updatedAt: new Date() })
884 .where(eq(pullRequests.id, pr.id));
885
886 if (isAiReviewEnabled()) {
887 triggerAiReview(
888 ownerName,
889 repoName,
890 pr.id,
891 pr.title,
892 pr.body,
893 pr.baseBranch,
894 pr.headBranch
895 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
896 }
897 }
898
899 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
900 }
901);
902
903// Convert a PR back to draft.
904pulls.post(
905 "/:owner/:repo/pulls/:number/draft",
906 softAuth,
907 requireAuth,
908 async (c) => {
909 const { owner: ownerName, repo: repoName } = c.req.param();
910 const prNum = parseInt(c.req.param("number"), 10);
911 const user = c.get("user")!;
912
913 const resolved = await resolveRepo(ownerName, repoName);
914 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
915
916 const [pr] = await db
917 .select()
918 .from(pullRequests)
919 .where(
920 and(
921 eq(pullRequests.repositoryId, resolved.repo.id),
922 eq(pullRequests.number, prNum)
923 )
924 )
925 .limit(1);
926 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
927
928 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
929 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
930 }
931
932 if (pr.state === "open" && !pr.isDraft) {
933 await db
934 .update(pullRequests)
935 .set({ isDraft: true, updatedAt: new Date() })
936 .where(eq(pullRequests.id, pr.id));
937 }
938
616939 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
617940 }
618941);
Addedsrc/routes/pwa.ts+134−0View fileUnifiedSplit
1/**
2 * Block G1 — PWA (progressive web app) support.
3 *
4 * GET /manifest.webmanifest — app manifest (install prompt)
5 * GET /sw.js — service worker (cache-first for static, network-first for HTML)
6 * GET /icon.svg — monochrome logo used by the manifest
7 *
8 * The service worker deliberately keeps the cache small (static CSS-in-JS is
9 * inlined so there's nothing to cache beyond the manifest + icon). HTML pages
10 * fall through to the network; cached copies only serve offline fallback.
11 *
12 * Adding `<link rel="manifest" href="/manifest.webmanifest">` + a tiny SW
13 * registration snippet to `Layout` turns any repo page into an installable
14 * PWA on Chrome/Safari.
15 */
16
17import { Hono } from "hono";
18
19const pwa = new Hono();
20
21export const MANIFEST = {
22 name: "Gluecron",
23 short_name: "Gluecron",
24 description: "AI-native code intelligence + git hosting",
25 start_url: "/",
26 scope: "/",
27 display: "standalone",
28 background_color: "#0d1117",
29 theme_color: "#0d1117",
30 icons: [
31 {
32 src: "/icon.svg",
33 sizes: "any",
34 type: "image/svg+xml",
35 purpose: "any maskable",
36 },
37 ],
38 categories: ["developer", "productivity"],
39} as const;
40
41pwa.get("/manifest.webmanifest", (c) => {
42 c.header("content-type", "application/manifest+json");
43 c.header("cache-control", "public, max-age=3600");
44 return c.body(JSON.stringify(MANIFEST));
45});
46
47const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
48 <rect width="128" height="128" rx="24" fill="#0d1117"/>
49 <g fill="#58a6ff" font-family="monospace" font-size="58" font-weight="700" text-anchor="middle">
50 <text x="64" y="82">gc</text>
51 </g>
52 <circle cx="28" cy="28" r="5" fill="#3fb950"/>
53</svg>`;
54
55pwa.get("/icon.svg", (c) => {
56 c.header("content-type", "image/svg+xml");
57 c.header("cache-control", "public, max-age=86400, immutable");
58 return c.body(ICON_SVG);
59});
60
61/**
62 * Bare-bones service worker. Offline behaviour:
63 * - HTML → network first, cached response on failure, fallback offline page
64 * - other → pass-through (the static CSS is inlined into the HTML, so there's
65 * no cross-request asset worth caching for v1)
66 */
67export const SERVICE_WORKER_SRC = `// gluecron service worker — v1
68const CACHE = 'gluecron-shell-v1';
69const SHELL = ['/', '/manifest.webmanifest', '/icon.svg'];
70
71self.addEventListener('install', (e) => {
72 e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).catch(() => {}));
73 self.skipWaiting();
74});
75
76self.addEventListener('activate', (e) => {
77 e.waitUntil(
78 caches.keys().then((keys) =>
79 Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
80 )
81 );
82 self.clients.claim();
83});
84
85self.addEventListener('fetch', (event) => {
86 const req = event.request;
87 if (req.method !== 'GET') return;
88 const url = new URL(req.url);
89 // Never intercept git, API, or auth endpoints — they must stay fresh.
90 if (
91 url.pathname.includes('.git/') ||
92 url.pathname.startsWith('/api/') ||
93 url.pathname.startsWith('/login') ||
94 url.pathname.startsWith('/logout') ||
95 url.pathname.startsWith('/register')
96 ) {
97 return;
98 }
99 const wantsHtml = req.headers.get('accept')?.includes('text/html');
100 if (wantsHtml) {
101 event.respondWith(
102 fetch(req)
103 .then((res) => {
104 const copy = res.clone();
105 caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
106 return res;
107 })
108 .catch(() => caches.match(req).then((hit) => hit || caches.match('/')))
109 );
110 }
111});
112`;
113
114pwa.get("/sw.js", (c) => {
115 c.header("content-type", "application/javascript");
116 c.header("cache-control", "public, max-age=60");
117 // Service-Worker-Allowed required for root-scope SW served from root
118 c.header("service-worker-allowed", "/");
119 return c.body(SERVICE_WORKER_SRC);
120});
121
122/**
123 * Inline script registering the SW. Loaded once at the bottom of every page.
124 * Kept tiny so we don't bloat TTI.
125 */
126export const PWA_REGISTER_SNIPPET = `
127if ('serviceWorker' in navigator) {
128 window.addEventListener('load', function() {
129 navigator.serviceWorker.register('/sw.js').catch(function() {});
130 });
131}
132`.trim();
133
134export default pwa;
Addedsrc/routes/reactions.ts+71−0View fileUnifiedSplit
1/**
2 * Reactions API — toggle + list reactions on issues, PRs, and their comments.
3 *
4 * POST /api/reactions/:targetType/:targetId/:emoji/toggle
5 * Body-less; auth required. Returns JSON {ok, added, counts}.
6 * If the request accepts text/html (form submission), redirects back.
7 *
8 * GET /api/reactions/:targetType/:targetId
9 * Returns the emoji -> count summary plus `reactedByMe` for the caller.
10 */
11
12import { Hono } from "hono";
13import type { AuthEnv } from "../middleware/auth";
14import { requireAuth, softAuth } from "../middleware/auth";
15import {
16 isAllowedEmoji,
17 isAllowedTarget,
18 summariseReactions,
19 toggleReaction,
20} from "../lib/reactions";
21
22const reactions = new Hono<AuthEnv>();
23
24reactions.use("/api/reactions/*", softAuth);
25
26reactions.get("/api/reactions/:targetType/:targetId", async (c) => {
27 const user = c.get("user");
28 const { targetType, targetId } = c.req.param();
29 if (!isAllowedTarget(targetType)) {
30 return c.json({ ok: false, error: "unknown target type" }, 400);
31 }
32 const rows = await summariseReactions(targetType, targetId, user?.id);
33 return c.json({ ok: true, reactions: rows });
34});
35
36reactions.post(
37 "/api/reactions/:targetType/:targetId/:emoji/toggle",
38 requireAuth,
39 async (c) => {
40 const user = c.get("user")!;
41 const { targetType, targetId, emoji } = c.req.param();
42 if (!isAllowedTarget(targetType)) {
43 return c.json({ ok: false, error: "unknown target type" }, 400);
44 }
45 if (!isAllowedEmoji(emoji)) {
46 return c.json({ ok: false, error: "unknown emoji" }, 400);
47 }
48
49 try {
50 const { added } = await toggleReaction(
51 user.id,
52 targetType,
53 targetId,
54 emoji
55 );
56 const summary = await summariseReactions(targetType, targetId, user.id);
57
58 const accept = c.req.header("accept") || "";
59 if (accept.includes("text/html")) {
60 const ref = c.req.header("referer");
61 return c.redirect(ref || "/");
62 }
63 return c.json({ ok: true, added, reactions: summary });
64 } catch (err) {
65 console.error("[reactions] toggle:", err);
66 return c.json({ ok: false, error: "server error" }, 500);
67 }
68 }
69);
70
71export default reactions;
Addedsrc/routes/releases.tsx+494−0View fileUnifiedSplit
1/**
2 * Releases — tagged snapshots with AI-generated changelogs.
3 *
4 * GET /:owner/:repo/releases — list
5 * GET /:owner/:repo/releases/new — create form (tag + target + AI notes)
6 * POST /:owner/:repo/releases — create release + git tag + changelog
7 * GET /:owner/:repo/releases/:tag — view single release
8 * POST /:owner/:repo/releases/:tag/delete — owner-only delete (also removes git tag)
9 *
10 * Publishing a release fans out `release_published` notifications to starrers.
11 */
12
13import { Hono } from "hono";
14import { eq, and, desc } from "drizzle-orm";
15import { db } from "../db";
16import {
17 releases,
18 repositories,
19 users,
20 stars,
21 repoSettings,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import {
28 listBranches,
29 listTags,
30 createTag,
31 deleteTag,
32 resolveRef,
33 commitsBetween,
34 getDefaultBranch,
35} from "../git/repository";
36import { generateChangelog } from "../lib/ai-generators";
37import { notifyMany, audit } from "../lib/notify";
38import { renderMarkdown } from "../lib/markdown";
39import { getUnreadCount } from "../lib/unread";
40
41const releasesRoute = new Hono<AuthEnv>();
42releasesRoute.use("*", softAuth);
43
44async function loadRepo(owner: string, repo: string) {
45 const [row] = await db
46 .select({
47 id: repositories.id,
48 name: repositories.name,
49 defaultBranch: repositories.defaultBranch,
50 ownerId: repositories.ownerId,
51 starCount: repositories.starCount,
52 forkCount: repositories.forkCount,
53 forkedFromId: repositories.forkedFromId,
54 })
55 .from(repositories)
56 .innerJoin(users, eq(repositories.ownerId, users.id))
57 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
58 .limit(1);
59 return row;
60}
61
62releasesRoute.get("/:owner/:repo/releases", async (c) => {
63 const user = c.get("user");
64 const { owner, repo } = c.req.param();
65 const repoRow = await loadRepo(owner, repo);
66 if (!repoRow) return c.notFound();
67
68 const rows = await db
69 .select({
70 id: releases.id,
71 tag: releases.tag,
72 name: releases.name,
73 body: releases.body,
74 targetCommit: releases.targetCommit,
75 isDraft: releases.isDraft,
76 isPrerelease: releases.isPrerelease,
77 createdAt: releases.createdAt,
78 publishedAt: releases.publishedAt,
79 authorName: users.username,
80 })
81 .from(releases)
82 .innerJoin(users, eq(releases.authorId, users.id))
83 .where(eq(releases.repositoryId, repoRow.id))
84 .orderBy(desc(releases.createdAt));
85
86 const unread = user ? await getUnreadCount(user.id) : 0;
87
88 return c.html(
89 <Layout
90 title={`Releases — ${owner}/${repo}`}
91 user={user}
92 notificationCount={unread}
93 >
94 <RepoHeader
95 owner={owner}
96 repo={repo}
97 starCount={repoRow.starCount}
98 forkCount={repoRow.forkCount}
99 currentUser={user?.username || null}
100 />
101 <RepoNav owner={owner} repo={repo} active="releases" />
102 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
103 <h3>Releases</h3>
104 {user && user.id === repoRow.ownerId && (
105 <a href={`/${owner}/${repo}/releases/new`} class="btn btn-primary">
106 + Draft release
107 </a>
108 )}
109 </div>
110
111 {rows.length === 0 ? (
112 <div class="empty-state">
113 <h2>No releases yet</h2>
114 <p>Tag a commit and share a changelog with your users.</p>
115 </div>
116 ) : (
117 <div>
118 {rows.map((r, i) => (
119 <div class="release-card">
120 <div class="release-header">
121 <div>
122 <span class="release-name">
123 <a href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}>
124 {r.name}
125 </a>
126 </span>
127 {i === 0 && !r.isDraft && !r.isPrerelease && (
128 <span class="release-tag release-latest" style="margin-left: 8px">
129 Latest
130 </span>
131 )}
132 {r.isDraft && (
133 <span class="badge" style="margin-left: 8px; color: var(--yellow); border-color: var(--yellow)">
134 Draft
135 </span>
136 )}
137 {r.isPrerelease && (
138 <span class="badge" style="margin-left: 8px; color: var(--accent); border-color: var(--accent)">
139 Pre-release
140 </span>
141 )}
142 </div>
143 <span class="release-tag">{r.tag}</span>
144 </div>
145 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px">
146 {r.authorName} released{" "}
147 {new Date(r.publishedAt || r.createdAt).toLocaleDateString()}
148 {" · "}
149 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
150 {r.targetCommit.slice(0, 7)}
151 </a>
152 </div>
153 {r.body && (
154 <div
155 class="markdown-body"
156 style="font-size: 13px; max-height: 200px; overflow: hidden; position: relative"
157 dangerouslySetInnerHTML={{
158 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
159 }}
160 ></div>
161 )}
162 {user && user.id === repoRow.ownerId && (
163 <form
164 method="POST"
165 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
166 style="margin-top: 12px"
167 onsubmit="return confirm('Delete this release?')"
168 >
169 <button type="submit" class="btn btn-sm btn-danger">
170 Delete
171 </button>
172 </form>
173 )}
174 </div>
175 ))}
176 </div>
177 )}
178 </Layout>
179 );
180});
181
182releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
183 const user = c.get("user")!;
184 const { owner, repo } = c.req.param();
185 const repoRow = await loadRepo(owner, repo);
186 if (!repoRow) return c.notFound();
187 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
188
189 const branches = await listBranches(owner, repo);
190 const tags = await listTags(owner, repo);
191 const unread = await getUnreadCount(user.id);
192 const error = c.req.query("error");
193
194 return c.html(
195 <Layout
196 title={`Draft release — ${owner}/${repo}`}
197 user={user}
198 notificationCount={unread}
199 >
200 <RepoHeader
201 owner={owner}
202 repo={repo}
203 starCount={repoRow.starCount}
204 forkCount={repoRow.forkCount}
205 currentUser={user.username}
206 />
207 <RepoNav owner={owner} repo={repo} active="releases" />
208 <h3>Draft a new release</h3>
209 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
210 <form
211 method="POST"
212 action={`/${owner}/${repo}/releases`}
213 style="max-width: 700px"
214 >
215 <div class="form-group">
216 <label>Tag</label>
217 <input
218 type="text"
219 name="tag"
220 required
221 placeholder="v1.0.0"
222 pattern="[A-Za-z0-9._\\-]+"
223 />
224 </div>
225 <div class="form-group">
226 <label>Target branch / commit</label>
227 <select name="target">
228 {branches.map((b) => (
229 <option value={b} selected={b === repoRow.defaultBranch}>
230 {b}
231 </option>
232 ))}
233 </select>
234 </div>
235 <div class="form-group">
236 <label>Release name</label>
237 <input type="text" name="name" required placeholder="v1.0.0 — the big one" />
238 </div>
239 <div class="form-group">
240 <label>Previous tag (for AI changelog)</label>
241 <select name="previousTag">
242 <option value="">(auto — last tag)</option>
243 {tags.map((t) => (
244 <option value={t.name}>{t.name}</option>
245 ))}
246 </select>
247 </div>
248 <div class="form-group">
249 <label>Notes (leave blank for AI-generated)</label>
250 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
251 </div>
252 <div style="display: flex; gap: 12px">
253 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
254 <input type="checkbox" name="isPrerelease" value="1" />
255 Pre-release
256 </label>
257 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
258 <input type="checkbox" name="isDraft" value="1" />
259 Save as draft
260 </label>
261 </div>
262 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
263 Publish release
264 </button>
265 </form>
266 </Layout>
267 );
268});
269
270releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
271 const user = c.get("user")!;
272 const { owner, repo } = c.req.param();
273 const repoRow = await loadRepo(owner, repo);
274 if (!repoRow) return c.notFound();
275 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
276
277 const body = await c.req.parseBody();
278 const tag = String(body.tag || "").trim();
279 const name = String(body.name || "").trim() || tag;
280 const target = String(body.target || repoRow.defaultBranch).trim();
281 const previousTag = String(body.previousTag || "").trim();
282 const notes = String(body.body || "").trim();
283 const isDraft = !!body.isDraft;
284 const isPrerelease = !!body.isPrerelease;
285
286 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
287 return c.redirect(
288 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
289 );
290 }
291
292 const sha = await resolveRef(owner, repo, target);
293 if (!sha) {
294 return c.redirect(
295 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
296 );
297 }
298
299 // Determine previous tag for changelog
300 let autoPrev = previousTag;
301 if (!autoPrev) {
302 const tags = await listTags(owner, repo);
303 autoPrev = tags[0]?.name || "";
304 }
305
306 // Generate changelog body if none provided
307 let finalBody = notes;
308 const [settings] = await db
309 .select()
310 .from(repoSettings)
311 .where(eq(repoSettings.repositoryId, repoRow.id))
312 .limit(1);
313 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
314
315 if (!finalBody && aiEnabled) {
316 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
317 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
318 }
319
320 // Create the git tag (best-effort — if it already exists we reuse)
321 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
322 if (!existing) {
323 await createTag(owner, repo, tag, sha, name || tag);
324 }
325
326 // Persist release
327 let releaseId = "";
328 try {
329 const [row] = await db
330 .insert(releases)
331 .values({
332 repositoryId: repoRow.id,
333 authorId: user.id,
334 tag,
335 name,
336 body: finalBody,
337 targetCommit: sha,
338 isDraft,
339 isPrerelease,
340 publishedAt: isDraft ? null : new Date(),
341 })
342 .returning();
343 releaseId = row?.id || "";
344 } catch (err) {
345 console.error("[releases] insert failed:", err);
346 return c.redirect(
347 `/${owner}/${repo}/releases/new?error=Tag+already+published`
348 );
349 }
350
351 // Notify starrers (only on publish)
352 if (!isDraft) {
353 try {
354 const starUsers = await db
355 .select({ userId: stars.userId })
356 .from(stars)
357 .where(eq(stars.repositoryId, repoRow.id));
358 await notifyMany(
359 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
360 {
361 kind: "release_published",
362 title: `${owner}/${repo} ${tag} released`,
363 body: name,
364 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
365 repositoryId: repoRow.id,
366 }
367 );
368 } catch {
369 /* ignore */
370 }
371 }
372
373 await audit({
374 userId: user.id,
375 repositoryId: repoRow.id,
376 action: "release.publish",
377 targetType: "release",
378 targetId: releaseId,
379 metadata: { tag, target, isDraft, isPrerelease },
380 });
381
382 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
383});
384
385releasesRoute.get("/:owner/:repo/releases/:tag", async (c) => {
386 const user = c.get("user");
387 const { owner, repo } = c.req.param();
388 const tag = decodeURIComponent(c.req.param("tag"));
389 const repoRow = await loadRepo(owner, repo);
390 if (!repoRow) return c.notFound();
391
392 const [release] = await db
393 .select({
394 id: releases.id,
395 tag: releases.tag,
396 name: releases.name,
397 body: releases.body,
398 targetCommit: releases.targetCommit,
399 isDraft: releases.isDraft,
400 isPrerelease: releases.isPrerelease,
401 createdAt: releases.createdAt,
402 publishedAt: releases.publishedAt,
403 authorName: users.username,
404 })
405 .from(releases)
406 .innerJoin(users, eq(releases.authorId, users.id))
407 .where(
408 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
409 )
410 .limit(1);
411 if (!release) return c.notFound();
412
413 const unread = user ? await getUnreadCount(user.id) : 0;
414
415 return c.html(
416 <Layout
417 title={`${release.name} — ${owner}/${repo}`}
418 user={user}
419 notificationCount={unread}
420 >
421 <RepoHeader
422 owner={owner}
423 repo={repo}
424 starCount={repoRow.starCount}
425 forkCount={repoRow.forkCount}
426 currentUser={user?.username || null}
427 />
428 <RepoNav owner={owner} repo={repo} active="releases" />
429 <div style="margin-bottom: 8px">
430 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
431 </div>
432 <div class="release-card">
433 <div class="release-header">
434 <span class="release-name">{release.name}</span>
435 <span class="release-tag">{release.tag}</span>
436 </div>
437 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
438 {release.authorName} released{" "}
439 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
440 {" · "}
441 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
442 {release.targetCommit.slice(0, 7)}
443 </a>
444 {" · "}
445 <a
446 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
447 >
448 Download
449 </a>
450 </div>
451 {release.body && (
452 <div
453 class="markdown-body"
454 dangerouslySetInnerHTML={{
455 __html: renderMarkdown(release.body),
456 }}
457 ></div>
458 )}
459 </div>
460 </Layout>
461 );
462});
463
464releasesRoute.post(
465 "/:owner/:repo/releases/:tag/delete",
466 requireAuth,
467 async (c) => {
468 const user = c.get("user")!;
469 const { owner, repo } = c.req.param();
470 const tag = decodeURIComponent(c.req.param("tag"));
471 const repoRow = await loadRepo(owner, repo);
472 if (!repoRow) return c.notFound();
473 if (repoRow.ownerId !== user.id) {
474 return c.redirect(`/${owner}/${repo}/releases`);
475 }
476
477 await db
478 .delete(releases)
479 .where(
480 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
481 );
482 await deleteTag(owner, repo, tag);
483 await audit({
484 userId: user.id,
485 repositoryId: repoRow.id,
486 action: "release.delete",
487 targetType: "release",
488 metadata: { tag },
489 });
490 return c.redirect(`/${owner}/${repo}/releases`);
491 }
492);
493
494export default releasesRoute;
Modifiedsrc/routes/repo-settings.tsx+228−2View fileUnifiedSplit
55import { Hono } from "hono";
66import { eq, and } from "drizzle-orm";
77import { db } from "../db";
8import { repositories, users } from "../db/schema";
8import { repositories, users, repoTransfers } from "../db/schema";
99import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
131131 </Form>
132132
133133 <div
134 style="margin-top: 40px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
134 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
135 >
136 <h3 style="margin-bottom: 8px">Template repository</h3>
137 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
138 {repo.isTemplate
139 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
140 : "Mark this repository as a template so others can seed new repositories from its files."}
141 </p>
142 <form
143 method="POST"
144 action={`/${ownerName}/${repoName}/settings/template`}
145 >
146 <input
147 type="hidden"
148 name="template"
149 value={repo.isTemplate ? "0" : "1"}
150 />
151 <button type="submit" class="btn">
152 {repo.isTemplate
153 ? "Unmark as template"
154 : "Mark as template"}
155 </button>
156 </form>
157 </div>
158
159 <div
160 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
161 >
162 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
163 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
164 Transfer this repository to another user. The new owner can
165 accept or decline the transfer by attempting to view it.
166 </p>
167 <form
168 method="POST"
169 action={`/${ownerName}/${repoName}/settings/transfer`}
170 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
171 >
172 <input
173 type="text"
174 name="new_owner"
175 placeholder="new-owner-username"
176 required
177 style="width:60%"
178 />{" "}
179 <button type="submit" class="btn">
180 Transfer
181 </button>
182 </form>
183 </div>
184
185 <div
186 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
187 >
188 <h3 style="margin-bottom: 8px">
189 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
190 </h3>
191 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
192 {repo.isArchived
193 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
194 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
195 </p>
196 <form
197 method="POST"
198 action={`/${ownerName}/${repoName}/settings/archive`}
199 >
200 <input
201 type="hidden"
202 name="archive"
203 value={repo.isArchived ? "0" : "1"}
204 />
205 <button type="submit" class="btn">
206 {repo.isArchived ? "Unarchive" : "Archive"} this repository
207 </button>
208 </form>
209 </div>
210
211 <div
212 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
135213 >
136214 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
137215 <Text size={14} muted style="display:block;margin-bottom:12px">
185263 );
186264});
187265
266// Toggle template flag
267repoSettings.post(
268 "/:owner/:repo/settings/template",
269 requireAuth,
270 async (c) => {
271 const { owner: ownerName, repo: repoName } = c.req.param();
272 const user = c.get("user")!;
273 const body = await c.req.parseBody();
274 const [owner] = await db
275 .select()
276 .from(users)
277 .where(eq(users.username, ownerName))
278 .limit(1);
279 if (!owner || owner.id !== user.id) {
280 return c.redirect(`/${ownerName}/${repoName}`);
281 }
282 const target = String(body.template || "1") === "1";
283 await db
284 .update(repositories)
285 .set({ isTemplate: target, updatedAt: new Date() })
286 .where(
287 and(
288 eq(repositories.ownerId, owner.id),
289 eq(repositories.name, repoName)
290 )
291 );
292 return c.redirect(
293 `/${ownerName}/${repoName}/settings?success=${
294 target ? "Marked+as+template" : "Unmarked+as+template"
295 }`
296 );
297 }
298);
299
300// Transfer repository to a new owner (by username)
301repoSettings.post(
302 "/:owner/:repo/settings/transfer",
303 requireAuth,
304 async (c) => {
305 const { owner: ownerName, repo: repoName } = c.req.param();
306 const user = c.get("user")!;
307 const body = await c.req.parseBody();
308 const newOwnerName = String(body.new_owner || "").trim();
309 if (!newOwnerName) {
310 return c.redirect(
311 `/${ownerName}/${repoName}/settings?error=New+owner+required`
312 );
313 }
314 const [owner] = await db
315 .select()
316 .from(users)
317 .where(eq(users.username, ownerName))
318 .limit(1);
319 if (!owner || owner.id !== user.id) {
320 return c.redirect(`/${ownerName}/${repoName}`);
321 }
322 const [newOwner] = await db
323 .select()
324 .from(users)
325 .where(eq(users.username, newOwnerName))
326 .limit(1);
327 if (!newOwner) {
328 return c.redirect(
329 `/${ownerName}/${repoName}/settings?error=User+not+found`
330 );
331 }
332 if (newOwner.id === owner.id) {
333 return c.redirect(
334 `/${ownerName}/${repoName}/settings?error=Same+owner`
335 );
336 }
337 // Reject if new owner already has a repo by this name
338 const [conflict] = await db
339 .select()
340 .from(repositories)
341 .where(
342 and(
343 eq(repositories.ownerId, newOwner.id),
344 eq(repositories.name, repoName)
345 )
346 )
347 .limit(1);
348 if (conflict) {
349 return c.redirect(
350 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
351 );
352 }
353 const [repo] = await db
354 .select()
355 .from(repositories)
356 .where(
357 and(
358 eq(repositories.ownerId, owner.id),
359 eq(repositories.name, repoName)
360 )
361 )
362 .limit(1);
363 if (!repo) return c.notFound();
364 await db
365 .update(repositories)
366 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
367 .where(eq(repositories.id, repo.id));
368 await db.insert(repoTransfers).values({
369 repositoryId: repo.id,
370 fromOwnerId: owner.id,
371 fromOrgId: repo.orgId,
372 toOwnerId: newOwner.id,
373 toOrgId: null,
374 initiatedBy: user.id,
375 });
376 return c.redirect(`/${newOwnerName}/${repoName}`);
377 }
378);
379
380// Archive / unarchive repository
381repoSettings.post(
382 "/:owner/:repo/settings/archive",
383 requireAuth,
384 async (c) => {
385 const { owner: ownerName, repo: repoName } = c.req.param();
386 const user = c.get("user")!;
387 const body = await c.req.parseBody();
388 const [owner] = await db
389 .select()
390 .from(users)
391 .where(eq(users.username, ownerName))
392 .limit(1);
393 if (!owner || owner.id !== user.id) {
394 return c.redirect(`/${ownerName}/${repoName}`);
395 }
396 const target = String(body.archive || "1") === "1";
397 await db
398 .update(repositories)
399 .set({ isArchived: target, updatedAt: new Date() })
400 .where(
401 and(
402 eq(repositories.ownerId, owner.id),
403 eq(repositories.name, repoName)
404 )
405 );
406 return c.redirect(
407 `/${ownerName}/${repoName}/settings?success=${
408 target ? "Repository+archived" : "Repository+unarchived"
409 }`
410 );
411 }
412);
413
188414// Delete repository
189415repoSettings.post(
190416 "/:owner/:repo/settings/delete",
Addedsrc/routes/required-checks.tsx+269−0View fileUnifiedSplit
1/**
2 * Block E6 — Required status checks matrix settings UI.
3 *
4 * GET /:owner/:repo/gates/protection/:id/checks — manage required checks
5 * POST /:owner/:repo/gates/protection/:id/checks — add a check name
6 * POST /:owner/:repo/gates/protection/:id/checks/:cid/delete — remove
7 *
8 * Required checks are scoped to a single branch-protection rule. Adding a
9 * check tells the merge handler "in addition to green gates, the check with
10 * this name must have a passing gate_run OR workflow_run against the head
11 * commit". Name matching is exact (case-sensitive); callers typically use
12 * workflow `name:` or the gate kinds (e.g. `GateTest`, `AI Review`).
13 */
14
15import { Hono } from "hono";
16import { and, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 branchProtection,
20 branchRequiredChecks,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { listRequiredChecks } from "../lib/branch-protection";
29import { audit } from "../lib/notify";
30
31const required = new Hono<AuthEnv>();
32required.use("*", softAuth);
33
34async function loadRepo(ownerName: string, repoName: string) {
35 try {
36 const [row] = await db
37 .select({
38 id: repositories.id,
39 name: repositories.name,
40 ownerId: repositories.ownerId,
41 starCount: repositories.starCount,
42 forkCount: repositories.forkCount,
43 })
44 .from(repositories)
45 .innerJoin(users, eq(repositories.ownerId, users.id))
46 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
47 .limit(1);
48 return row || null;
49 } catch {
50 return null;
51 }
52}
53
54async function loadRule(repositoryId: string, ruleId: string) {
55 try {
56 const [rule] = await db
57 .select()
58 .from(branchProtection)
59 .where(
60 and(
61 eq(branchProtection.id, ruleId),
62 eq(branchProtection.repositoryId, repositoryId)
63 )
64 )
65 .limit(1);
66 return rule || null;
67 } catch {
68 return null;
69 }
70}
71
72required.get(
73 "/:owner/:repo/gates/protection/:id/checks",
74 requireAuth,
75 async (c) => {
76 const user = c.get("user")!;
77 const { owner, repo, id } = c.req.param();
78 const repoRow = await loadRepo(owner, repo);
79 if (!repoRow) return c.notFound();
80 if (repoRow.ownerId !== user.id) {
81 return c.redirect(`/${owner}/${repo}/gates`);
82 }
83 const rule = await loadRule(repoRow.id, id);
84 if (!rule) {
85 return c.redirect(
86 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
87 );
88 }
89
90 const checks = await listRequiredChecks(rule.id);
91 const success = c.req.query("success");
92 const error = c.req.query("error");
93
94 return c.html(
95 <Layout title={`Required checks — ${rule.pattern}`} user={user}>
96 <RepoHeader
97 owner={owner}
98 repo={repo}
99 starCount={repoRow.starCount}
100 forkCount={repoRow.forkCount}
101 currentUser={user.username}
102 />
103 <RepoNav owner={owner} repo={repo} active="gates" />
104
105 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
106 <h3>
107 Required checks · <code>{rule.pattern}</code>
108 </h3>
109 <a href={`/${owner}/${repo}/gates/settings`} class="btn btn-sm">
110 Back to protection
111 </a>
112 </div>
113
114 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
115 Merges into branches matching this rule require a passing run for
116 each named check. Names match against <code>gate_runs.gate_name</code>{" "}
117 (e.g. <code>GateTest</code>, <code>AI Review</code>,{" "}
118 <code>Secret Scan</code>, <code>Type Check</code>) or the{" "}
119 <code>name:</code> field of a workflow in{" "}
120 <code>.gluecron/workflows/*.yml</code>.
121 </p>
122
123 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
124 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
125
126 <div class="panel" style="margin-bottom:16px">
127 {checks.length === 0 ? (
128 <div class="panel-empty">No required checks configured.</div>
129 ) : (
130 checks.map((ch) => (
131 <div class="panel-item" style="justify-content:space-between">
132 <code
133 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
134 >
135 {ch.checkName}
136 </code>
137 <form
138 method="POST"
139 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
140 onsubmit="return confirm('Remove this required check?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 Remove
144 </button>
145 </form>
146 </div>
147 ))
148 )}
149 </div>
150
151 <form
152 method="POST"
153 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
154 class="panel"
155 style="padding:16px"
156 >
157 <div class="form-group">
158 <label>Check name</label>
159 <input
160 type="text"
161 name="checkName"
162 required
163 placeholder="GateTest"
164 style="font-family:var(--font-mono)"
165 />
166 </div>
167 <button type="submit" class="btn btn-primary">
168 Add required check
169 </button>
170 </form>
171 </Layout>
172 );
173 }
174);
175
176required.post(
177 "/:owner/:repo/gates/protection/:id/checks",
178 requireAuth,
179 async (c) => {
180 const user = c.get("user")!;
181 const { owner, repo, id } = c.req.param();
182 const repoRow = await loadRepo(owner, repo);
183 if (!repoRow) return c.notFound();
184 if (repoRow.ownerId !== user.id) {
185 return c.redirect(`/${owner}/${repo}/gates`);
186 }
187 const rule = await loadRule(repoRow.id, id);
188 if (!rule) {
189 return c.redirect(
190 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
191 );
192 }
193
194 const body = await c.req.parseBody();
195 const checkName = String(body.checkName || "").trim();
196 if (!checkName) {
197 return c.redirect(
198 `/${owner}/${repo}/gates/protection/${rule.id}/checks?error=${encodeURIComponent("Name required")}`
199 );
200 }
201
202 try {
203 await db
204 .insert(branchRequiredChecks)
205 .values({ branchProtectionId: rule.id, checkName });
206 } catch (err) {
207 // Likely a unique-index collision — treat as success.
208 console.error("[required-checks] insert:", err);
209 }
210
211 await audit({
212 userId: user.id,
213 repositoryId: repoRow.id,
214 action: "branch_required_checks.create",
215 targetId: rule.id,
216 metadata: { checkName, pattern: rule.pattern },
217 });
218
219 return c.redirect(
220 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check added")}`
221 );
222 }
223);
224
225required.post(
226 "/:owner/:repo/gates/protection/:id/checks/:cid/delete",
227 requireAuth,
228 async (c) => {
229 const user = c.get("user")!;
230 const { owner, repo, id, cid } = c.req.param();
231 const repoRow = await loadRepo(owner, repo);
232 if (!repoRow) return c.notFound();
233 if (repoRow.ownerId !== user.id) {
234 return c.redirect(`/${owner}/${repo}/gates`);
235 }
236 const rule = await loadRule(repoRow.id, id);
237 if (!rule) {
238 return c.redirect(
239 `/${owner}/${repo}/gates/settings?error=${encodeURIComponent("Rule not found")}`
240 );
241 }
242
243 try {
244 await db
245 .delete(branchRequiredChecks)
246 .where(
247 and(
248 eq(branchRequiredChecks.id, cid),
249 eq(branchRequiredChecks.branchProtectionId, rule.id)
250 )
251 );
252 } catch (err) {
253 console.error("[required-checks] delete:", err);
254 }
255
256 await audit({
257 userId: user.id,
258 repositoryId: repoRow.id,
259 action: "branch_required_checks.delete",
260 targetId: rule.id,
261 });
262
263 return c.redirect(
264 `/${owner}/${repo}/gates/protection/${rule.id}/checks?success=${encodeURIComponent("Check removed")}`
265 );
266 }
267);
268
269export default required;
Addedsrc/routes/rulesets.tsx+514−0View fileUnifiedSplit
1/**
2 * Block J6 — Ruleset management UI.
3 *
4 * GET /:owner/:repo/settings/rulesets — list + create
5 * POST /:owner/:repo/settings/rulesets — create
6 * GET /:owner/:repo/settings/rulesets/:id — detail, add rules
7 * POST /:owner/:repo/settings/rulesets/:id — update enforcement
8 * POST /:owner/:repo/settings/rulesets/:id/delete
9 * POST /:owner/:repo/settings/rulesets/:id/rules — add rule
10 * POST /:owner/:repo/settings/rulesets/:id/rules/:rid/delete
11 */
12
13import { Hono } from "hono";
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, users } from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { requireAuth, softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { audit } from "../lib/notify";
22import {
23 RULE_TYPES,
24 addRule,
25 createRuleset,
26 deleteRule,
27 deleteRuleset,
28 getRuleset,
29 listRulesetsForRepo,
30 parseParams,
31 updateRulesetEnforcement,
32} from "../lib/rulesets";
33
34const rulesets = new Hono<AuthEnv>();
35rulesets.use("*", softAuth);
36
37async function gate(c: any) {
38 const user = c.get("user");
39 if (!user) return c.redirect("/login");
40 const { owner: ownerName, repo: repoName } = c.req.param();
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!owner) return c.notFound();
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
52 )
53 .limit(1);
54 if (!repo) return c.notFound();
55 if (user.id !== repo.ownerId) {
56 return c.redirect(`/${ownerName}/${repoName}`);
57 }
58 return { user, owner, repo, ownerName, repoName };
59}
60
61function ruleDescription(type: string, params: Record<string, unknown>): string {
62 switch (type) {
63 case "commit_message_pattern":
64 return `commit message ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
65 case "branch_name_pattern":
66 return `branch name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
67 case "tag_name_pattern":
68 return `tag name ${params.require === false ? "MUST NOT" : "must"} match /${params.pattern || ""}/`;
69 case "blocked_file_paths":
70 return `blocks changes to: ${(params.paths as string[] | undefined)?.join(", ") || "(none)"}`;
71 case "max_file_size":
72 return `max blob size ${params.bytes || 0}B`;
73 case "forbid_force_push":
74 return "force push forbidden";
75 default:
76 return type;
77 }
78}
79
80// ---------- List + create ----------
81
82rulesets.get("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
83 const ctx = await gate(c);
84 if (ctx instanceof Response) return ctx;
85 const { ownerName, repoName, repo, user } = ctx;
86 const all = await listRulesetsForRepo(repo.id);
87 const message = c.req.query("message");
88 const error = c.req.query("error");
89 return c.html(
90 <Layout title={`Rulesets — ${ownerName}/${repoName}`} user={user}>
91 <RepoHeader owner={ownerName} repo={repoName} />
92 <RepoNav owner={ownerName} repo={repoName} active="settings" />
93 <div class="settings-container">
94 <h2>Rulesets</h2>
95 <p style="color:var(--text-muted)">
96 Policy engine that extends branch protection. Each ruleset contains
97 multiple rules (commit messages, branch/tag names, blocked paths,
98 max file size, force-push bans). Enforcement modes:
99 <strong> active</strong> blocks, <strong>evaluate</strong> only logs,
100 <strong> disabled</strong> is inert.
101 </p>
102 {message && (
103 <div class="auth-success" style="margin-top:12px">
104 {decodeURIComponent(message)}
105 </div>
106 )}
107 {error && (
108 <div class="auth-error" style="margin-top:12px">
109 {decodeURIComponent(error)}
110 </div>
111 )}
112
113 <h3 style="margin-top:24px">Existing rulesets</h3>
114 {all.length === 0 ? (
115 <div class="panel-empty" style="padding:16px">
116 No rulesets yet.
117 </div>
118 ) : (
119 <div class="panel">
120 {all.map((rs) => (
121 <div
122 class="panel-item"
123 style="flex-direction:column;align-items:stretch;gap:4px"
124 >
125 <div style="display:flex;justify-content:space-between;gap:12px">
126 <div>
127 <a
128 href={`/${ownerName}/${repoName}/settings/rulesets/${rs.id}`}
129 style="font-weight:600"
130 >
131 {rs.name}
132 </a>
133 <span
134 style={`margin-left:8px;font-size:10px;padding:2px 6px;border-radius:3px;background:${
135 rs.enforcement === "active"
136 ? "var(--red)"
137 : rs.enforcement === "evaluate"
138 ? "var(--yellow)"
139 : "var(--bg-subtle)"
140 };color:#fff;text-transform:uppercase`}
141 >
142 {rs.enforcement}
143 </span>
144 <span
145 style="margin-left:8px;color:var(--text-muted);font-size:12px"
146 >
147 {rs.rules.length} rule
148 {rs.rules.length === 1 ? "" : "s"}
149 </span>
150 </div>
151 </div>
152 </div>
153 ))}
154 </div>
155 )}
156
157 <h3 style="margin-top:24px">New ruleset</h3>
158 <form
159 method="POST"
160 action={`/${ownerName}/${repoName}/settings/rulesets`}
161 class="auth-form"
162 style="max-width:520px"
163 >
164 <div class="form-group">
165 <label for="rs-name">Name</label>
166 <input
167 type="text"
168 id="rs-name"
169 name="name"
170 placeholder="e.g. release-branches"
171 required
172 maxLength={120}
173 />
174 </div>
175 <div class="form-group">
176 <label for="rs-enf">Enforcement</label>
177 <select id="rs-enf" name="enforcement" required>
178 <option value="active">active — block on violation</option>
179 <option value="evaluate">evaluate — log only</option>
180 <option value="disabled">disabled</option>
181 </select>
182 </div>
183 <button type="submit" class="btn btn-primary">
184 Create ruleset
185 </button>
186 </form>
187 </div>
188 </Layout>
189 );
190});
191
192rulesets.post("/:owner/:repo/settings/rulesets", requireAuth, async (c) => {
193 const ctx = await gate(c);
194 if (ctx instanceof Response) return ctx;
195 const { ownerName, repoName, repo, user } = ctx;
196 const body = await c.req.parseBody();
197 const name = String(body.name || "");
198 const enforcement = String(body.enforcement || "active") as
199 | "active"
200 | "evaluate"
201 | "disabled";
202 const result = await createRuleset({
203 repositoryId: repo.id,
204 name,
205 enforcement,
206 createdBy: user.id,
207 });
208 const base = `/${ownerName}/${repoName}/settings/rulesets`;
209 if (!result.ok) {
210 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
211 }
212 await audit({
213 userId: user.id,
214 repositoryId: repo.id,
215 action: "ruleset.create",
216 targetId: result.id,
217 metadata: { name, enforcement },
218 });
219 return c.redirect(`${base}/${result.id}`);
220});
221
222// ---------- Detail ----------
223
224rulesets.get(
225 "/:owner/:repo/settings/rulesets/:id",
226 requireAuth,
227 async (c) => {
228 const ctx = await gate(c);
229 if (ctx instanceof Response) return ctx;
230 const { ownerName, repoName, repo, user } = ctx;
231 const id = c.req.param("id");
232 const rs = await getRuleset(id, repo.id);
233 if (!rs) return c.notFound();
234 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
235 const message = c.req.query("message");
236 const error = c.req.query("error");
237 return c.html(
238 <Layout
239 title={`Ruleset ${rs.name} — ${ownerName}/${repoName}`}
240 user={user}
241 >
242 <RepoHeader owner={ownerName} repo={repoName} />
243 <RepoNav owner={ownerName} repo={repoName} active="settings" />
244 <div class="settings-container">
245 <div style="display:flex;justify-content:space-between;gap:12px;align-items:center">
246 <div>
247 <h2 style="margin:0">{rs.name}</h2>
248 <span
249 style={`font-size:10px;padding:2px 6px;border-radius:3px;background:${
250 rs.enforcement === "active"
251 ? "var(--red)"
252 : rs.enforcement === "evaluate"
253 ? "var(--yellow)"
254 : "var(--bg-subtle)"
255 };color:#fff;text-transform:uppercase`}
256 >
257 {rs.enforcement}
258 </span>
259 </div>
260 <a
261 href={`/${ownerName}/${repoName}/settings/rulesets`}
262 class="btn btn-sm"
263 >
264 ← Back
265 </a>
266 </div>
267
268 {message && (
269 <div class="auth-success" style="margin-top:12px">
270 {decodeURIComponent(message)}
271 </div>
272 )}
273 {error && (
274 <div class="auth-error" style="margin-top:12px">
275 {decodeURIComponent(error)}
276 </div>
277 )}
278
279 <h3 style="margin-top:24px">Enforcement</h3>
280 <form
281 method="POST"
282 action={base}
283 style="display:flex;gap:8px;align-items:center"
284 >
285 <select name="enforcement">
286 <option
287 value="active"
288 selected={rs.enforcement === "active" as any}
289 >
290 active
291 </option>
292 <option
293 value="evaluate"
294 selected={rs.enforcement === "evaluate" as any}
295 >
296 evaluate
297 </option>
298 <option
299 value="disabled"
300 selected={rs.enforcement === "disabled" as any}
301 >
302 disabled
303 </option>
304 </select>
305 <button type="submit" class="btn btn-primary btn-sm">
306 Update
307 </button>
308 </form>
309
310 <h3 style="margin-top:24px">Rules</h3>
311 {rs.rules.length === 0 ? (
312 <div class="panel-empty" style="padding:16px">
313 No rules in this ruleset.
314 </div>
315 ) : (
316 <div class="panel">
317 {rs.rules.map((r) => {
318 const params = parseParams(r.params);
319 return (
320 <div
321 class="panel-item"
322 style="flex-direction:column;align-items:stretch;gap:4px"
323 >
324 <div style="display:flex;justify-content:space-between;gap:8px">
325 <div>
326 <span
327 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px"
328 >
329 {r.ruleType}
330 </span>
331 <span>{ruleDescription(r.ruleType, params)}</span>
332 </div>
333 <form
334 method="POST"
335 action={`${base}/rules/${r.id}/delete`}
336 >
337 <button
338 type="submit"
339 class="btn btn-sm"
340 style="font-size:11px"
341 >
342 Delete
343 </button>
344 </form>
345 </div>
346 </div>
347 );
348 })}
349 </div>
350 )}
351
352 <h3 style="margin-top:24px">Add rule</h3>
353 <form
354 method="POST"
355 action={`${base}/rules`}
356 class="auth-form"
357 style="max-width:640px"
358 >
359 <div class="form-group">
360 <label for="rt">Rule type</label>
361 <select id="rt" name="rule_type" required>
362 {RULE_TYPES.map((t) => (
363 <option value={t}>{t}</option>
364 ))}
365 </select>
366 </div>
367 <div class="form-group">
368 <label for="rp">
369 Params (JSON) — e.g.{" "}
370 <code>{`{"pattern":"^(feat|fix|chore):"}`}</code>
371 </label>
372 <textarea
373 id="rp"
374 name="params"
375 rows={4}
376 placeholder="{}"
377 style="font-family:var(--font-mono);font-size:12px"
378 ></textarea>
379 </div>
380 <button type="submit" class="btn btn-primary">
381 Add rule
382 </button>
383 </form>
384
385 <h3 style="margin-top:24px;color:var(--red)">Danger zone</h3>
386 <form method="POST" action={`${base}/delete`}>
387 <button type="submit" class="btn" style="color:var(--red)">
388 Delete ruleset
389 </button>
390 </form>
391 </div>
392 </Layout>
393 );
394 }
395);
396
397rulesets.post(
398 "/:owner/:repo/settings/rulesets/:id",
399 requireAuth,
400 async (c) => {
401 const ctx = await gate(c);
402 if (ctx instanceof Response) return ctx;
403 const { ownerName, repoName, repo, user } = ctx;
404 const id = c.req.param("id");
405 const body = await c.req.parseBody();
406 const enforcement = String(body.enforcement || "active") as
407 | "active"
408 | "evaluate"
409 | "disabled";
410 const ok = await updateRulesetEnforcement(id, repo.id, enforcement);
411 if (ok) {
412 await audit({
413 userId: user.id,
414 repositoryId: repo.id,
415 action: "ruleset.update",
416 targetId: id,
417 metadata: { enforcement },
418 });
419 }
420 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
421 return c.redirect(
422 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
423 ok ? "Updated." : "Update failed"
424 )}`
425 );
426 }
427);
428
429rulesets.post(
430 "/:owner/:repo/settings/rulesets/:id/delete",
431 requireAuth,
432 async (c) => {
433 const ctx = await gate(c);
434 if (ctx instanceof Response) return ctx;
435 const { ownerName, repoName, repo, user } = ctx;
436 const id = c.req.param("id");
437 const ok = await deleteRuleset(id, repo.id);
438 if (ok) {
439 await audit({
440 userId: user.id,
441 repositoryId: repo.id,
442 action: "ruleset.delete",
443 targetId: id,
444 });
445 }
446 const base = `/${ownerName}/${repoName}/settings/rulesets`;
447 return c.redirect(
448 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
449 ok ? "Ruleset deleted." : "Delete failed"
450 )}`
451 );
452 }
453);
454
455rulesets.post(
456 "/:owner/:repo/settings/rulesets/:id/rules",
457 requireAuth,
458 async (c) => {
459 const ctx = await gate(c);
460 if (ctx instanceof Response) return ctx;
461 const { ownerName, repoName, repo, user } = ctx;
462 const id = c.req.param("id");
463 const body = await c.req.parseBody();
464 const ruleType = String(body.rule_type || "") as any;
465 const params = parseParams(String(body.params || "{}"));
466 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
467 const result = await addRule({
468 rulesetId: id,
469 repositoryId: repo.id,
470 ruleType,
471 params,
472 });
473 if (!result.ok) {
474 return c.redirect(`${base}?error=${encodeURIComponent(result.error)}`);
475 }
476 await audit({
477 userId: user.id,
478 repositoryId: repo.id,
479 action: "ruleset.rule.add",
480 targetId: result.id,
481 metadata: { ruleType, params },
482 });
483 return c.redirect(`${base}?message=${encodeURIComponent("Rule added.")}`);
484 }
485);
486
487rulesets.post(
488 "/:owner/:repo/settings/rulesets/:id/rules/:rid/delete",
489 requireAuth,
490 async (c) => {
491 const ctx = await gate(c);
492 if (ctx instanceof Response) return ctx;
493 const { ownerName, repoName, repo, user } = ctx;
494 const id = c.req.param("id");
495 const rid = c.req.param("rid");
496 const ok = await deleteRule(rid, id, repo.id);
497 if (ok) {
498 await audit({
499 userId: user.id,
500 repositoryId: repo.id,
501 action: "ruleset.rule.delete",
502 targetId: rid,
503 });
504 }
505 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
506 return c.redirect(
507 `${base}?${ok ? "message" : "error"}=${encodeURIComponent(
508 ok ? "Rule removed." : "Delete failed"
509 )}`
510 );
511 }
512);
513
514export default rulesets;
Addedsrc/routes/saved-replies.tsx+242−0View fileUnifiedSplit
1/**
2 * Saved replies — per-user canned comment templates.
3 *
4 * Routes:
5 * GET /settings/replies list + create form
6 * POST /settings/replies create
7 * POST /settings/replies/:id/delete delete
8 * POST /settings/replies/:id update
9 * GET /api/user/replies JSON list for the insertion picker
10 */
11
12import { Hono } from "hono";
13import { and, eq, asc } from "drizzle-orm";
14import { db } from "../db";
15import { savedReplies } from "../db/schema";
16import type { AuthEnv } from "../middleware/auth";
17import { requireAuth } from "../middleware/auth";
18import { Layout } from "../views/layout";
19
20const replies = new Hono<AuthEnv>();
21
22replies.use("/settings/replies", requireAuth);
23replies.use("/settings/replies/*", requireAuth);
24replies.use("/api/user/replies", requireAuth);
25
26function trimBounded(s: string, max: number): string {
27 const t = s.trim();
28 return t.length > max ? t.slice(0, max) : t;
29}
30
31async function listForUser(userId: string) {
32 try {
33 return await db
34 .select()
35 .from(savedReplies)
36 .where(eq(savedReplies.userId, userId))
37 .orderBy(asc(savedReplies.shortcut));
38 } catch (err) {
39 console.error("[saved-replies] list:", err);
40 return [];
41 }
42}
43
44replies.get("/settings/replies", async (c) => {
45 const user = c.get("user")!;
46 const rows = await listForUser(user.id);
47 const error = c.req.query("error");
48 const success = c.req.query("success");
49
50 return c.html(
51 <Layout title="Saved replies" user={user}>
52 <div class="settings-container" style="max-width: 720px">
53 <h2>Saved replies</h2>
54 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
55 Canned responses you can insert into any issue or PR comment. The
56 shortcut is a nickname only you see.
57 </p>
58
59 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
60 {success && (
61 <div class="auth-success">{decodeURIComponent(success)}</div>
62 )}
63
64 <form method="POST" action="/settings/replies" style="margin-bottom: 24px">
65 <div class="form-group">
66 <label for="shortcut">Shortcut</label>
67 <input
68 type="text"
69 id="shortcut"
70 name="shortcut"
71 required
72 maxLength={64}
73 placeholder="lgtm"
74 />
75 </div>
76 <div class="form-group">
77 <label for="body">Reply body</label>
78 <textarea
79 id="body"
80 name="body"
81 rows={4}
82 required
83 maxLength={4096}
84 placeholder="LGTM! Thanks for the PR."
85 style="font-family: var(--font-mono); font-size: 13px"
86 />
87 </div>
88 <button type="submit" class="btn btn-primary">
89 Add saved reply
90 </button>
91 </form>
92
93 {rows.length > 0 && (
94 <div>
95 <h3 style="font-size: 16px; margin-bottom: 12px">
96 Your replies ({rows.length})
97 </h3>
98 <div class="saved-replies-list">
99 {rows.map((r) => (
100 <details class="saved-reply-item">
101 <summary>
102 <code>{r.shortcut}</code>
103 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
104 {r.body.slice(0, 80).replace(/\n/g, " ")}
105 {r.body.length > 80 ? "\u2026" : ""}
106 </span>
107 </summary>
108 <div style="padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border)">
109 <form method="POST" action={`/settings/replies/${r.id}`}>
110 <div class="form-group">
111 <label>Shortcut</label>
112 <input
113 type="text"
114 name="shortcut"
115 required
116 value={r.shortcut}
117 maxLength={64}
118 />
119 </div>
120 <div class="form-group">
121 <label>Body</label>
122 <textarea
123 name="body"
124 rows={4}
125 required
126 maxLength={4096}
127 style="font-family: var(--font-mono); font-size: 13px"
128 >
129 {r.body}
130 </textarea>
131 </div>
132 <div style="display: flex; gap: 8px">
133 <button type="submit" class="btn btn-primary">
134 Save
135 </button>
136 <button
137 type="submit"
138 formaction={`/settings/replies/${r.id}/delete`}
139 class="btn btn-danger"
140 onclick="return confirm('Delete this saved reply?')"
141 >
142 Delete
143 </button>
144 </div>
145 </form>
146 </div>
147 </details>
148 ))}
149 </div>
150 </div>
151 )}
152 </div>
153 </Layout>
154 );
155});
156
157replies.post("/settings/replies", async (c) => {
158 const user = c.get("user")!;
159 const body = await c.req.parseBody();
160 const shortcut = trimBounded(String(body.shortcut || ""), 64);
161 const text = trimBounded(String(body.body || ""), 4096);
162 if (!shortcut || !text) {
163 return c.redirect(
164 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
165 );
166 }
167 try {
168 await db.insert(savedReplies).values({
169 userId: user.id,
170 shortcut,
171 body: text,
172 });
173 } catch (err: any) {
174 if (String(err?.message || err).includes("saved_replies_user_shortcut")) {
175 return c.redirect(
176 "/settings/replies?error=" +
177 encodeURIComponent("You already have a reply with that shortcut")
178 );
179 }
180 console.error("[saved-replies] create:", err);
181 return c.redirect(
182 "/settings/replies?error=" + encodeURIComponent("Failed to save")
183 );
184 }
185 return c.redirect(
186 "/settings/replies?success=" + encodeURIComponent("Reply saved")
187 );
188});
189
190replies.post("/settings/replies/:id", async (c) => {
191 const user = c.get("user")!;
192 const id = c.req.param("id");
193 const body = await c.req.parseBody();
194 const shortcut = trimBounded(String(body.shortcut || ""), 64);
195 const text = trimBounded(String(body.body || ""), 4096);
196 if (!shortcut || !text) {
197 return c.redirect(
198 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
199 );
200 }
201 try {
202 await db
203 .update(savedReplies)
204 .set({ shortcut, body: text, updatedAt: new Date() })
205 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
206 } catch (err) {
207 console.error("[saved-replies] update:", err);
208 }
209 return c.redirect(
210 "/settings/replies?success=" + encodeURIComponent("Reply updated")
211 );
212});
213
214replies.post("/settings/replies/:id/delete", async (c) => {
215 const user = c.get("user")!;
216 const id = c.req.param("id");
217 try {
218 await db
219 .delete(savedReplies)
220 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
221 } catch (err) {
222 console.error("[saved-replies] delete:", err);
223 }
224 return c.redirect(
225 "/settings/replies?success=" + encodeURIComponent("Reply deleted")
226 );
227});
228
229replies.get("/api/user/replies", async (c) => {
230 const user = c.get("user")!;
231 const rows = await listForUser(user.id);
232 return c.json({
233 ok: true,
234 replies: rows.map((r) => ({
235 id: r.id,
236 shortcut: r.shortcut,
237 body: r.body,
238 })),
239 });
240});
241
242export default replies;
Addedsrc/routes/search.tsx+290−0View fileUnifiedSplit
1/**
2 * Global search — across repos, users, issues, PRs.
3 *
4 * GET /search?q=...&type=repos|users|issues|prs
5 *
6 * Text search uses Postgres ILIKE — good enough for the scale GlueCron is at
7 * today. If traffic grows, swap in pgvector + AI embeddings.
8 */
9
10import { Hono } from "hono";
11import { and, desc, eq, or, sql } from "drizzle-orm";
12import { db } from "../db";
13import {
14 issues,
15 pullRequests,
16 repositories,
17 users,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoCard } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { getUnreadCount } from "../lib/unread";
24
25const search = new Hono<AuthEnv>();
26search.use("*", softAuth);
27
28search.get("/search", async (c) => {
29 const user = c.get("user");
30 const q = (c.req.query("q") || "").trim();
31 const type = c.req.query("type") || "repos";
32 const unread = user ? await getUnreadCount(user.id) : 0;
33
34 let repoHits: Array<{
35 repo: typeof repositories.$inferSelect;
36 ownerName: string;
37 }> = [];
38 let userHits: Array<typeof users.$inferSelect> = [];
39 let issueHits: Array<{
40 id: string;
41 number: number;
42 title: string;
43 state: string;
44 repoName: string;
45 repoOwner: string;
46 }> = [];
47 let prHits: Array<{
48 id: string;
49 number: number;
50 title: string;
51 state: string;
52 repoName: string;
53 repoOwner: string;
54 }> = [];
55
56 if (q) {
57 const pat = `%${q}%`;
58 if (type === "repos") {
59 const results = await db
60 .select({ repo: repositories, ownerName: users.username })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(
64 and(
65 eq(repositories.isPrivate, false),
66 sql`(${repositories.name} ILIKE ${pat} OR ${repositories.description} ILIKE ${pat})`
67 )
68 )
69 .orderBy(desc(repositories.starCount))
70 .limit(30);
71 repoHits = results;
72 } else if (type === "users") {
73 userHits = await db
74 .select()
75 .from(users)
76 .where(
77 sql`(${users.username} ILIKE ${pat} OR ${users.displayName} ILIKE ${pat})`
78 )
79 .limit(30);
80 } else if (type === "issues") {
81 issueHits = await db
82 .select({
83 id: issues.id,
84 number: issues.number,
85 title: issues.title,
86 state: issues.state,
87 repoName: repositories.name,
88 repoOwner: users.username,
89 })
90 .from(issues)
91 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
92 .innerJoin(users, eq(repositories.ownerId, users.id))
93 .where(
94 and(
95 eq(repositories.isPrivate, false),
96 sql`(${issues.title} ILIKE ${pat} OR ${issues.body} ILIKE ${pat})`
97 )
98 )
99 .orderBy(desc(issues.updatedAt))
100 .limit(30);
101 } else if (type === "prs") {
102 prHits = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 })
111 .from(pullRequests)
112 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
113 .innerJoin(users, eq(repositories.ownerId, users.id))
114 .where(
115 and(
116 eq(repositories.isPrivate, false),
117 sql`(${pullRequests.title} ILIKE ${pat} OR ${pullRequests.body} ILIKE ${pat})`
118 )
119 )
120 .orderBy(desc(pullRequests.updatedAt))
121 .limit(30);
122 }
123 }
124
125 const tab = (id: string, label: string) => (
126 <a
127 href={`/search?q=${encodeURIComponent(q)}&type=${id}`}
128 class={type === id ? "active" : ""}
129 >
130 {label}
131 </a>
132 );
133
134 return c.html(
135 <Layout
136 title={q ? `Search — ${q}` : "Search"}
137 user={user}
138 notificationCount={unread}
139 >
140 <form method="GET" action="/search" style="margin-bottom: 16px">
141 <input
142 type="hidden"
143 name="type"
144 value={type}
145 />
146 <input
147 type="search"
148 name="q"
149 value={q}
150 placeholder="Search repositories, users, issues, PRs…"
151 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
152 autofocus
153 />
154 </form>
155
156 <div class="issue-tabs" style="margin-bottom: 20px">
157 {tab("repos", "Repositories")}
158 {tab("users", "Users")}
159 {tab("issues", "Issues")}
160 {tab("prs", "Pull requests")}
161 </div>
162
163 {!q && (
164 <div class="empty-state">
165 <p>Type to search across GlueCron.</p>
166 </div>
167 )}
168
169 {q && type === "repos" && (
170 repoHits.length === 0 ? (
171 <div class="empty-state"><p>No repositories match "{q}"</p></div>
172 ) : (
173 <div class="card-grid">
174 {repoHits.map(({ repo, ownerName }) => (
175 <RepoCard repo={repo} ownerName={ownerName} />
176 ))}
177 </div>
178 )
179 )}
180
181 {q && type === "users" && (
182 userHits.length === 0 ? (
183 <div class="empty-state"><p>No users match "{q}"</p></div>
184 ) : (
185 <div class="panel">
186 {userHits.map((u) => (
187 <div class="panel-item">
188 <div class="dot blue"></div>
189 <div style="flex: 1">
190 <a href={`/${u.username}`} style="font-weight: 600">
191 {u.displayName || u.username}
192 </a>
193 <div class="meta">@{u.username}</div>
194 {u.bio && <div class="meta">{u.bio}</div>}
195 </div>
196 </div>
197 ))}
198 </div>
199 )
200 )}
201
202 {q && type === "issues" && (
203 issueHits.length === 0 ? (
204 <div class="empty-state"><p>No issues match "{q}"</p></div>
205 ) : (
206 <div class="panel">
207 {issueHits.map((i) => (
208 <div class="panel-item">
209 <div
210 class={`dot ${i.state === "open" ? "green" : "yellow"}`}
211 ></div>
212 <div style="flex: 1">
213 <a
214 href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}
215 >
216 {i.title}
217 </a>
218 <div class="meta">
219 {i.repoOwner}/{i.repoName}#{i.number} · {i.state}
220 </div>
221 </div>
222 </div>
223 ))}
224 </div>
225 )
226 )}
227
228 {q && type === "prs" && (
229 prHits.length === 0 ? (
230 <div class="empty-state"><p>No pull requests match "{q}"</p></div>
231 ) : (
232 <div class="panel">
233 {prHits.map((pr) => (
234 <div class="panel-item">
235 <div
236 class={`dot ${pr.state === "open" ? "green" : pr.state === "merged" ? "blue" : "yellow"}`}
237 ></div>
238 <div style="flex: 1">
239 <a
240 href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}
241 >
242 {pr.title}
243 </a>
244 <div class="meta">
245 {pr.repoOwner}/{pr.repoName}#{pr.number} · {pr.state}
246 </div>
247 </div>
248 </div>
249 ))}
250 </div>
251 )
252 )}
253 </Layout>
254 );
255});
256
257// Keyboard shortcuts help page — linked from Cmd+? in nav
258search.get("/shortcuts", async (c) => {
259 const user = c.get("user");
260 const unread = user ? await getUnreadCount(user.id) : 0;
261 const shortcuts: Array<{ keys: string; desc: string }> = [
262 { keys: "/", desc: "Focus global search" },
263 { keys: "Cmd/Ctrl + K", desc: "Open AI assistant" },
264 { keys: "g d", desc: "Go to dashboard" },
265 { keys: "g n", desc: "Go to notifications" },
266 { keys: "g e", desc: "Go to explore" },
267 { keys: "n", desc: "New repository" },
268 { keys: "?", desc: "Show this help" },
269 ];
270
271 return c.html(
272 <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}>
273 <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2>
274 <div class="panel">
275 {shortcuts.map((s) => (
276 <div class="panel-item" style="justify-content: space-between">
277 <span>{s.desc}</span>
278 <kbd
279 style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px"
280 >
281 {s.keys}
282 </kbd>
283 </div>
284 ))}
285 </div>
286 </Layout>
287 );
288});
289
290export default search;
Addedsrc/routes/semantic-search.tsx+325−0View fileUnifiedSplit
1/**
2 * Block D1 — Semantic code search UI + reindex trigger.
3 *
4 * GET /:owner/:repo/search/semantic?q=... — results page (ILIKE parity)
5 * POST /:owner/:repo/search/semantic/reindex — owner-only, fire-and-forget
6 *
7 * Intentionally tolerates a missing DB / missing repo / missing index so the
8 * page is always navigable. When there's no index yet, the page shows a
9 * "Build index" CTA pointing at the reindex endpoint.
10 */
11
12import { Hono } from "hono";
13import { eq, and, desc } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users, codeChunks } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { IssueNav } from "./issues";
19import { softAuth, requireAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import {
22 getDefaultBranch,
23 resolveRef,
24 repoExists,
25} from "../git/repository";
26import {
27 indexRepository,
28 searchRepository,
29 isEmbeddingsProviderAvailable,
30} from "../lib/semantic-search";
31
32const semanticSearch = new Hono<AuthEnv>();
33semanticSearch.use("*", softAuth);
34
35async function resolveRepo(ownerName: string, repoName: string) {
36 try {
37 const [owner] = await db
38 .select()
39 .from(users)
40 .where(eq(users.username, ownerName))
41 .limit(1);
42 if (!owner) return null;
43 const [repo] = await db
44 .select()
45 .from(repositories)
46 .where(
47 and(
48 eq(repositories.ownerId, owner.id),
49 eq(repositories.name, repoName)
50 )
51 )
52 .limit(1);
53 if (!repo) return null;
54 return { owner, repo };
55 } catch {
56 return null;
57 }
58}
59
60function NotFound({ user }: { user: any }) {
61 return (
62 <Layout title="Not Found" user={user}>
63 <div class="empty-state">
64 <h2>Repository not found</h2>
65 <p>No such repository, or you don't have access.</p>
66 </div>
67 </Layout>
68 );
69}
70
71semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
72 const { owner: ownerName, repo: repoName } = c.req.param();
73 const user = c.get("user");
74 const q = (c.req.query("q") || "").trim();
75 const flash = c.req.query("flash");
76
77 const resolved = await resolveRepo(ownerName, repoName);
78 if (!resolved) {
79 return c.html(<NotFound user={user} />, 404);
80 }
81 const { repo } = resolved;
82
83 // Figure out last-indexed state (chunk count + most recent createdAt).
84 let indexedCount = 0;
85 let lastIndexedAt: Date | null = null;
86 let indexedCommitSha: string | null = null;
87 try {
88 // Grab the newest chunk row for metadata (sha + createdAt).
89 const [newest] = await db
90 .select({
91 createdAt: codeChunks.createdAt,
92 commitSha: codeChunks.commitSha,
93 })
94 .from(codeChunks)
95 .where(eq(codeChunks.repositoryId, repo.id))
96 .orderBy(desc(codeChunks.createdAt))
97 .limit(1);
98 if (newest) {
99 lastIndexedAt = newest.createdAt as unknown as Date;
100 indexedCommitSha = newest.commitSha || null;
101 // Rough count for the UI blurb — order of magnitude is fine.
102 const rows = await db
103 .select({ id: codeChunks.id })
104 .from(codeChunks)
105 .where(eq(codeChunks.repositoryId, repo.id))
106 .limit(5000);
107 indexedCount = rows.length;
108 }
109 } catch {
110 // DB unavailable — show the page anyway so the URL always resolves.
111 }
112
113 let hits: Awaited<ReturnType<typeof searchRepository>> = [];
114 if (q && indexedCount > 0) {
115 try {
116 hits = await searchRepository({
117 repositoryId: repo.id,
118 query: q,
119 limit: 20,
120 });
121 } catch {
122 hits = [];
123 }
124 }
125
126 const providers = isEmbeddingsProviderAvailable();
127 const providerLabel = providers.voyage
128 ? "Voyage voyage-code-3"
129 : "lexical fallback (512-dim)";
130
131 const isOwner = !!user && user.id === repo.ownerId;
132 const refForLinks = indexedCommitSha || repo.defaultBranch || "HEAD";
133
134 return c.html(
135 <Layout title={`Semantic search — ${ownerName}/${repoName}`} user={user}>
136 <RepoHeader owner={ownerName} repo={repoName} />
137 <IssueNav owner={ownerName} repo={repoName} active="code" />
138
139 <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px">
140 <h2 style="margin: 0">Semantic search</h2>
141 <div class="meta" style="font-size: 12px">
142 Provider: <strong>{providerLabel}</strong>
143 </div>
144 </div>
145
146 {flash && (
147 <div class="auth-success" style="margin-bottom: 16px">
148 {decodeURIComponent(flash)}
149 </div>
150 )}
151
152 <form
153 method="GET"
154 action={`/${ownerName}/${repoName}/search/semantic`}
155 style="margin-bottom: 16px"
156 >
157 <input
158 type="search"
159 name="q"
160 value={q}
161 placeholder="Ask a question or describe what you're looking for…"
162 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
163 autofocus
164 />
165 </form>
166
167 {indexedCount === 0 ? (
168 <div class="empty-state">
169 <h3>No index yet</h3>
170 <p>
171 This repository hasn't been indexed for semantic search. Build the
172 index to enable AI-powered code lookup.
173 </p>
174 {isOwner ? (
175 <form
176 method="POST"
177 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
178 style="margin-top: 12px"
179 >
180 <button type="submit" class="btn btn-primary">
181 Build index
182 </button>
183 </form>
184 ) : (
185 <p class="meta" style="margin-top: 8px">
186 Only the repository owner can trigger indexing.
187 </p>
188 )}
189 </div>
190 ) : (
191 <>
192 <div
193 class="meta"
194 style="font-size: 12px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center"
195 >
196 <span>
197 {indexedCount} chunk{indexedCount === 1 ? "" : "s"} indexed
198 {lastIndexedAt && (
199 <>
200 {" · last indexed "}
201 {new Date(lastIndexedAt).toLocaleString()}
202 </>
203 )}
204 </span>
205 {isOwner && (
206 <form
207 method="POST"
208 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
209 style="display: inline"
210 >
211 <button type="submit" class="btn btn-sm">
212 Reindex
213 </button>
214 </form>
215 )}
216 </div>
217
218 {!q ? (
219 <div class="empty-state">
220 <p>Type a query to search across this repo's code.</p>
221 </div>
222 ) : hits.length === 0 ? (
223 <div class="empty-state">
224 <p>No results for "{q}"</p>
225 </div>
226 ) : (
227 <div class="panel">
228 {hits.map((h) => {
229 const href = `/${ownerName}/${repoName}/blob/${refForLinks}/${h.path}#L${h.startLine}`;
230 const preview =
231 h.content.length > 600
232 ? h.content.slice(0, 600) + "\n…"
233 : h.content;
234 return (
235 <div class="panel-item" style="flex-direction: column; align-items: stretch">
236 <div
237 style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px"
238 >
239 <a href={href} style="font-weight: 600">
240 {h.path}
241 </a>
242 <span class="meta" style="font-size: 11px">
243 lines {h.startLine}–{h.endLine} · score{" "}
244 {h.score.toFixed(3)}
245 </span>
246 </div>
247 <pre
248 style="margin: 0; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; font-size: 12px; overflow-x: auto; white-space: pre-wrap"
249 >
250 {preview}
251 </pre>
252 </div>
253 );
254 })}
255 </div>
256 )}
257 </>
258 )}
259 </Layout>
260 );
261});
262
263// Owner-only: rebuild the index. Fire-and-forget so the UI doesn't block on
264// potentially multi-minute work. Always redirects.
265semanticSearch.post(
266 "/:owner/:repo/search/semantic/reindex",
267 requireAuth,
268 async (c) => {
269 const { owner: ownerName, repo: repoName } = c.req.param();
270 const user = c.get("user")!;
271
272 const resolved = await resolveRepo(ownerName, repoName);
273 if (!resolved) {
274 return c.redirect(`/${ownerName}/${repoName}`);
275 }
276 const { repo } = resolved;
277
278 if (repo.ownerId !== user.id) {
279 return c.redirect(
280 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
281 "Only the repository owner can trigger indexing."
282 )}`
283 );
284 }
285
286 // Resolve the commit sha at the default branch. If the repo has no
287 // commits yet we bail with a friendly flash.
288 let sha: string | null = null;
289 try {
290 if (await repoExists(ownerName, repoName)) {
291 const branch =
292 (await getDefaultBranch(ownerName, repoName)) ||
293 repo.defaultBranch ||
294 "main";
295 sha = await resolveRef(ownerName, repoName, branch);
296 }
297 } catch {
298 sha = null;
299 }
300
301 if (!sha) {
302 return c.redirect(
303 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
304 "Repository has no commits yet — nothing to index."
305 )}`
306 );
307 }
308
309 // Fire-and-forget. Errors are swallowed inside indexRepository.
310 void indexRepository({
311 owner: ownerName,
312 repo: repoName,
313 repositoryId: repo.id,
314 commitSha: sha,
315 });
316
317 return c.redirect(
318 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
319 "Indexing started — results will appear shortly."
320 )}`
321 );
322 }
323);
324
325export default semanticSearch;
Addedsrc/routes/settings-2fa.tsx+446−0View fileUnifiedSplit
1/**
2 * 2FA settings (Block B4).
3 *
4 * Routes:
5 * GET /settings/2fa status + recovery code management
6 * POST /settings/2fa/enroll generate a pending secret, show QR
7 * GET /settings/2fa/enroll same as POST (for bookmarks)
8 * POST /settings/2fa/confirm verify first code, flip enabled
9 * POST /settings/2fa/disable require password + disable + wipe
10 * POST /settings/2fa/recovery/regen regenerate recovery codes
11 */
12
13import { Hono } from "hono";
14import { eq } from "drizzle-orm";
15import { db } from "../db";
16import { users, userTotp, userRecoveryCodes } from "../db/schema";
17import type { AuthEnv } from "../middleware/auth";
18import { requireAuth } from "../middleware/auth";
19import { Layout } from "../views/layout";
20import { verifyPassword } from "../lib/auth";
21import {
22 generateTotpSecret,
23 otpauthUrl,
24 verifyTotpCode,
25 generateRecoveryCodes,
26 hashRecoveryCode,
27} from "../lib/totp";
28import { audit } from "../lib/notify";
29import { config } from "../lib/config";
30
31const settings2fa = new Hono<AuthEnv>();
32
33settings2fa.use("/settings/2fa", requireAuth);
34settings2fa.use("/settings/2fa/*", requireAuth);
35
36function errorRedirect(path: string, msg: string) {
37 return `${path}?error=${encodeURIComponent(msg)}`;
38}
39
40/** Status page: either "off" (offer enroll), "pending" (finish enrol), "on" (disable + manage codes). */
41settings2fa.get("/settings/2fa", async (c) => {
42 const user = c.get("user")!;
43 const error = c.req.query("error");
44 const success = c.req.query("success");
45
46 let state: "off" | "pending" | "on" = "off";
47 try {
48 const [row] = await db
49 .select({ enabledAt: userTotp.enabledAt })
50 .from(userTotp)
51 .where(eq(userTotp.userId, user.id))
52 .limit(1);
53 if (row) state = row.enabledAt ? "on" : "pending";
54 } catch (err) {
55 console.error("[2fa] status:", err);
56 }
57
58 let unusedRecovery = 0;
59 try {
60 const rows = await db
61 .select({ usedAt: userRecoveryCodes.usedAt })
62 .from(userRecoveryCodes)
63 .where(eq(userRecoveryCodes.userId, user.id));
64 unusedRecovery = rows.filter((r) => !r.usedAt).length;
65 } catch {
66 /* ignore */
67 }
68
69 return c.html(
70 <Layout title="Two-factor authentication" user={user}>
71 <div class="settings-container">
72 <div class="breadcrumb">
73 <a href="/settings">settings</a>
74 <span>/</span>
75 <span>2fa</span>
76 </div>
77 <h2>Two-factor authentication</h2>
78 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
79 {success && (
80 <div class="auth-success">{decodeURIComponent(success)}</div>
81 )}
82 <p style="color: var(--text-muted); font-size: 13px">
83 Require a 6-digit code from your authenticator app on every sign-in.
84 Works with Google Authenticator, 1Password, Bitwarden, Authy, and any
85 other TOTP-compatible app.
86 </p>
87
88 {state === "off" && (
89 <form method="POST" action="/settings/2fa/enroll">
90 <button type="submit" class="btn btn-primary">
91 Enable two-factor authentication
92 </button>
93 </form>
94 )}
95
96 {state === "pending" && (
97 <>
98 <p
99 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow, #d29922); padding: 8px 12px; border-radius: var(--radius); color: var(--yellow, #d29922); font-size: 13px"
100 >
101 Enrolment started but not confirmed. Finish by entering a code
102 from your authenticator below.
103 </p>
104 <a href="/settings/2fa/enroll" class="btn btn-primary">
105 Continue enrolment
106 </a>
107 </>
108 )}
109
110 {state === "on" && (
111 <>
112 <div
113 style="background: rgba(63, 185, 80, 0.1); border: 1px solid var(--green); padding: 8px 12px; border-radius: var(--radius); color: var(--green); font-size: 13px; margin-bottom: 16px"
114 >
115 Two-factor authentication is enabled.
116 </div>
117 <h3 style="font-size: 15px; margin-top: 16px">Recovery codes</h3>
118 <p style="color: var(--text-muted); font-size: 13px">
119 {unusedRecovery} unused recovery code
120 {unusedRecovery === 1 ? "" : "s"} remaining. Each code can be
121 used once if you lose access to your authenticator.
122 </p>
123 <form
124 method="POST"
125 action="/settings/2fa/recovery/regen"
126 style="display: inline-block; margin-right: 8px"
127 onsubmit="return confirm('Regenerate recovery codes? Your existing codes will stop working.')"
128 >
129 <button type="submit" class="btn">
130 Regenerate recovery codes
131 </button>
132 </form>
133
134 <h3 style="font-size: 15px; margin-top: 24px">Disable</h3>
135 <p style="color: var(--text-muted); font-size: 13px">
136 Confirm your password to turn off 2FA.
137 </p>
138 <form method="POST" action="/settings/2fa/disable">
139 <div class="form-group" style="max-width: 320px">
140 <label for="password">Password</label>
141 <input
142 type="password"
143 name="password"
144 required
145 autocomplete="current-password"
146 />
147 </div>
148 <button type="submit" class="btn btn-danger">
149 Disable two-factor authentication
150 </button>
151 </form>
152 </>
153 )}
154 </div>
155 </Layout>
156 );
157});
158
159/** Generate (or re-use pending) secret + show the QR enrolment page. */
160async function showEnrolPage(c: any, user: any, error?: string) {
161 let secret: string;
162 try {
163 const [existing] = await db
164 .select()
165 .from(userTotp)
166 .where(eq(userTotp.userId, user.id))
167 .limit(1);
168 if (existing && !existing.enabledAt) {
169 secret = existing.secret;
170 } else if (existing && existing.enabledAt) {
171 return c.redirect(
172 errorRedirect("/settings/2fa", "2FA is already enabled")
173 );
174 } else {
175 secret = generateTotpSecret();
176 await db.insert(userTotp).values({ userId: user.id, secret });
177 }
178 } catch (err) {
179 console.error("[2fa] enroll:", err);
180 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
181 }
182
183 const url = otpauthUrl({
184 secret,
185 accountName: user.email || user.username,
186 issuer: "gluecron",
187 });
188 // Render a simple data URL QR via a public chart service fallback —
189 // but we avoid external deps and instead show the secret + URL so any
190 // authenticator can be set up manually. Apps scan otpauth:// directly.
191 return c.html(
192 <Layout title="Enable 2FA" user={user}>
193 <div class="settings-container">
194 <div class="breadcrumb">
195 <a href="/settings">settings</a>
196 <span>/</span>
197 <a href="/settings/2fa">2fa</a>
198 <span>/</span>
199 <span>enrol</span>
200 </div>
201 <h2>Set up your authenticator</h2>
202 {error && <div class="auth-error">{error}</div>}
203 <ol
204 style="color: var(--text-muted); font-size: 14px; line-height: 1.6; padding-left: 20px"
205 >
206 <li>
207 Open your authenticator app (Google Authenticator, 1Password,
208 Bitwarden, Authy, etc).
209 </li>
210 <li>
211 Add a new entry. Either scan the otpauth link below with a QR
212 reader, or type in the secret key manually.
213 </li>
214 <li>Enter the 6-digit code the app shows to confirm.</li>
215 </ol>
216 <div
217 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; margin: 16px 0"
218 >
219 <div style="font-size: 12px; color: var(--text-muted)">Secret key</div>
220 <code
221 style="font-size: 14px; font-family: monospace; word-break: break-all"
222 >
223 {secret}
224 </code>
225 <div
226 style="font-size: 12px; color: var(--text-muted); margin-top: 12px"
227 >
228 otpauth URL (for QR apps)
229 </div>
230 <code
231 style="font-size: 12px; font-family: monospace; word-break: break-all; color: var(--text)"
232 >
233 {url}
234 </code>
235 </div>
236 <form method="POST" action="/settings/2fa/confirm">
237 <div class="form-group" style="max-width: 280px">
238 <label for="code">6-digit code</label>
239 <input
240 type="text"
241 name="code"
242 required
243 pattern="[0-9]{6}"
244 inputmode="numeric"
245 autocomplete="one-time-code"
246 maxLength={6}
247 />
248 </div>
249 <button type="submit" class="btn btn-primary">
250 Confirm + enable
251 </button>
252 </form>
253 </div>
254 </Layout>
255 );
256}
257
258settings2fa.get("/settings/2fa/enroll", async (c) => {
259 const user = c.get("user")!;
260 return showEnrolPage(c, user);
261});
262
263settings2fa.post("/settings/2fa/enroll", async (c) => {
264 const user = c.get("user")!;
265 return showEnrolPage(c, user);
266});
267
268/** Verify the first code + flip enabled. Also mint recovery codes. */
269settings2fa.post("/settings/2fa/confirm", async (c) => {
270 const user = c.get("user")!;
271 const body = await c.req.parseBody();
272 const code = String(body.code || "").trim();
273
274 if (!/^\d{6}$/.test(code)) {
275 return c.redirect(
276 errorRedirect("/settings/2fa/enroll", "Enter the 6-digit code")
277 );
278 }
279
280 try {
281 const [row] = await db
282 .select()
283 .from(userTotp)
284 .where(eq(userTotp.userId, user.id))
285 .limit(1);
286 if (!row || row.enabledAt) {
287 return c.redirect("/settings/2fa");
288 }
289 const ok = await verifyTotpCode(row.secret, code);
290 if (!ok) {
291 return c.redirect(
292 errorRedirect(
293 "/settings/2fa/enroll",
294 "Code did not verify — try again"
295 )
296 );
297 }
298 await db
299 .update(userTotp)
300 .set({ enabledAt: new Date(), lastUsedAt: new Date() })
301 .where(eq(userTotp.userId, user.id));
302
303 // Mint + store recovery codes
304 const codes = generateRecoveryCodes(10);
305 const hashes = await Promise.all(codes.map(hashRecoveryCode));
306 await db.delete(userRecoveryCodes).where(eq(userRecoveryCodes.userId, user.id));
307 await db.insert(userRecoveryCodes).values(
308 hashes.map((h) => ({ userId: user.id, codeHash: h }))
309 );
310
311 await audit({
312 userId: user.id,
313 action: "2fa.enable",
314 targetType: "user",
315 targetId: user.id,
316 });
317
318 return c.html(
319 <Layout title="Save your recovery codes" user={user}>
320 <div class="settings-container">
321 <div class="breadcrumb">
322 <a href="/settings">settings</a>
323 <span>/</span>
324 <a href="/settings/2fa">2fa</a>
325 <span>/</span>
326 <span>recovery codes</span>
327 </div>
328 <h2>Save your recovery codes</h2>
329 <div
330 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
331 >
332 These codes are shown only once. Store them somewhere safe — a
333 password manager, a printed copy. Each code works once.
334 </div>
335 <pre
336 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
337 >
338{codes.join("\n")}
339 </pre>
340 <a href="/settings/2fa" class="btn btn-primary">
341 I've saved them
342 </a>
343 </div>
344 </Layout>
345 );
346 } catch (err) {
347 console.error("[2fa] confirm:", err);
348 return c.redirect(
349 errorRedirect("/settings/2fa/enroll", "Service unavailable")
350 );
351 }
352});
353
354settings2fa.post("/settings/2fa/disable", async (c) => {
355 const user = c.get("user")!;
356 const body = await c.req.parseBody();
357 const password = String(body.password || "");
358 if (!password) {
359 return c.redirect(
360 errorRedirect("/settings/2fa", "Password is required")
361 );
362 }
363 try {
364 const [u] = await db
365 .select({ passwordHash: users.passwordHash })
366 .from(users)
367 .where(eq(users.id, user.id))
368 .limit(1);
369 if (!u || !(await verifyPassword(password, u.passwordHash))) {
370 return c.redirect(errorRedirect("/settings/2fa", "Invalid password"));
371 }
372 await db.delete(userTotp).where(eq(userTotp.userId, user.id));
373 await db
374 .delete(userRecoveryCodes)
375 .where(eq(userRecoveryCodes.userId, user.id));
376 await audit({
377 userId: user.id,
378 action: "2fa.disable",
379 targetType: "user",
380 targetId: user.id,
381 });
382 return c.redirect("/settings/2fa?success=Two-factor+disabled");
383 } catch (err) {
384 console.error("[2fa] disable:", err);
385 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
386 }
387});
388
389settings2fa.post("/settings/2fa/recovery/regen", async (c) => {
390 const user = c.get("user")!;
391 try {
392 const [row] = await db
393 .select({ enabledAt: userTotp.enabledAt })
394 .from(userTotp)
395 .where(eq(userTotp.userId, user.id))
396 .limit(1);
397 if (!row || !row.enabledAt) {
398 return c.redirect(
399 errorRedirect("/settings/2fa", "Enable 2FA first")
400 );
401 }
402 const codes = generateRecoveryCodes(10);
403 const hashes = await Promise.all(codes.map(hashRecoveryCode));
404 await db
405 .delete(userRecoveryCodes)
406 .where(eq(userRecoveryCodes.userId, user.id));
407 await db.insert(userRecoveryCodes).values(
408 hashes.map((h) => ({ userId: user.id, codeHash: h }))
409 );
410 await audit({
411 userId: user.id,
412 action: "2fa.recovery.regenerate",
413 targetType: "user",
414 targetId: user.id,
415 });
416 return c.html(
417 <Layout title="New recovery codes" user={user}>
418 <div class="settings-container">
419 <h2>New recovery codes</h2>
420 <div
421 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
422 >
423 Store these somewhere safe — the previous codes no longer work.
424 </div>
425 <pre
426 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
427 >
428{codes.join("\n")}
429 </pre>
430 <a href="/settings/2fa" class="btn btn-primary">
431 Done
432 </a>
433 </div>
434 </Layout>
435 );
436 } catch (err) {
437 console.error("[2fa] regen:", err);
438 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
439 }
440});
441
442// Keep the import-check happy — config is intentionally available for
443// future issuer customisation.
444void config;
445
446export default settings2fa;
Modifiedsrc/routes/settings.tsx+21−0View fileUnifiedSplit
8282 </Button>
8383 </Form>
8484 </div>
85 <p style="margin-top:20px">
86 <a href="/settings">Back to settings</a>
87 </p>
8588 </Layout>
8689 );
8790});
8891
92settings.post("/settings/notifications", async (c) => {
93 const user = c.get("user")!;
94 const body = await c.req.parseBody();
95 await db
96 .update(users)
97 .set({
98 notifyEmailOnMention: String(body.notify_email_on_mention || "") === "1",
99 notifyEmailOnAssign: String(body.notify_email_on_assign || "") === "1",
100 notifyEmailOnGateFail:
101 String(body.notify_email_on_gate_fail || "") === "1",
102 notifyEmailDigestWeekly:
103 String(body.notify_email_digest_weekly || "") === "1",
104 updatedAt: new Date(),
105 })
106 .where(eq(users.id, user.id));
107 return c.redirect("/settings?success=Email+preferences+updated");
108});
109
89110settings.post("/settings/profile", async (c) => {
90111 const user = c.get("user")!;
91112 const body = await c.req.parseBody();
Addedsrc/routes/signing-keys.tsx+209−0View fileUnifiedSplit
1/**
2 * Block J3 — Signing keys UI.
3 *
4 * GET /settings/signing-keys — list + add form
5 * POST /settings/signing-keys — add new key
6 * POST /settings/signing-keys/:id/delete
7 */
8
9import { Hono } from "hono";
10import { Layout } from "../views/layout";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import {
14 addSigningKey,
15 deleteSigningKey,
16 listSigningKeysForUser,
17} from "../lib/signatures";
18import { audit } from "../lib/notify";
19
20const signingKeysRoutes = new Hono<AuthEnv>();
21signingKeysRoutes.use("/settings/signing-keys", requireAuth);
22signingKeysRoutes.use("/settings/signing-keys/*", requireAuth);
23
24signingKeysRoutes.get("/settings/signing-keys", async (c) => {
25 const user = c.get("user")!;
26 const keys = await listSigningKeysForUser(user.id);
27 const message = c.req.query("message");
28 const error = c.req.query("error");
29 return c.html(
30 <Layout title="Signing keys" user={user}>
31 <div class="settings-container">
32 <h2>Signing keys</h2>
33 <p style="color:var(--text-muted)">
34 Register the GPG or SSH public key you use for{" "}
35 <code>git commit -S</code>. Commits we can match to a registered key
36 render with a <span style="color:var(--green);font-weight:600">Verified</span>{" "}
37 badge. This is identity matching by fingerprint — cryptographic
38 verification is future work.
39 </p>
40 {message && (
41 <div class="auth-success" style="margin-top:12px">
42 {decodeURIComponent(message)}
43 </div>
44 )}
45 {error && (
46 <div class="auth-error" style="margin-top:12px">
47 {decodeURIComponent(error)}
48 </div>
49 )}
50
51 <h3 style="margin-top:24px">Your keys</h3>
52 {keys.length === 0 ? (
53 <div class="panel-empty" style="padding:16px">
54 No signing keys yet.
55 </div>
56 ) : (
57 <div class="panel">
58 {keys.map((k) => (
59 <div
60 class="panel-item"
61 style="flex-direction:column;align-items:stretch;gap:4px"
62 >
63 <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap">
64 <div>
65 <span
66 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px"
67 >
68 {k.keyType}
69 </span>
70 <span style="font-weight:600">{k.title}</span>
71 {k.email && (
72 <span
73 style="font-size:12px;color:var(--text-muted);margin-left:8px"
74 >
75 {k.email}
76 </span>
77 )}
78 </div>
79 <form
80 method="POST"
81 action={`/settings/signing-keys/${k.id}/delete`}
82 >
83 <button
84 type="submit"
85 class="btn btn-sm"
86 style="font-size:11px"
87 >
88 Delete
89 </button>
90 </form>
91 </div>
92 <div
93 style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);word-break:break-all"
94 >
95 {k.fingerprint}
96 </div>
97 </div>
98 ))}
99 </div>
100 )}
101
102 <h3 style="margin-top:24px">Add a key</h3>
103 <form
104 method="POST"
105 action="/settings/signing-keys"
106 class="auth-form"
107 style="max-width:720px"
108 >
109 <div class="form-group">
110 <label for="sk-title">Title</label>
111 <input
112 type="text"
113 id="sk-title"
114 name="title"
115 placeholder="e.g. Work laptop"
116 required
117 maxLength={120}
118 />
119 </div>
120 <div class="form-group">
121 <label for="sk-type">Key type</label>
122 <select id="sk-type" name="key_type" required>
123 <option value="gpg">GPG</option>
124 <option value="ssh">SSH</option>
125 </select>
126 </div>
127 <div class="form-group">
128 <label for="sk-email">Email (optional)</label>
129 <input
130 type="email"
131 id="sk-email"
132 name="email"
133 placeholder="commit-author@example.com"
134 maxLength={200}
135 />
136 </div>
137 <div class="form-group">
138 <label for="sk-public">Public key</label>
139 <textarea
140 id="sk-public"
141 name="public_key"
142 rows={10}
143 required
144 placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----&#10;...&#10;-----END PGP PUBLIC KEY BLOCK-----&#10;&#10;or: ssh-ed25519 AAAA... you@laptop"
145 style="font-family:var(--font-mono);font-size:12px"
146 />
147 </div>
148 <button type="submit" class="btn btn-primary">
149 Add key
150 </button>
151 </form>
152 </div>
153 </Layout>
154 );
155});
156
157signingKeysRoutes.post("/settings/signing-keys", async (c) => {
158 const user = c.get("user")!;
159 const body = await c.req.parseBody();
160 const keyType = String(body.key_type || "").toLowerCase() as "gpg" | "ssh";
161 const title = String(body.title || "");
162 const publicKey = String(body.public_key || "");
163 const email = String(body.email || "");
164
165 const result = await addSigningKey({
166 userId: user.id,
167 keyType,
168 title,
169 publicKey,
170 email,
171 });
172
173 if (!result.ok) {
174 return c.redirect(
175 `/settings/signing-keys?error=${encodeURIComponent(result.error)}`
176 );
177 }
178 await audit({
179 userId: user.id,
180 action: "signing_keys.add",
181 targetId: result.id,
182 metadata: { keyType, fingerprint: result.fingerprint },
183 });
184 return c.redirect(
185 `/settings/signing-keys?message=${encodeURIComponent(
186 `Added key ${result.fingerprint.slice(0, 24)}…`
187 )}`
188 );
189});
190
191signingKeysRoutes.post("/settings/signing-keys/:id/delete", async (c) => {
192 const user = c.get("user")!;
193 const id = c.req.param("id");
194 const ok = await deleteSigningKey(id, user.id);
195 if (ok) {
196 await audit({
197 userId: user.id,
198 action: "signing_keys.delete",
199 targetId: id,
200 });
201 }
202 return c.redirect(
203 `/settings/signing-keys?${ok ? "message" : "error"}=${encodeURIComponent(
204 ok ? "Key removed." : "Key not found"
205 )}`
206 );
207});
208
209export default signingKeysRoutes;
Addedsrc/routes/sponsors.tsx+432−0View fileUnifiedSplit
1/**
2 * Block I6 — Sponsors.
3 *
4 * GET /sponsors/:username — public sponsor page
5 * GET /settings/sponsors — maintain your own tiers + activity
6 * POST /settings/sponsors/tiers/new — publish a tier
7 * POST /settings/sponsors/tiers/:id/delete — retire a tier
8 * POST /sponsors/:username — record a sponsorship
9 *
10 * Payment rails are out of scope — this captures intent + thank-you notes.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, isNull, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 sponsorships,
18 sponsorshipTiers,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24
25const sponsors = new Hono<AuthEnv>();
26sponsors.use("*", softAuth);
27
28function formatCents(cents: number): string {
29 if (cents === 0) return "Any amount";
30 const dollars = (cents / 100).toFixed(2);
31 return `$${dollars}`;
32}
33
34// ---------- Public sponsor page ----------
35
36sponsors.get("/sponsors/:username", async (c) => {
37 const user = c.get("user");
38 const targetName = c.req.param("username");
39 const [target] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, targetName))
43 .limit(1);
44 if (!target) return c.notFound();
45
46 const tiers = await db
47 .select()
48 .from(sponsorshipTiers)
49 .where(
50 and(
51 eq(sponsorshipTiers.maintainerId, target.id),
52 eq(sponsorshipTiers.isActive, true)
53 )
54 )
55 .orderBy(sponsorshipTiers.monthlyCents);
56
57 const recentPublic = await db
58 .select({
59 id: sponsorships.id,
60 amountCents: sponsorships.amountCents,
61 createdAt: sponsorships.createdAt,
62 note: sponsorships.note,
63 sponsorName: users.username,
64 })
65 .from(sponsorships)
66 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
67 .where(
68 and(
69 eq(sponsorships.maintainerId, target.id),
70 eq(sponsorships.isPublic, true),
71 isNull(sponsorships.cancelledAt)
72 )
73 )
74 .orderBy(desc(sponsorships.createdAt))
75 .limit(20);
76
77 return c.html(
78 <Layout title={`Sponsor ${targetName}`} user={user}>
79 <h2>Sponsor {targetName}</h2>
80 <p
81 style="font-size:14px;color:var(--text-muted);margin:8px 0 20px"
82 >
83 Support {targetName}'s open-source work on Gluecron.
84 </p>
85
86 {tiers.length === 0 ? (
87 <div class="panel" style="padding:16px">
88 <p style="margin-bottom:12px">
89 {targetName} hasn't published any sponsorship tiers yet.
90 </p>
91 {user ? (
92 <form method="POST" action={`/sponsors/${targetName}`}>
93 <input
94 type="number"
95 name="amount_cents"
96 placeholder="Amount in cents (e.g. 500 = $5)"
97 min="100"
98 required
99 style="width:60%"
100 />{" "}
101 <button type="submit" class="btn btn-primary">
102 Sponsor (one-time)
103 </button>
104 </form>
105 ) : (
106 <a href={`/login?next=/sponsors/${targetName}`}>
107 Sign in to sponsor
108 </a>
109 )}
110 </div>
111 ) : (
112 <div
113 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:20px"
114 >
115 {tiers.map((t) => (
116 <form
117 method="POST"
118 action={`/sponsors/${targetName}`}
119 class="panel"
120 style="padding:16px;display:flex;flex-direction:column;gap:8px"
121 >
122 <input type="hidden" name="tier_id" value={t.id} />
123 <div style="font-weight:700;font-size:16px">{t.name}</div>
124 <div style="font-size:22px;color:var(--accent);font-weight:700">
125 {formatCents(t.monthlyCents)}
126 {t.monthlyCents > 0 && (
127 <span style="font-size:13px;color:var(--text-muted);font-weight:400">
128 /mo
129 </span>
130 )}
131 </div>
132 <div
133 style="font-size:13px;color:var(--text-muted);flex:1"
134 >
135 {t.description || "\u2014"}
136 </div>
137 {user ? (
138 <>
139 <select name="kind" style="font-size:13px">
140 <option value="monthly">Monthly</option>
141 {t.oneTimeAllowed && (
142 <option value="one_time">One-time</option>
143 )}
144 </select>
145 <button type="submit" class="btn btn-primary">
146 Sponsor
147 </button>
148 </>
149 ) : (
150 <a
151 href={`/login?next=/sponsors/${targetName}`}
152 class="btn"
153 style="text-align:center"
154 >
155 Sign in to sponsor
156 </a>
157 )}
158 </form>
159 ))}
160 </div>
161 )}
162
163 <h3>Recent sponsors</h3>
164 <div class="panel">
165 {recentPublic.length === 0 ? (
166 <div class="panel-empty">Be the first to sponsor.</div>
167 ) : (
168 recentPublic.map((s) => (
169 <div class="panel-item" style="justify-content:space-between">
170 <div>
171 <a href={`/${s.sponsorName}`}>
172 <strong>{s.sponsorName}</strong>
173 </a>
174 {s.note && (
175 <span
176 style="margin-left:8px;font-size:13px;color:var(--text-muted)"
177 >
178 "{s.note}"
179 </span>
180 )}
181 </div>
182 <div
183 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
184 >
185 {formatCents(s.amountCents)} ·{" "}
186 {new Date(s.createdAt).toLocaleDateString()}
187 </div>
188 </div>
189 ))
190 )}
191 </div>
192 </Layout>
193 );
194});
195
196// Record a sponsorship
197sponsors.post("/sponsors/:username", requireAuth, async (c) => {
198 const user = c.get("user")!;
199 const targetName = c.req.param("username");
200 const [target] = await db
201 .select()
202 .from(users)
203 .where(eq(users.username, targetName))
204 .limit(1);
205 if (!target) return c.notFound();
206 if (target.id === user.id) {
207 return c.redirect(`/sponsors/${targetName}`);
208 }
209 const body = await c.req.parseBody();
210 const tierId = body.tier_id ? String(body.tier_id) : null;
211 let amountCents = 0;
212 let kind = String(body.kind || "one_time");
213 if (kind !== "monthly" && kind !== "one_time") kind = "one_time";
214
215 if (tierId) {
216 const [tier] = await db
217 .select()
218 .from(sponsorshipTiers)
219 .where(eq(sponsorshipTiers.id, tierId))
220 .limit(1);
221 if (!tier || tier.maintainerId !== target.id) {
222 return c.redirect(`/sponsors/${targetName}`);
223 }
224 amountCents = tier.monthlyCents;
225 } else {
226 amountCents = Math.max(0, parseInt(String(body.amount_cents || "0"), 10));
227 }
228 if (amountCents <= 0 && !tierId) {
229 return c.redirect(`/sponsors/${targetName}`);
230 }
231
232 await db.insert(sponsorships).values({
233 sponsorId: user.id,
234 maintainerId: target.id,
235 tierId: tierId || null,
236 amountCents,
237 kind,
238 note: body.note ? String(body.note).slice(0, 200) : null,
239 isPublic: body.is_public !== "0",
240 });
241 return c.redirect(`/sponsors/${targetName}?thanks=1`);
242});
243
244// ---------- Maintainer settings ----------
245
246sponsors.get("/settings/sponsors", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const [tiers, activity] = await Promise.all([
249 db
250 .select()
251 .from(sponsorshipTiers)
252 .where(eq(sponsorshipTiers.maintainerId, user.id))
253 .orderBy(sponsorshipTiers.monthlyCents),
254 db
255 .select({
256 id: sponsorships.id,
257 amountCents: sponsorships.amountCents,
258 kind: sponsorships.kind,
259 createdAt: sponsorships.createdAt,
260 sponsorName: users.username,
261 })
262 .from(sponsorships)
263 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
264 .where(eq(sponsorships.maintainerId, user.id))
265 .orderBy(desc(sponsorships.createdAt))
266 .limit(50),
267 ]);
268 const total = activity.reduce((sum, s) => sum + s.amountCents, 0);
269 return c.html(
270 <Layout title="Sponsorship settings" user={user}>
271 <h2>Sponsorship</h2>
272 <p style="color:var(--text-muted);margin-bottom:16px">
273 Your public sponsor page is at{" "}
274 <a href={`/sponsors/${user.username}`}>/sponsors/{user.username}</a>.
275 </p>
276
277 <div class="panel" style="padding:16px;margin-bottom:20px">
278 <div style="font-size:12px;color:var(--text-muted)">
279 Total received
280 </div>
281 <div style="font-size:24px;font-weight:700">
282 {formatCents(total)}
283 </div>
284 </div>
285
286 <h3>Tiers</h3>
287 <div class="panel" style="margin-bottom:20px">
288 {tiers.length === 0 ? (
289 <div class="panel-empty">
290 No tiers yet. Add one below to start accepting support.
291 </div>
292 ) : (
293 tiers.map((t) => (
294 <div class="panel-item" style="justify-content:space-between">
295 <div>
296 <div style="font-weight:600">{t.name}</div>
297 <div
298 style="font-size:12px;color:var(--text-muted);margin-top:2px"
299 >
300 {formatCents(t.monthlyCents)}/mo ·{" "}
301 {t.description || "no description"}
302 </div>
303 </div>
304 <form
305 method="POST"
306 action={`/settings/sponsors/tiers/${t.id}/delete`}
307 onsubmit="return confirm('Retire this tier?')"
308 >
309 <button type="submit" class="btn btn-sm btn-danger">
310 Retire
311 </button>
312 </form>
313 </div>
314 ))
315 )}
316 </div>
317
318 <h3>Add a tier</h3>
319 <form
320 method="POST"
321 action="/settings/sponsors/tiers/new"
322 class="panel"
323 style="padding:16px"
324 >
325 <div class="form-group">
326 <label>Name</label>
327 <input type="text" name="name" required style="width:100%" />
328 </div>
329 <div class="form-group">
330 <label>Description</label>
331 <textarea name="description" rows="2" style="width:100%" />
332 </div>
333 <div class="form-group">
334 <label>Monthly amount (cents)</label>
335 <input
336 type="number"
337 name="monthly_cents"
338 min="0"
339 placeholder="500 = $5/mo"
340 required
341 />
342 </div>
343 <button type="submit" class="btn btn-primary">
344 Add tier
345 </button>
346 </form>
347
348 <h3 style="margin-top:24px">Recent activity</h3>
349 <div class="panel">
350 {activity.length === 0 ? (
351 <div class="panel-empty">No sponsors yet.</div>
352 ) : (
353 activity.map((a) => (
354 <div class="panel-item" style="justify-content:space-between">
355 <div>
356 <a href={`/${a.sponsorName}`}>{a.sponsorName}</a>
357 <span
358 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
359 >
360 {a.kind}
361 </span>
362 </div>
363 <div
364 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
365 >
366 {formatCents(a.amountCents)} ·{" "}
367 {new Date(a.createdAt).toLocaleDateString()}
368 </div>
369 </div>
370 ))
371 )}
372 </div>
373 </Layout>
374 );
375});
376
377sponsors.post("/settings/sponsors/tiers/new", requireAuth, async (c) => {
378 const user = c.get("user")!;
379 const body = await c.req.parseBody();
380 const name = String(body.name || "").trim();
381 if (!name) return c.redirect("/settings/sponsors");
382 const monthlyCents = Math.max(
383 0,
384 parseInt(String(body.monthly_cents || "0"), 10)
385 );
386 await db.insert(sponsorshipTiers).values({
387 maintainerId: user.id,
388 name,
389 description: String(body.description || ""),
390 monthlyCents,
391 });
392 return c.redirect("/settings/sponsors");
393});
394
395sponsors.post(
396 "/settings/sponsors/tiers/:id/delete",
397 requireAuth,
398 async (c) => {
399 const user = c.get("user")!;
400 const id = c.req.param("id");
401 await db
402 .update(sponsorshipTiers)
403 .set({ isActive: false })
404 .where(
405 and(
406 eq(sponsorshipTiers.id, id),
407 eq(sponsorshipTiers.maintainerId, user.id)
408 )
409 );
410 return c.redirect("/settings/sponsors");
411 }
412);
413
414// Handy stat helper for other pages
415export async function sponsorshipTotalForUser(
416 userId: string
417): Promise<number> {
418 try {
419 const [r] = await db
420 .select({ n: sql<number>`coalesce(sum(${sponsorships.amountCents}), 0)::int` })
421 .from(sponsorships)
422 .where(eq(sponsorships.maintainerId, userId));
423 return Number(r?.n || 0);
424 } catch {
425 return 0;
426 }
427}
428
429/** Test-only hook. */
430export const __internal = { formatCents };
431
432export default sponsors;
Addedsrc/routes/sso.tsx+426−0View fileUnifiedSplit
1/**
2 * Block I10 — Enterprise SSO (OIDC) routes.
3 *
4 * GET /admin/sso — site-admin config page
5 * POST /admin/sso — save config
6 * GET /login/sso — begin OIDC flow (redirect to IdP)
7 * GET /login/sso/callback — handle IdP redirect, create session
8 * POST /settings/sso/unlink — user drops their SSO link
9 */
10
11import { Hono } from "hono";
12import { eq } from "drizzle-orm";
13import { getCookie, setCookie, deleteCookie } from "hono/cookie";
14import { db } from "../db";
15import { ssoUserLinks } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { isSiteAdmin } from "../lib/admin";
20import { audit } from "../lib/notify";
21import {
22 buildAuthorizeUrl,
23 exchangeCode,
24 fetchUserinfo,
25 findOrCreateUserFromSso,
26 getSsoConfig,
27 issueSsoSession,
28 randomToken,
29 ssoRedirectUri,
30 upsertSsoConfig,
31} from "../lib/sso";
32import { sessionCookieOptions } from "../lib/auth";
33
34const sso = new Hono<AuthEnv>();
35sso.use("*", softAuth);
36
37// Re-export the shared cookie options under the SSO namespace (buildable types)
38// — defined here so we don't double-import under the same name.
39function ssoStateCookieOpts(): {
40 httpOnly: boolean;
41 secure: boolean;
42 sameSite: "Lax";
43 path: string;
44 maxAge: number;
45} {
46 return {
47 httpOnly: true,
48 secure: process.env.NODE_ENV === "production",
49 sameSite: "Lax",
50 path: "/",
51 maxAge: 600, // 10 min to complete the flow
52 };
53}
54
55// ----------------------------------------------------------------------------
56// Admin config page
57// ----------------------------------------------------------------------------
58
59async function adminGate(c: any): Promise<{ user: any } | Response> {
60 const user = c.get("user");
61 if (!user) return c.redirect("/login?next=/admin/sso");
62 if (!(await isSiteAdmin(user.id))) {
63 return c.html(
64 <Layout title="Forbidden" user={user}>
65 <div class="empty-state">
66 <h2>403 — Not a site admin</h2>
67 <p>You don't have permission to configure SSO.</p>
68 </div>
69 </Layout>,
70 403
71 );
72 }
73 return { user };
74}
75
76sso.get("/admin/sso", requireAuth, async (c) => {
77 const g = await adminGate(c);
78 if (g instanceof Response) return g;
79 const { user } = g;
80
81 const cfg = await getSsoConfig();
82 const success = c.req.query("success");
83 const error = c.req.query("error");
84 const redirectUri = ssoRedirectUri();
85
86 return c.html(
87 <Layout title="SSO — Admin" user={user}>
88 <div class="settings-container" style="max-width:780px">
89 <h2>Enterprise SSO (OpenID Connect)</h2>
90 <p style="color:var(--text-muted)">
91 Configure a single site-wide OIDC provider. Users will see a
92 "Sign in with{" "}
93 <code>{cfg?.providerName || "SSO"}</code>" button on /login.
94 </p>
95 <div class="panel" style="padding:12px;margin-bottom:16px">
96 <div
97 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
98 >
99 Redirect URI — paste this into your IdP
100 </div>
101 <code style="font-size:13px">{redirectUri}</code>
102 </div>
103
104 {success && (
105 <div class="auth-success">{decodeURIComponent(success)}</div>
106 )}
107 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
108
109 <form
110 method="POST"
111 action="/admin/sso"
112 class="panel"
113 style="padding:16px"
114 >
115 <label
116 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
117 >
118 <input
119 type="checkbox"
120 name="enabled"
121 value="1"
122 checked={!!cfg?.enabled}
123 />
124 <span>Enable SSO sign-in on /login</span>
125 </label>
126 <div class="form-group">
127 <label for="provider_name">Button label</label>
128 <input
129 type="text"
130 id="provider_name"
131 name="provider_name"
132 value={cfg?.providerName || "SSO"}
133 maxLength={120}
134 placeholder="Okta"
135 />
136 </div>
137 <div class="form-group">
138 <label for="issuer">Issuer URL</label>
139 <input
140 type="text"
141 id="issuer"
142 name="issuer"
143 value={cfg?.issuer || ""}
144 placeholder="https://example.okta.com"
145 />
146 </div>
147 <div class="form-group">
148 <label for="authorization_endpoint">Authorization endpoint</label>
149 <input
150 type="text"
151 id="authorization_endpoint"
152 name="authorization_endpoint"
153 value={cfg?.authorizationEndpoint || ""}
154 placeholder="https://example.okta.com/oauth2/v1/authorize"
155 />
156 </div>
157 <div class="form-group">
158 <label for="token_endpoint">Token endpoint</label>
159 <input
160 type="text"
161 id="token_endpoint"
162 name="token_endpoint"
163 value={cfg?.tokenEndpoint || ""}
164 placeholder="https://example.okta.com/oauth2/v1/token"
165 />
166 </div>
167 <div class="form-group">
168 <label for="userinfo_endpoint">Userinfo endpoint</label>
169 <input
170 type="text"
171 id="userinfo_endpoint"
172 name="userinfo_endpoint"
173 value={cfg?.userinfoEndpoint || ""}
174 placeholder="https://example.okta.com/oauth2/v1/userinfo"
175 />
176 </div>
177 <div class="form-group">
178 <label for="client_id">Client ID</label>
179 <input
180 type="text"
181 id="client_id"
182 name="client_id"
183 value={cfg?.clientId || ""}
184 autocomplete="off"
185 />
186 </div>
187 <div class="form-group">
188 <label for="client_secret">Client secret</label>
189 <input
190 type="password"
191 id="client_secret"
192 name="client_secret"
193 value={cfg?.clientSecret || ""}
194 autocomplete="off"
195 placeholder={
196 cfg?.clientSecret ? "(storedleave blank to keep)" : ""
197 }
198 />
199 </div>
200 <div class="form-group">
201 <label for="scopes">OIDC scopes</label>
202 <input
203 type="text"
204 id="scopes"
205 name="scopes"
206 value={cfg?.scopes || "openid profile email"}
207 />
208 </div>
209 <div class="form-group">
210 <label for="allowed_email_domains">
211 Allowed email domains (comma-separated, empty = any)
212 </label>
213 <input
214 type="text"
215 id="allowed_email_domains"
216 name="allowed_email_domains"
217 value={cfg?.allowedEmailDomains || ""}
218 placeholder="example.com, acme.io"
219 />
220 </div>
221 <label
222 style="display:flex;gap:8px;align-items:center;margin:12px 0"
223 >
224 <input
225 type="checkbox"
226 name="auto_create_users"
227 value="1"
228 checked={cfg ? cfg.autoCreateUsers : true}
229 />
230 <span>
231 Auto-create local accounts on first SSO sign-in (turn off to
232 require admins to pre-provision users)
233 </span>
234 </label>
235 <button type="submit" class="btn btn-primary">
236 Save SSO settings
237 </button>
238 </form>
239 </div>
240 </Layout>
241 );
242});
243
244sso.post("/admin/sso", requireAuth, async (c) => {
245 const g = await adminGate(c);
246 if (g instanceof Response) return g;
247 const { user } = g;
248
249 const body = await c.req.parseBody();
250 const existing = await getSsoConfig();
251 const secretSubmitted = String(body.client_secret || "");
252 const result = await upsertSsoConfig({
253 enabled: String(body.enabled || "") === "1",
254 providerName: String(body.provider_name || "SSO"),
255 issuer: String(body.issuer || ""),
256 authorizationEndpoint: String(body.authorization_endpoint || ""),
257 tokenEndpoint: String(body.token_endpoint || ""),
258 userinfoEndpoint: String(body.userinfo_endpoint || ""),
259 clientId: String(body.client_id || ""),
260 clientSecret:
261 secretSubmitted.trim().length === 0 && existing?.clientSecret
262 ? existing.clientSecret
263 : secretSubmitted,
264 scopes: String(body.scopes || "openid profile email"),
265 allowedEmailDomains: String(body.allowed_email_domains || ""),
266 autoCreateUsers: String(body.auto_create_users || "") === "1",
267 });
268
269 if (!result.ok) {
270 return c.redirect(
271 `/admin/sso?error=${encodeURIComponent(result.error)}`
272 );
273 }
274
275 await audit({
276 userId: user.id,
277 action: "admin.sso.configure",
278 metadata: {
279 enabled: String(body.enabled || "") === "1",
280 provider: String(body.provider_name || "SSO"),
281 autoCreateUsers: String(body.auto_create_users || "") === "1",
282 allowedDomains: String(body.allowed_email_domains || "") || null,
283 },
284 });
285
286 return c.redirect(
287 `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}`
288 );
289});
290
291// ----------------------------------------------------------------------------
292// OIDC flow
293// ----------------------------------------------------------------------------
294
295sso.get("/login/sso", async (c) => {
296 const cfg = await getSsoConfig();
297 if (!cfg || !cfg.enabled) {
298 return c.redirect(
299 `/login?error=${encodeURIComponent("SSO is not enabled")}`
300 );
301 }
302 if (
303 !cfg.authorizationEndpoint ||
304 !cfg.tokenEndpoint ||
305 !cfg.userinfoEndpoint ||
306 !cfg.clientId ||
307 !cfg.clientSecret
308 ) {
309 return c.redirect(
310 `/login?error=${encodeURIComponent("SSO is not fully configured")}`
311 );
312 }
313 const state = randomToken(16);
314 const nonce = randomToken(16);
315 const redirectUri = ssoRedirectUri();
316 let target: string;
317 try {
318 target = buildAuthorizeUrl(cfg, state, nonce, redirectUri);
319 } catch (err) {
320 return c.redirect(
321 `/login?error=${encodeURIComponent(
322 err instanceof Error ? err.message : "SSO misconfigured"
323 )}`
324 );
325 }
326 setCookie(c, "sso_state", state, ssoStateCookieOpts());
327 setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts());
328 return c.redirect(target);
329});
330
331sso.get("/login/sso/callback", async (c) => {
332 const cfg = await getSsoConfig();
333 if (!cfg || !cfg.enabled) {
334 return c.redirect(
335 `/login?error=${encodeURIComponent("SSO is not enabled")}`
336 );
337 }
338
339 const code = c.req.query("code");
340 const state = c.req.query("state");
341 const errCode = c.req.query("error");
342 if (errCode) {
343 return c.redirect(
344 `/login?error=${encodeURIComponent(
345 `SSO provider error: ${errCode}`
346 )}`
347 );
348 }
349 if (!code || !state) {
350 return c.redirect(
351 `/login?error=${encodeURIComponent("Missing code or state")}`
352 );
353 }
354
355 const expectedState = getCookie(c, "sso_state");
356 if (!expectedState || expectedState !== state) {
357 return c.redirect(
358 `/login?error=${encodeURIComponent(
359 "SSO state mismatch. Please try again."
360 )}`
361 );
362 }
363
364 // One-shot cookies — burn them even on failure
365 deleteCookie(c, "sso_state", { path: "/" });
366 deleteCookie(c, "sso_nonce", { path: "/" });
367
368 try {
369 const tokens = await exchangeCode(cfg, code, ssoRedirectUri());
370 const claims = await fetchUserinfo(cfg, tokens.access_token);
371 const result = await findOrCreateUserFromSso(claims, cfg);
372 if (!result.ok) {
373 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
374 }
375
376 const token = await issueSsoSession(result.user.id);
377 setCookie(c, "session", token, sessionCookieOptions());
378
379 await audit({
380 userId: result.user.id,
381 action: "auth.sso.login",
382 metadata: {
383 provider: cfg.providerName,
384 sub: claims.sub,
385 email: claims.email || null,
386 },
387 });
388
389 return c.redirect("/");
390 } catch (err) {
391 console.error("[sso] callback error:", err);
392 return c.redirect(
393 `/login?error=${encodeURIComponent(
394 err instanceof Error
395 ? `SSO sign-in failed: ${err.message}`
396 : "SSO sign-in failed"
397 )}`
398 );
399 }
400});
401
402// ----------------------------------------------------------------------------
403// User: unlink SSO
404// ----------------------------------------------------------------------------
405
406sso.post("/settings/sso/unlink", requireAuth, async (c) => {
407 const user = c.get("user")!;
408 const links = await db
409 .select({ id: ssoUserLinks.id })
410 .from(ssoUserLinks)
411 .where(eq(ssoUserLinks.userId, user.id));
412 if (links.length === 0) {
413 return c.redirect("/settings?error=" + encodeURIComponent("No SSO link"));
414 }
415 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id));
416 await audit({
417 userId: user.id,
418 action: "auth.sso.unlink",
419 metadata: { removedLinks: links.length },
420 });
421 return c.redirect(
422 "/settings?success=" + encodeURIComponent("SSO link removed.")
423 );
424});
425
426export default sso;
Addedsrc/routes/symbols.tsx+369−0View fileUnifiedSplit
1/**
2 * Block I8 — Symbol / xref navigation.
3 *
4 * GET /:owner/:repo/symbols — overview + "Reindex" button
5 * GET /:owner/:repo/symbols/search — search by name (prefix match)
6 * GET /:owner/:repo/symbols/:name — definitions list
7 * POST /:owner/:repo/symbols/reindex — owner-only, runs indexer
8 */
9
10import { Hono } from "hono";
11import { and, asc, eq, ilike, sql } from "drizzle-orm";
12import { db } from "../db";
13import { codeSymbols, repositories, users } from "../db/schema";
14import { Layout } from "../views/layout";
15import { RepoHeader, RepoNav } from "../views/components";
16import { softAuth, requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import { indexRepositorySymbols, findDefinitions } from "../lib/symbols";
19
20const symbols = new Hono<AuthEnv>();
21symbols.use("*", softAuth);
22
23async function loadRepo(ownerName: string, repoName: string) {
24 const [owner] = await db
25 .select()
26 .from(users)
27 .where(eq(users.username, ownerName))
28 .limit(1);
29 if (!owner) return null;
30 const [repo] = await db
31 .select()
32 .from(repositories)
33 .where(
34 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
35 )
36 .limit(1);
37 if (!repo) return null;
38 return { owner, repo };
39}
40
41// ---------- Overview ----------
42
43symbols.get("/:owner/:repo/symbols", async (c) => {
44 const user = c.get("user");
45 const { owner: ownerName, repo: repoName } = c.req.param();
46 const ctx = await loadRepo(ownerName, repoName);
47 if (!ctx) return c.notFound();
48 const { owner, repo } = ctx;
49 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
50 return c.notFound();
51 }
52
53 const [countRow] = await db
54 .select({ n: sql<number>`count(*)::int` })
55 .from(codeSymbols)
56 .where(eq(codeSymbols.repositoryId, repo.id));
57 const total = Number(countRow?.n || 0);
58
59 const byKindRaw = await db
60 .select({
61 kind: codeSymbols.kind,
62 n: sql<number>`count(*)::int`,
63 })
64 .from(codeSymbols)
65 .where(eq(codeSymbols.repositoryId, repo.id))
66 .groupBy(codeSymbols.kind);
67
68 const latest = await db
69 .select({
70 name: codeSymbols.name,
71 kind: codeSymbols.kind,
72 path: codeSymbols.path,
73 line: codeSymbols.line,
74 })
75 .from(codeSymbols)
76 .where(eq(codeSymbols.repositoryId, repo.id))
77 .orderBy(asc(codeSymbols.name))
78 .limit(50);
79
80 const isOwner = user && user.id === repo.ownerId;
81 const message = c.req.query("message");
82
83 return c.html(
84 <Layout title={`Symbols — ${ownerName}/${repoName}`} user={user}>
85 <RepoHeader owner={ownerName} repo={repoName} />
86 <RepoNav owner={ownerName} repo={repoName} active="code" />
87 <div class="settings-container">
88 <div style="display:flex;justify-content:space-between;align-items:center">
89 <h2 style="margin:0">Symbols</h2>
90 {isOwner && (
91 <form method="POST" action={`/${ownerName}/${repoName}/symbols/reindex`}>
92 <button type="submit" class="btn btn-primary btn-sm">
93 Reindex
94 </button>
95 </form>
96 )}
97 </div>
98 {message && (
99 <div class="auth-success" style="margin-top:12px">
100 {decodeURIComponent(message)}
101 </div>
102 )}
103 <p style="color:var(--text-muted);margin-top:8px">
104 Top-level definitions from the default branch. Click a symbol to
105 see all its definitions.
106 </p>
107
108 <form
109 method="GET"
110 action={`/${ownerName}/${repoName}/symbols/search`}
111 style="display:flex;gap:8px;margin:16px 0"
112 >
113 <input
114 type="text"
115 name="q"
116 placeholder="Search symbol name..."
117 required
118 style="flex:1"
119 />
120 <button type="submit" class="btn">
121 Search
122 </button>
123 </form>
124
125 <div
126 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:16px"
127 >
128 <div class="panel" style="padding:12px;text-align:center">
129 <div style="font-size:20px;font-weight:700">{total}</div>
130 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
131 Symbols
132 </div>
133 </div>
134 {byKindRaw.map((r) => (
135 <div class="panel" style="padding:12px;text-align:center">
136 <div style="font-size:20px;font-weight:700">{Number(r.n)}</div>
137 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
138 {r.kind}
139 </div>
140 </div>
141 ))}
142 </div>
143
144 <h3>A–Z</h3>
145 {total === 0 ? (
146 <div class="panel-empty" style="padding:24px">
147 No symbols indexed yet.
148 {isOwner && " Click Reindex to scan the repository."}
149 </div>
150 ) : (
151 <div class="panel">
152 {latest.map((s) => (
153 <div class="panel-item" style="justify-content:space-between">
154 <div>
155 <a
156 href={`/${ownerName}/${repoName}/symbols/${encodeURIComponent(s.name)}`}
157 style="font-weight:600;font-family:var(--font-mono)"
158 >
159 {s.name}
160 </a>{" "}
161 <span
162 style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-left:6px"
163 >
164 {s.kind}
165 </span>
166 </div>
167 <a
168 href={`/${ownerName}/${repoName}/blob/HEAD/${s.path}#L${s.line}`}
169 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
170 >
171 {s.path}:{s.line}
172 </a>
173 </div>
174 ))}
175 </div>
176 )}
177 </div>
178 </Layout>
179 );
180});
181
182// ---------- Search ----------
183
184symbols.get("/:owner/:repo/symbols/search", async (c) => {
185 const user = c.get("user");
186 const { owner: ownerName, repo: repoName } = c.req.param();
187 const ctx = await loadRepo(ownerName, repoName);
188 if (!ctx) return c.notFound();
189 const { repo } = ctx;
190 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
191 return c.notFound();
192 }
193
194 const q = (c.req.query("q") || "").trim();
195 const results = q
196 ? await db
197 .select({
198 name: codeSymbols.name,
199 kind: codeSymbols.kind,
200 path: codeSymbols.path,
201 line: codeSymbols.line,
202 })
203 .from(codeSymbols)
204 .where(
205 and(
206 eq(codeSymbols.repositoryId, repo.id),
207 ilike(codeSymbols.name, `${q}%`)
208 )
209 )
210 .orderBy(asc(codeSymbols.name))
211 .limit(200)
212 : [];
213
214 return c.html(
215 <Layout title={`Symbol search — ${ownerName}/${repoName}`} user={user}>
216 <RepoHeader owner={ownerName} repo={repoName} />
217 <RepoNav owner={ownerName} repo={repoName} active="code" />
218 <div class="settings-container">
219 <h2>Symbol search</h2>
220 <form
221 method="GET"
222 action={`/${ownerName}/${repoName}/symbols/search`}
223 style="display:flex;gap:8px;margin:12px 0"
224 >
225 <input
226 type="text"
227 name="q"
228 value={q}
229 placeholder="Search symbol name..."
230 required
231 style="flex:1"
232 />
233 <button type="submit" class="btn">
234 Search
235 </button>
236 <a href={`/${ownerName}/${repoName}/symbols`} class="btn">
237 Back
238 </a>
239 </form>
240 {q === "" ? (
241 <p style="color:var(--text-muted)">Enter a prefix to search.</p>
242 ) : results.length === 0 ? (
243 <p style="color:var(--text-muted)">No symbols match "{q}".</p>
244 ) : (
245 <div class="panel">
246 {results.map((s) => (
247 <div class="panel-item" style="justify-content:space-between">
248 <div>
249 <a
250 href={`/${ownerName}/${repoName}/symbols/${encodeURIComponent(s.name)}`}
251 style="font-weight:600;font-family:var(--font-mono)"
252 >
253 {s.name}
254 </a>{" "}
255 <span
256 style="font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-left:6px"
257 >
258 {s.kind}
259 </span>
260 </div>
261 <a
262 href={`/${ownerName}/${repoName}/blob/HEAD/${s.path}#L${s.line}`}
263 style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)"
264 >
265 {s.path}:{s.line}
266 </a>
267 </div>
268 ))}
269 </div>
270 )}
271 </div>
272 </Layout>
273 );
274});
275
276// ---------- Symbol detail ----------
277
278symbols.get("/:owner/:repo/symbols/:name", async (c) => {
279 const user = c.get("user");
280 const { owner: ownerName, repo: repoName, name } = c.req.param();
281 const ctx = await loadRepo(ownerName, repoName);
282 if (!ctx) return c.notFound();
283 const { repo } = ctx;
284 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
285 return c.notFound();
286 }
287
288 const defs = await findDefinitions(repo.id, decodeURIComponent(name));
289
290 return c.html(
291 <Layout title={`${name} — ${ownerName}/${repoName}`} user={user}>
292 <RepoHeader owner={ownerName} repo={repoName} />
293 <RepoNav owner={ownerName} repo={repoName} active="code" />
294 <div class="settings-container">
295 <h2 style="font-family:var(--font-mono)">{decodeURIComponent(name)}</h2>
296 <p style="color:var(--text-muted)">
297 {defs.length} definition{defs.length === 1 ? "" : "s"}
298 </p>
299 {defs.length === 0 ? (
300 <div class="panel-empty" style="padding:24px">
301 No definitions found.{" "}
302 <a href={`/${ownerName}/${repoName}/symbols`}>Back to symbols</a>
303 </div>
304 ) : (
305 <div class="panel">
306 {defs.map((d) => (
307 <div class="panel-item" style="flex-direction:column;align-items:stretch;gap:4px">
308 <div style="display:flex;justify-content:space-between;align-items:center">
309 <span
310 style="font-size:11px;color:var(--text-muted);text-transform:uppercase"
311 >
312 {d.kind}
313 </span>
314 <a
315 href={`/${ownerName}/${repoName}/blob/HEAD/${d.path}#L${d.line}`}
316 style="font-size:12px;font-family:var(--font-mono)"
317 >
318 {d.path}:{d.line}
319 </a>
320 </div>
321 {d.signature && (
322 <pre
323 style="margin:0;padding:8px;background:var(--bg-subtle);border-radius:4px;font-size:12px;overflow-x:auto"
324 >
325 {d.signature}
326 </pre>
327 )}
328 </div>
329 ))}
330 </div>
331 )}
332 <p style="margin-top:20px">
333 <a href={`/${ownerName}/${repoName}/symbols`}>← Back to symbols</a>
334 </p>
335 </div>
336 </Layout>
337 );
338});
339
340// ---------- Reindex ----------
341
342symbols.post("/:owner/:repo/symbols/reindex", requireAuth, async (c) => {
343 const user = c.get("user")!;
344 const { owner: ownerName, repo: repoName } = c.req.param();
345 const ctx = await loadRepo(ownerName, repoName);
346 if (!ctx) return c.notFound();
347 const { repo } = ctx;
348 if (user.id !== repo.ownerId) {
349 return c.html(
350 <Layout title="Forbidden" user={user}>
351 <div class="empty-state">
352 <h2>403</h2>
353 <p>Only the repository owner can reindex symbols.</p>
354 </div>
355 </Layout>,
356 403
357 );
358 }
359
360 const result = await indexRepositorySymbols(repo.id);
361 const msg = result
362 ? `Indexed ${result.indexed} symbols across ${result.files} files.`
363 : "Indexing failed — see server logs.";
364 return c.redirect(
365 `/${ownerName}/${repoName}/symbols?message=${encodeURIComponent(msg)}`
366 );
367});
368
369export default symbols;
Addedsrc/routes/templates.ts+122−0View fileUnifiedSplit
1/**
2 * Block I2 — Template repositories.
3 *
4 * POST /:owner/:repo/use-template — clone a template into a new repo owned
5 * by the current user. Similar to fork, but:
6 * - source must have `is_template = true`
7 * - destination is given a new name via the `name` form field
8 * - `forked_from_id` is NOT set (templates break lineage)
9 * - default branch is reset to a clean history (for now, we use the
10 * template's history, matching GitHub's optional `--include-all-branches`
11 * behavior from the template's default branch only)
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { activityFeed, repositories, users } from "../db/schema";
18import { softAuth, requireAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { getRepoPath, repoExists } from "../git/repository";
21import { config } from "../lib/config";
22import { join } from "path";
23
24const templates = new Hono<AuthEnv>();
25templates.use("*", softAuth);
26
27templates.post("/:owner/:repo/use-template", requireAuth, async (c) => {
28 const { owner: ownerName, repo: repoName } = c.req.param();
29 const user = c.get("user")!;
30 const body = await c.req.parseBody();
31 const newName = String(body.name || "").trim();
32 if (!newName) {
33 return c.redirect(`/${ownerName}/${repoName}?error=Name+required`);
34 }
35 if (!/^[a-zA-Z0-9._-]+$/.test(newName)) {
36 return c.redirect(`/${ownerName}/${repoName}?error=Invalid+name`);
37 }
38
39 if (!(await repoExists(ownerName, repoName))) {
40 return c.redirect(`/${ownerName}/${repoName}`);
41 }
42
43 const [sourceOwner] = await db
44 .select()
45 .from(users)
46 .where(eq(users.username, ownerName))
47 .limit(1);
48 if (!sourceOwner) return c.redirect(`/${ownerName}/${repoName}`);
49
50 const [sourceRepo] = await db
51 .select()
52 .from(repositories)
53 .where(
54 and(
55 eq(repositories.ownerId, sourceOwner.id),
56 eq(repositories.name, repoName)
57 )
58 )
59 .limit(1);
60 if (!sourceRepo) return c.redirect(`/${ownerName}/${repoName}`);
61 if (!sourceRepo.isTemplate) {
62 return c.redirect(`/${ownerName}/${repoName}?error=Not+a+template`);
63 }
64
65 // Refuse if the user already has a repo by that name
66 if (await repoExists(user.username, newName)) {
67 return c.redirect(`/${user.username}/${newName}`);
68 }
69
70 const sourcePath = getRepoPath(ownerName, repoName);
71 const destPath = join(config.gitReposPath, user.username, `${newName}.git`);
72 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
73 stdout: "pipe",
74 stderr: "pipe",
75 });
76 await proc.exited;
77
78 const [newRepo] = await db
79 .insert(repositories)
80 .values({
81 name: newName,
82 ownerId: user.id,
83 description: sourceRepo.description
84 ? `Seeded from ${ownerName}/${repoName} template`
85 : `Seeded from ${ownerName}/${repoName} template`,
86 isPrivate: false,
87 defaultBranch: sourceRepo.defaultBranch,
88 diskPath: destPath,
89 // Intentionally no forkedFromId — templates break lineage
90 })
91 .returning();
92
93 if (newRepo) {
94 try {
95 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
96 await bootstrapRepository({
97 repositoryId: newRepo.id,
98 ownerUserId: user.id,
99 defaultBranch: sourceRepo.defaultBranch,
100 skipWelcomeIssue: true,
101 });
102 } catch {
103 // bootstrap failures shouldn't break the primary create
104 }
105 try {
106 await db.insert(activityFeed).values({
107 repositoryId: newRepo.id,
108 userId: user.id,
109 action: "created",
110 metadata: JSON.stringify({
111 fromTemplate: `${ownerName}/${repoName}`,
112 }),
113 });
114 } catch {
115 // best effort
116 }
117 }
118
119 return c.redirect(`/${user.username}/${newName}`);
120});
121
122export default templates;
Addedsrc/routes/theme.ts+64−0View fileUnifiedSplit
1/**
2 * Theme toggle — flips a "theme" cookie between dark and light.
3 * Cookie is read by both the SSR layout (via middleware-injected prop) and
4 * a pre-paint inline script to avoid FOUC.
5 */
6
7import { Hono } from "hono";
8import { getCookie, setCookie } from "hono/cookie";
9
10const theme = new Hono();
11
12function readTheme(c: any): "dark" | "light" {
13 const v = getCookie(c, "theme");
14 return v === "light" ? "light" : "dark";
15}
16
17function writeTheme(c: any, value: "dark" | "light") {
18 setCookie(c, "theme", value, {
19 path: "/",
20 maxAge: 60 * 60 * 24 * 365, // 1 year
21 sameSite: "Lax",
22 httpOnly: false, // must be readable by the pre-paint script
23 });
24}
25
26function redirectBack(c: any): Response {
27 const ref = c.req.header("referer");
28 // Only follow same-origin referers — anything else falls back to /.
29 try {
30 if (ref) {
31 const u = new URL(ref);
32 const host = c.req.header("host");
33 if (u.host === host) return c.redirect(u.pathname + u.search);
34 }
35 } catch {
36 // fall through
37 }
38 return c.redirect("/");
39}
40
41// GET /theme/toggle — flip current value, redirect back.
42// Lives outside /settings/* so it doesn't get blocked by the settings
43// auth middleware — theme should work for logged-out visitors too.
44theme.get("/theme/toggle", (c) => {
45 const next = readTheme(c) === "light" ? "dark" : "light";
46 writeTheme(c, next);
47 return redirectBack(c);
48});
49
50// GET /theme/set?mode=dark|light — explicit setter (for tests + API).
51theme.get("/theme/set", (c) => {
52 const mode = c.req.query("mode");
53 if (mode !== "dark" && mode !== "light") {
54 return c.json({ ok: false, error: "mode must be 'dark' or 'light'" }, 400);
55 }
56 writeTheme(c, mode);
57 if ((c.req.header("accept") || "").includes("application/json")) {
58 return c.json({ ok: true, theme: mode });
59 }
60 return redirectBack(c);
61});
62
63export { readTheme };
64export default theme;
Addedsrc/routes/traffic.tsx+195−0View fileUnifiedSplit
1/**
2 * Block F1 — Traffic analytics UI.
3 *
4 * GET /:owner/:repo/traffic — owner-only 14-day views/clones chart,
5 * unique visitors, top paths + referers.
6 */
7
8import { Hono } from "hono";
9import { and, eq } from "drizzle-orm";
10import { db } from "../db";
11import { repositories, users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { RepoHeader, RepoNav } from "../views/components";
14import { softAuth, requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { summarise } from "../lib/traffic";
17
18const traffic = new Hono<AuthEnv>();
19traffic.use("*", softAuth);
20
21async function loadRepo(owner: string, repo: string) {
22 try {
23 const [row] = await db
24 .select({
25 id: repositories.id,
26 name: repositories.name,
27 ownerId: repositories.ownerId,
28 starCount: repositories.starCount,
29 forkCount: repositories.forkCount,
30 })
31 .from(repositories)
32 .innerJoin(users, eq(repositories.ownerId, users.id))
33 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
34 .limit(1);
35 return row || null;
36 } catch {
37 return null;
38 }
39}
40
41traffic.get("/:owner/:repo/traffic", requireAuth, async (c) => {
42 const user = c.get("user")!;
43 const { owner, repo } = c.req.param();
44 const repoRow = await loadRepo(owner, repo);
45 if (!repoRow) return c.notFound();
46 if (repoRow.ownerId !== user.id) {
47 return c.redirect(`/${owner}/${repo}`);
48 }
49
50 const windowDays = Math.max(
51 1,
52 Math.min(90, parseInt(c.req.query("days") || "14", 10) || 14)
53 );
54 const summary = await summarise(repoRow.id, windowDays);
55
56 // Simple ascii-bar chart scaled to the max day.
57 const maxN = Math.max(
58 1,
59 ...summary.daily.map((d) => d.views + d.clones)
60 );
61
62 return c.html(
63 <Layout title={`Traffic — ${owner}/${repo}`} user={user}>
64 <RepoHeader
65 owner={owner}
66 repo={repo}
67 starCount={repoRow.starCount}
68 forkCount={repoRow.forkCount}
69 currentUser={user.username}
70 />
71 <RepoNav owner={owner} repo={repo} active="insights" />
72
73 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
74 <h3>Traffic ({windowDays}d)</h3>
75 <div>
76 {[7, 14, 30, 90].map((d) => (
77 <a
78 href={`/${owner}/${repo}/traffic?days=${d}`}
79 class={`btn btn-sm ${d === windowDays ? "btn-primary" : ""}`}
80 style="margin-left:4px"
81 >
82 {d}d
83 </a>
84 ))}
85 </div>
86 </div>
87
88 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
89 <div class="panel" style="padding:12px;text-align:center">
90 <div style="font-size:22px;font-weight:700;color:#79c0ff">
91 {summary.totalViews}
92 </div>
93 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
94 Views
95 </div>
96 </div>
97 <div class="panel" style="padding:12px;text-align:center">
98 <div style="font-size:22px;font-weight:700;color:#d2a8ff">
99 {summary.totalClones}
100 </div>
101 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
102 Clones
103 </div>
104 </div>
105 <div class="panel" style="padding:12px;text-align:center">
106 <div style="font-size:22px;font-weight:700;color:var(--green)">
107 {summary.uniqueVisitorsApprox}
108 </div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Unique (approx)
111 </div>
112 </div>
113 </div>
114
115 <h4>Daily</h4>
116 <div class="panel" style="margin-bottom:20px;padding:12px">
117 {summary.daily.length === 0 ? (
118 <p style="color:var(--text-muted);font-size:13px">
119 No traffic recorded yet. Views are tracked automatically as people
120 visit this repo; clones + API hits are tracked on git-http access.
121 </p>
122 ) : (
123 summary.daily.map((d) => {
124 const total = d.views + d.clones;
125 const pct = Math.round((total / maxN) * 100);
126 return (
127 <div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
128 <span
129 style="font-family:var(--font-mono);color:var(--text-muted);width:88px"
130 >
131 {d.day}
132 </span>
133 <div
134 style={`flex:1;height:14px;background:var(--bg-tertiary);border-radius:3px;position:relative;overflow:hidden`}
135 >
136 <div
137 style={`position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#79c0ff ${d.views / Math.max(1, total) * 100}%,#d2a8ff ${d.views / Math.max(1, total) * 100}%)`}
138 />
139 </div>
140 <span style="font-family:var(--font-mono);width:56px;text-align:right">
141 {d.views}v / {d.clones}c
142 </span>
143 </div>
144 );
145 })
146 )}
147 </div>
148
149 <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
150 <div>
151 <h4>Top paths</h4>
152 <div class="panel">
153 {summary.topPaths.length === 0 ? (
154 <div class="panel-empty">No paths recorded.</div>
155 ) : (
156 summary.topPaths.map((p) => (
157 <div class="panel-item" style="justify-content:space-between">
158 <code style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block">
159 {p.path}
160 </code>
161 <span style="font-family:var(--font-mono);color:var(--text-muted)">
162 {p.n}
163 </span>
164 </div>
165 ))
166 )}
167 </div>
168 </div>
169 <div>
170 <h4>Top referers</h4>
171 <div class="panel">
172 {summary.topReferers.length === 0 ? (
173 <div class="panel-empty">No external referers.</div>
174 ) : (
175 summary.topReferers.map((r) => (
176 <div class="panel-item" style="justify-content:space-between">
177 <span
178 style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block"
179 >
180 {r.referer}
181 </span>
182 <span style="font-family:var(--font-mono);color:var(--text-muted)">
183 {r.n}
184 </span>
185 </div>
186 ))
187 )}
188 </div>
189 </div>
190 </div>
191 </Layout>
192 );
193});
194
195export default traffic;
Modifiedsrc/routes/web.tsx+394−72View fileUnifiedSplit
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc } from "drizzle-orm";
8import { eq, and, desc, inArray } from "drizzle-orm";
99import { db } from "../db";
10import { users, repositories, stars } from "../db/schema";
10import {
11 users,
12 repositories,
13 stars,
14 commitVerifications,
15} from "../db/schema";
1116import { Layout } from "../views/layout";
1217import {
1318 RepoHeader,
4146import { highlightCode } from "../lib/highlight";
4247import { softAuth, requireAuth } from "../middleware/auth";
4348import type { AuthEnv } from "../middleware/auth";
49import { trackByName } from "../lib/traffic";
4450
4551const web = new Hono<AuthEnv>();
4652
5258 const user = c.get("user");
5359
5460 if (user) {
55 // Show user's repos
56 const repos = await db
57 .select()
58 .from(repositories)
59 .where(eq(repositories.ownerId, user.id))
60 .orderBy(desc(repositories.updatedAt));
61
62 return c.html(
63 <Layout title="Dashboard" user={user}>
64 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
65 <h2>Your repositories</h2>
66 <a href="/new" class="btn btn-primary">
67 + New repository
68 </a>
69 </div>
70 {repos.length === 0 ? (
71 <div class="empty-state">
72 <h2>No repositories yet</h2>
73 <p>Create your first repository to get started.</p>
74 </div>
75 ) : (
76 <div class="card-grid">
77 {repos.map((repo) => (
78 <RepoCard repo={repo} ownerName={user.username} />
79 ))}
80 </div>
81 )}
82 </Layout>
83 );
61 const { renderDashboard } = await import("./dashboard");
62 return renderDashboard(c);
8463 }
8564
8665 return c.html(
186165
187166 const diskPath = await initBareRepo(user.username, name);
188167
189 await db.insert(repositories).values({
190 name,
191 ownerId: user.id,
192 description: description || null,
193 isPrivate,
194 diskPath,
195 });
168 const [newRepo] = await db
169 .insert(repositories)
170 .values({
171 name,
172 ownerId: user.id,
173 description: description || null,
174 isPrivate,
175 diskPath,
176 })
177 .returning();
178
179 if (newRepo) {
180 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
181 await bootstrapRepository({
182 repositoryId: newRepo.id,
183 ownerUserId: user.id,
184 defaultBranch: "main",
185 });
186 }
196187
197188 return c.redirect(`/${user.username}/${name}`);
198189});
240231 : allRepos.filter((r) => !r.isPrivate);
241232 }
242233
234 // Block J4 — follow counts + viewer's follow state
235 let followState = {
236 followers: 0,
237 following: 0,
238 viewerFollows: false,
239 };
240 if (ownerUser) {
241 try {
242 const { followCounts, isFollowing } = await import("../lib/follows");
243 const counts = await followCounts(ownerUser.id);
244 followState.followers = counts.followers;
245 followState.following = counts.following;
246 if (user && user.id !== ownerUser.id) {
247 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
248 }
249 } catch {
250 // DB hiccup — fall back to zeros.
251 }
252 }
253 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
254
255 // Block J5 — profile README. Render owner/owner repo's README on the
256 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
257 // back to "<user>/.github" for org-style profile repos.
258 let profileReadmeHtml: string | null = null;
259 try {
260 const candidates = [ownerName, ".github"];
261 for (const rname of candidates) {
262 if (await repoExists(ownerName, rname)) {
263 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
264 const md = await getReadme(ownerName, rname, ref);
265 if (md) {
266 profileReadmeHtml = renderMarkdown(md);
267 break;
268 }
269 }
270 }
271 } catch {
272 profileReadmeHtml = null;
273 }
274
243275 return c.html(
244276 <Layout title={ownerName} user={user}>
245277 <div class="user-profile">
250282 <h2>{ownerUser?.displayName || ownerName}</h2>
251283 <div class="username">@{ownerName}</div>
252284 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
285 <div
286 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
287 >
288 <a
289 href={`/${ownerName}/followers`}
290 style="color:var(--text-muted)"
291 >
292 <strong style="color:var(--text)">
293 {followState.followers}
294 </strong>{" "}
295 follower{followState.followers === 1 ? "" : "s"}
296 </a>
297 <a
298 href={`/${ownerName}/following`}
299 style="color:var(--text-muted)"
300 >
301 <strong style="color:var(--text)">
302 {followState.following}
303 </strong>{" "}
304 following
305 </a>
306 {canFollow && (
307 <form
308 method="POST"
309 action={`/${ownerName}/${
310 followState.viewerFollows ? "unfollow" : "follow"
311 }`}
312 >
313 <button
314 type="submit"
315 class={`btn ${
316 followState.viewerFollows ? "" : "btn-primary"
317 } btn-sm`}
318 >
319 {followState.viewerFollows ? "Unfollow" : "Follow"}
320 </button>
321 </form>
322 )}
323 </div>
253324 </div>
254325 </div>
326 {profileReadmeHtml && (
327 <div
328 class="markdown-body"
329 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
330 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
331 />
332 )}
255333 <h3 style="margin-bottom: 16px">Repositories</h3>
256334 {repos.length === 0 ? (
257335 <p style="color: var(--text-muted)">No repositories yet.</p>
328406 const { owner, repo } = c.req.param();
329407 const user = c.get("user");
330408
409 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
410 trackByName(owner, repo, "view", {
411 userId: user?.id || null,
412 path: `/${owner}/${repo}`,
413 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
414 userAgent: c.req.header("user-agent") || null,
415 referer: c.req.header("referer") || null,
416 }).catch(() => {});
417
331418 if (!(await repoExists(owner, repo))) {
332419 return c.html(
333420 <Layout title="Not Found" user={user}>
342429 );
343430 }
344431
345 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
346 const branches = await listBranches(owner, repo);
347 const tree = await getTree(owner, repo, defaultBranch);
348
349 // Get star info if user logged in
350 let starCount = 0;
351 let starred = false;
352 try {
353 const [ownerUser] = await db
354 .select()
355 .from(users)
356 .where(eq(users.username, owner))
357 .limit(1);
358 if (ownerUser) {
359 const [repoRow] = await db
360 .select()
361 .from(repositories)
362 .where(
363 and(
364 eq(repositories.ownerId, ownerUser.id),
365 eq(repositories.name, repo)
432 // Parallelize all independent operations
433 const [defaultBranch, branches] = await Promise.all([
434 getDefaultBranch(owner, repo).then((b) => b || "main"),
435 listBranches(owner, repo),
436 ]);
437 const [tree, starInfo] = await Promise.all([
438 getTree(owner, repo, defaultBranch),
439 // Star info fetched in parallel with tree
440 (async () => {
441 try {
442 const [ownerUser] = await db
443 .select()
444 .from(users)
445 .where(eq(users.username, owner))
446 .limit(1);
447 if (!ownerUser)
448 return {
449 starCount: 0,
450 starred: false,
451 archived: false,
452 isTemplate: false,
453 };
454 const [repoRow] = await db
455 .select()
456 .from(repositories)
457 .where(
458 and(
459 eq(repositories.ownerId, ownerUser.id),
460 eq(repositories.name, repo)
461 )
366462 )
367 )
368 .limit(1);
369 if (repoRow) {
370 starCount = repoRow.starCount;
463 .limit(1);
464 if (!repoRow)
465 return {
466 starCount: 0,
467 starred: false,
468 archived: false,
469 isTemplate: false,
470 };
471 let starred = false;
371472 if (user) {
372473 const [star] = await db
373474 .select()
381482 .limit(1);
382483 starred = !!star;
383484 }
485 return {
486 starCount: repoRow.starCount,
487 starred,
488 archived: repoRow.isArchived,
489 isTemplate: repoRow.isTemplate,
490 };
491 } catch {
492 return {
493 starCount: 0,
494 starred: false,
495 archived: false,
496 isTemplate: false,
497 };
384498 }
385 }
386 } catch {
387 // DB not available
388 }
499 })(),
500 ]);
501 const { starCount, starred, archived, isTemplate } = starInfo;
389502
390503 if (tree.length === 0) {
391504 return c.html(
396509 starCount={starCount}
397510 starred={starred}
398511 currentUser={user?.username}
512 archived={archived}
513 isTemplate={isTemplate}
399514 />
400515 <RepoNav owner={owner} repo={repo} active="code" />
401516 <div class="empty-state">
418533 starCount={starCount}
419534 starred={starred}
420535 currentUser={user?.username}
536 archived={archived}
537 isTemplate={isTemplate}
421538 />
539 {isTemplate && user && user.username !== owner && (
540 <div
541 class="panel"
542 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
543 >
544 <div style="font-size:13px">
545 <strong>Template repository.</strong> Create a new repository from
546 this template's files.
547 </div>
548 <form
549 method="POST"
550 action={`/${owner}/${repo}/use-template`}
551 style="display:flex;gap:8px;align-items:center"
552 >
553 <input
554 type="text"
555 name="name"
556 placeholder="new-repo-name"
557 required
558 style="width:200px"
559 />
560 <button type="submit" class="btn btn-primary">
561 Use this template
562 </button>
563 </form>
564 </div>
565 )}
422566 <RepoNav owner={owner} repo={repo} active="code" />
423567 <BranchSwitcher
424568 owner={owner}
614758
615759 const commits = await listCommits(owner, repo, ref, 50);
616760
761 // Block J3batch-fetch cached verification results for the page.
762 let verifications: Record<string, { verified: boolean; reason: string }> = {};
763 try {
764 const [ownerRow] = await db
765 .select()
766 .from(users)
767 .where(eq(users.username, owner))
768 .limit(1);
769 if (ownerRow) {
770 const [repoRow] = await db
771 .select()
772 .from(repositories)
773 .where(
774 and(
775 eq(repositories.ownerId, ownerRow.id),
776 eq(repositories.name, repo)
777 )
778 )
779 .limit(1);
780 if (repoRow && commits.length > 0) {
781 const rows = await db
782 .select()
783 .from(commitVerifications)
784 .where(
785 and(
786 eq(commitVerifications.repositoryId, repoRow.id),
787 inArray(
788 commitVerifications.commitSha,
789 commits.map((c) => c.sha)
790 )
791 )
792 );
793 for (const r of rows) {
794 verifications[r.commitSha] = {
795 verified: r.verified,
796 reason: r.reason,
797 };
798 }
799 }
800 }
801 } catch {
802 // DB unavailable — skip the badges gracefully.
803 }
804
617805 return c.html(
618806 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
619807 <RepoHeader owner={owner} repo={repo} />
630818 <p>No commits yet.</p>
631819 </div>
632820 ) : (
633 <CommitList commits={commits} owner={owner} repo={repo} />
821 <CommitList
822 commits={commits}
823 owner={owner}
824 repo={repo}
825 verifications={verifications}
826 />
634827 )}
635828 </Layout>
636829 );
641834 const { owner, repo, sha } = c.req.param();
642835 const user = c.get("user");
643836
644 const commit = await getCommit(owner, repo, sha);
837 // Fetch commit, full message, and diff in parallel
838 const [commit, fullMessage, diffResult] = await Promise.all([
839 getCommit(owner, repo, sha),
840 getCommitFullMessage(owner, repo, sha),
841 getDiff(owner, repo, sha),
842 ]);
645843 if (!commit) {
646844 return c.html(
647845 <Layout title="Not Found" user={user}>
653851 );
654852 }
655853
656 const fullMessage = await getCommitFullMessage(owner, repo, sha);
657 const { files, raw } = await getDiff(owner, repo, sha);
854 // Block J3 — try to verify this commit's signature.
855 let verification:
856 | { verified: boolean; reason: string; signatureType: string | null }
857 | null = null;
858 // Block J8 — external CI commit statuses rollup.
859 let statusCombined:
860 | {
861 state: "pending" | "success" | "failure";
862 total: number;
863 contexts: Array<{
864 context: string;
865 state: string;
866 description: string | null;
867 targetUrl: string | null;
868 }>;
869 }
870 | null = null;
871 try {
872 const [ownerRow] = await db
873 .select()
874 .from(users)
875 .where(eq(users.username, owner))
876 .limit(1);
877 if (ownerRow) {
878 const [repoRow] = await db
879 .select()
880 .from(repositories)
881 .where(
882 and(
883 eq(repositories.ownerId, ownerRow.id),
884 eq(repositories.name, repo)
885 )
886 )
887 .limit(1);
888 if (repoRow) {
889 const { verifyCommit } = await import("../lib/signatures");
890 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
891 verification = {
892 verified: v.verified,
893 reason: v.reason,
894 signatureType: v.signatureType,
895 };
896 try {
897 const { combinedStatus } = await import("../lib/commit-statuses");
898 const combined = await combinedStatus(repoRow.id, commit.sha);
899 if (combined.total > 0) {
900 statusCombined = {
901 state: combined.state as any,
902 total: combined.total,
903 contexts: combined.contexts.map((c) => ({
904 context: c.context,
905 state: c.state,
906 description: c.description,
907 targetUrl: c.targetUrl,
908 })),
909 };
910 }
911 } catch {
912 statusCombined = null;
913 }
914 }
915 }
916 } catch {
917 verification = null;
918 }
919
920 const { files, raw } = diffResult;
658921
659922 return c.html(
660923 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
678941 day: "numeric",
679942 year: "numeric",
680943 })}
944 {verification && verification.reason !== "unsigned" && (
945 <span
946 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
947 verification.verified
948 ? "var(--green,#2ea043)"
949 : "var(--yellow,#d29922)"
950 }`}
951 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
952 >
953 {verification.verified ? "Verified" : verification.reason}
954 </span>
955 )}
681956 </div>
682957 <div style="margin-top: 8px">
683958 <span class="commit-sha">{commit.sha}</span>
696971 </span>
697972 )}
698973 </div>
974 {statusCombined && (
975 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
976 <strong style="color: var(--text)">Checks</strong>
977 <span style="margin-left: 8px; color: var(--text-muted)">
978 {statusCombined.total} total —{" "}
979 <span
980 style={`color:${
981 statusCombined.state === "success"
982 ? "var(--green,#2ea043)"
983 : statusCombined.state === "failure"
984 ? "var(--red,#da3633)"
985 : "var(--yellow,#d29922)"
986 }`}
987 >
988 {statusCombined.state}
989 </span>
990 </span>
991 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
992 {statusCombined.contexts.map((cx) => (
993 <span
994 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
995 cx.state === "success"
996 ? "var(--green,#2ea043)"
997 : cx.state === "pending"
998 ? "var(--yellow,#d29922)"
999 : "var(--red,#da3633)"
1000 }`}
1001 title={cx.description || cx.context}
1002 >
1003 {cx.targetUrl ? (
1004 <a
1005 href={cx.targetUrl}
1006 style="color: inherit; text-decoration: none"
1007 rel="noopener"
1008 >
1009 {cx.context}: {cx.state}
1010 </a>
1011 ) : (
1012 <>
1013 {cx.context}: {cx.state}
1014 </>
1015 )}
1016 </span>
1017 ))}
1018 </div>
1019 </div>
1020 )}
6991021 </div>
7001022 <DiffView raw={raw} files={files} />
7011023 </Layout>
7341056 headers: {
7351057 "Content-Type": "application/octet-stream",
7361058 "Content-Disposition": `attachment; filename="${fileName}"`,
737 "Cache-Control": "no-cache",
1059 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
7381060 },
7391061 });
7401062});
Addedsrc/routes/wikis.tsx+752−0View fileUnifiedSplit
1/**
2 * Block E3 — Wikis: per-repo markdown page collection with revision history.
3 *
4 * v1 is DB-backed (no git bare repo). Each wiki_pages row holds the current
5 * title+body+revision counter; every edit appends a wiki_revisions row for
6 * history/diff/revert.
7 *
8 * Never throws.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc } from "drizzle-orm";
13import { db } from "../db";
14import {
15 wikiPages,
16 wikiRevisions,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26/** lowercase-alphanumerics joined by single dashes, trimmed. */
27export function slugifyTitle(title: string): string {
28 return title
29 .toLowerCase()
30 .normalize("NFKD")
31 .replace(/[^a-z0-9\s-]/g, "")
32 .replace(/\s+/g, "-")
33 .replace(/-+/g, "-")
34 .replace(/^-+|-+$/g, "");
35}
36
37const wikiRoutes = new Hono<AuthEnv>();
38
39async function resolveRepo(ownerName: string, repoName: string) {
40 try {
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!owner) return null;
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, owner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repo) return null;
58 return { owner, repo };
59 } catch {
60 return null;
61 }
62}
63
64function notFound(user: any, label = "Page not found") {
65 return (
66 <Layout title={label} user={user}>
67 <div class="empty-state">
68 <h2>{label}</h2>
69 </div>
70 </Layout>
71 );
72}
73
74function WikiSidebar(props: {
75 ownerName: string;
76 repoName: string;
77 pages: { slug: string; title: string }[];
78 user: any;
79}) {
80 const { ownerName, repoName, pages, user } = props;
81 return (
82 <aside style="min-width: 220px; border-right: 1px solid var(--border); padding-right: 16px;">
83 <div style="font-weight: 600; margin-bottom: 8px;">Pages</div>
84 <ul style="list-style: none; padding: 0; margin: 0;">
85 {pages.map((p) => (
86 <li>
87 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>{p.title}</a>
88 </li>
89 ))}
90 </ul>
91 {user && (
92 <div style="margin-top: 16px;">
93 <a
94 href={`/${ownerName}/${repoName}/wiki/new`}
95 class="btn btn-primary"
96 >
97 + New page
98 </a>
99 </div>
100 )}
101 </aside>
102 );
103}
104
105async function listPages(repoId: string) {
106 try {
107 return await db
108 .select({ slug: wikiPages.slug, title: wikiPages.title })
109 .from(wikiPages)
110 .where(eq(wikiPages.repositoryId, repoId))
111 .orderBy(wikiPages.title);
112 } catch {
113 return [];
114 }
115}
116
117// Root — render "home" page if exists, else CTA
118wikiRoutes.get("/:owner/:repo/wiki", softAuth, async (c) => {
119 const { owner: ownerName, repo: repoName } = c.req.param();
120 const user = c.get("user");
121 const resolved = await resolveRepo(ownerName, repoName);
122 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
123 const pages = await listPages(resolved.repo.id);
124
125 let home: any = null;
126 try {
127 const [row] = await db
128 .select()
129 .from(wikiPages)
130 .where(
131 and(
132 eq(wikiPages.repositoryId, resolved.repo.id),
133 eq(wikiPages.slug, "home")
134 )
135 )
136 .limit(1);
137 if (row) home = row;
138 } catch {
139 // leave null
140 }
141
142 return c.html(
143 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
144 <RepoHeader owner={ownerName} repo={repoName} />
145 <div style="display: flex; gap: 24px; margin-top: 16px;">
146 <WikiSidebar
147 ownerName={ownerName}
148 repoName={repoName}
149 pages={pages}
150 user={user}
151 />
152 <main style="flex: 1;">
153 {home ? (
154 <>
155 <h1>{home.title}</h1>
156 <div
157 dangerouslySetInnerHTML={{
158 __html: renderMarkdown(home.body || ""),
159 }}
160 />
161 </>
162 ) : (
163 <div class="empty-state">
164 <h2>No wiki yet</h2>
165 {user ? (
166 <a
167 href={`/${ownerName}/${repoName}/wiki/new`}
168 class="btn btn-primary"
169 >
170 Create the Home page
171 </a>
172 ) : (
173 <p>Nothing here yet.</p>
174 )}
175 </div>
176 )}
177 </main>
178 </div>
179 </Layout>
180 );
181});
182
183// All pages index
184wikiRoutes.get("/:owner/:repo/wiki/pages", softAuth, async (c) => {
185 const { owner: ownerName, repo: repoName } = c.req.param();
186 const user = c.get("user");
187 const resolved = await resolveRepo(ownerName, repoName);
188 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
189 let rows: any[] = [];
190 try {
191 rows = await db
192 .select()
193 .from(wikiPages)
194 .where(eq(wikiPages.repositoryId, resolved.repo.id))
195 .orderBy(wikiPages.title);
196 } catch {
197 rows = [];
198 }
199 return c.html(
200 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
201 <RepoHeader owner={ownerName} repo={repoName} />
202 <h2 style="margin-top: 16px;">Wiki pages</h2>
203 {rows.length === 0 ? (
204 <div class="empty-state">
205 <p>No pages.</p>
206 </div>
207 ) : (
208 <table class="file-table">
209 <tbody>
210 {rows.map((p) => (
211 <tr>
212 <td>
213 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>
214 {p.title}
215 </a>
216 </td>
217 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
218 r{p.revision}
219 </td>
220 </tr>
221 ))}
222 </tbody>
223 </table>
224 )}
225 </Layout>
226 );
227});
228
229// New page form
230wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
231 const { owner: ownerName, repo: repoName } = c.req.param();
232 const user = c.get("user");
233 const resolved = await resolveRepo(ownerName, repoName);
234 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
235 return c.html(
236 <Layout title="New wiki page" user={user}>
237 <RepoHeader owner={ownerName} repo={repoName} />
238 <h2 style="margin-top: 20px;">New wiki page</h2>
239 <form
240 method="POST"
241 action={`/${ownerName}/${repoName}/wiki`}
242 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
243 >
244 <input
245 type="text"
246 name="title"
247 placeholder="Page title"
248 required
249 style="padding: 8px;"
250 />
251 <textarea
252 name="body"
253 rows={16}
254 placeholder="Markdown body"
255 style="padding: 8px; font-family: monospace;"
256 ></textarea>
257 <button type="submit" class="btn btn-primary">
258 Create page
259 </button>
260 </form>
261 </Layout>
262 );
263});
264
265// Create
266wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
267 const { owner: ownerName, repo: repoName } = c.req.param();
268 const user = c.get("user")!;
269 const resolved = await resolveRepo(ownerName, repoName);
270 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
271
272 const form = await c.req.formData();
273 const title = (form.get("title") as string || "").trim();
274 const body = (form.get("body") as string || "").trim();
275 if (!title) {
276 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
277 }
278 const slug = slugifyTitle(title) || "page";
279
280 try {
281 const [page] = await db
282 .insert(wikiPages)
283 .values({
284 repositoryId: resolved.repo.id,
285 slug,
286 title,
287 body,
288 revision: 1,
289 updatedBy: user.id,
290 })
291 .returning({ id: wikiPages.id });
292 await db.insert(wikiRevisions).values({
293 pageId: page.id,
294 revision: 1,
295 title,
296 body,
297 message: "Initial",
298 authorId: user.id,
299 });
300 } catch {
301 // likely unique-violation on slug; redirect to the existing page
302 }
303 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
304});
305
306// View page
307wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
308 const { owner: ownerName, repo: repoName, slug } = c.req.param();
309 const user = c.get("user");
310 const resolved = await resolveRepo(ownerName, repoName);
311 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
312
313 let page: any = null;
314 try {
315 const [row] = await db
316 .select()
317 .from(wikiPages)
318 .where(
319 and(
320 eq(wikiPages.repositoryId, resolved.repo.id),
321 eq(wikiPages.slug, slug)
322 )
323 )
324 .limit(1);
325 if (row) page = row;
326 } catch {
327 // leave null
328 }
329 if (!page) return c.html(notFound(user, "Page not found"), 404);
330 const pages = await listPages(resolved.repo.id);
331 const isOwner = user && user.id === resolved.repo.ownerId;
332
333 return c.html(
334 <Layout title={`${page.title} — wiki`} user={user}>
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <div style="display: flex; gap: 24px; margin-top: 16px;">
337 <WikiSidebar
338 ownerName={ownerName}
339 repoName={repoName}
340 pages={pages}
341 user={user}
342 />
343 <main style="flex: 1;">
344 <div style="display: flex; justify-content: space-between; align-items: center;">
345 <h1 style="margin: 0;">{page.title}</h1>
346 <div style="display: flex; gap: 8px;">
347 <a
348 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
349 class="btn"
350 >
351 History
352 </a>
353 {user && (
354 <a
355 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
356 class="btn"
357 >
358 Edit
359 </a>
360 )}
361 {isOwner && (
362 <form
363 method="POST"
364 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
365 style="display: inline;"
366 onsubmit="return confirm('Delete this page?')"
367 >
368 <button type="submit" class="btn">
369 Delete
370 </button>
371 </form>
372 )}
373 </div>
374 </div>
375 <div
376 style="margin-top: 16px;"
377 dangerouslySetInnerHTML={{
378 __html: renderMarkdown(page.body || ""),
379 }}
380 />
381 </main>
382 </div>
383 </Layout>
384 );
385});
386
387// Edit form
388wikiRoutes.get(
389 "/:owner/:repo/wiki/:slug/edit",
390 requireAuth,
391 async (c) => {
392 const { owner: ownerName, repo: repoName, slug } = c.req.param();
393 const user = c.get("user");
394 const resolved = await resolveRepo(ownerName, repoName);
395 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
396
397 let page: any = null;
398 try {
399 const [row] = await db
400 .select()
401 .from(wikiPages)
402 .where(
403 and(
404 eq(wikiPages.repositoryId, resolved.repo.id),
405 eq(wikiPages.slug, slug)
406 )
407 )
408 .limit(1);
409 if (row) page = row;
410 } catch {
411 // leave null
412 }
413 if (!page) return c.html(notFound(user, "Page not found"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${page.title}`} user={user}>
417 <RepoHeader owner={ownerName} repo={repoName} />
418 <h2 style="margin-top: 20px;">Edit "{page.title}"</h2>
419 <form
420 method="POST"
421 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
422 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
423 >
424 <input
425 type="text"
426 name="title"
427 value={page.title}
428 required
429 style="padding: 8px;"
430 />
431 <textarea
432 name="body"
433 rows={16}
434 style="padding: 8px; font-family: monospace;"
435 >
436 {page.body}
437 </textarea>
438 <input
439 type="text"
440 name="message"
441 placeholder="Revision message (optional)"
442 style="padding: 8px;"
443 />
444 <button type="submit" class="btn btn-primary">
445 Save
446 </button>
447 </form>
448 </Layout>
449 );
450 }
451);
452
453// Save edit
454wikiRoutes.post(
455 "/:owner/:repo/wiki/:slug/edit",
456 requireAuth,
457 async (c) => {
458 const { owner: ownerName, repo: repoName, slug } = c.req.param();
459 const user = c.get("user")!;
460 const resolved = await resolveRepo(ownerName, repoName);
461 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
462
463 const form = await c.req.formData();
464 const title = (form.get("title") as string || "").trim();
465 const body = (form.get("body") as string || "").trim();
466 const message = (form.get("message") as string || "").trim();
467 if (!title) {
468 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
469 }
470
471 try {
472 const [page] = await db
473 .select()
474 .from(wikiPages)
475 .where(
476 and(
477 eq(wikiPages.repositoryId, resolved.repo.id),
478 eq(wikiPages.slug, slug)
479 )
480 )
481 .limit(1);
482 if (page) {
483 const nextRev = page.revision + 1;
484 await db
485 .update(wikiPages)
486 .set({
487 title,
488 body,
489 revision: nextRev,
490 updatedAt: new Date(),
491 updatedBy: user.id,
492 })
493 .where(eq(wikiPages.id, page.id));
494 await db.insert(wikiRevisions).values({
495 pageId: page.id,
496 revision: nextRev,
497 title,
498 body,
499 message: message || null,
500 authorId: user.id,
501 });
502 }
503 } catch {
504 // swallow
505 }
506 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
507 }
508);
509
510// Delete
511wikiRoutes.post(
512 "/:owner/:repo/wiki/:slug/delete",
513 requireAuth,
514 async (c) => {
515 const { owner: ownerName, repo: repoName, slug } = c.req.param();
516 const user = c.get("user")!;
517 const resolved = await resolveRepo(ownerName, repoName);
518 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
519 if (user.id !== resolved.repo.ownerId) {
520 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
521 }
522 try {
523 await db
524 .delete(wikiPages)
525 .where(
526 and(
527 eq(wikiPages.repositoryId, resolved.repo.id),
528 eq(wikiPages.slug, slug)
529 )
530 );
531 } catch {
532 // swallow
533 }
534 return c.redirect(`/${ownerName}/${repoName}/wiki`);
535 }
536);
537
538// History
539wikiRoutes.get(
540 "/:owner/:repo/wiki/:slug/history",
541 softAuth,
542 async (c) => {
543 const { owner: ownerName, repo: repoName, slug } = c.req.param();
544 const user = c.get("user");
545 const resolved = await resolveRepo(ownerName, repoName);
546 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
547
548 let page: any = null;
549 let revs: any[] = [];
550 try {
551 const [row] = await db
552 .select()
553 .from(wikiPages)
554 .where(
555 and(
556 eq(wikiPages.repositoryId, resolved.repo.id),
557 eq(wikiPages.slug, slug)
558 )
559 )
560 .limit(1);
561 if (row) {
562 page = row;
563 revs = await db
564 .select({
565 r: wikiRevisions,
566 author: { username: users.username },
567 })
568 .from(wikiRevisions)
569 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
570 .where(eq(wikiRevisions.pageId, page.id))
571 .orderBy(desc(wikiRevisions.revision));
572 }
573 } catch {
574 // leave null
575 }
576 if (!page) return c.html(notFound(user, "Page not found"), 404);
577
578 return c.html(
579 <Layout title={`${page.title} — history`} user={user}>
580 <RepoHeader owner={ownerName} repo={repoName} />
581 <h2 style="margin-top: 20px;">
582 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{page.title}</a>
583 history
584 </h2>
585 <table class="file-table">
586 <tbody>
587 {revs.map((rv) => (
588 <tr>
589 <td>
590 <a
591 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
592 >
593 Revision {rv.r.revision}
594 </a>
595 {rv.r.message && (
596 <span style="color: var(--text-muted);">
597 {" "}
598 — {rv.r.message}
599 </span>
600 )}
601 </td>
602 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
603 by @{rv.author.username}
604 {user && user.id === resolved.repo.ownerId &&
605 rv.r.revision !== page.revision && (
606 <>
607 {" "}
608 ·{" "}
609 <form
610 method="POST"
611 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
612 style="display: inline;"
613 >
614 <button type="submit" class="btn" style="font-size: 11px;">
615 Revert to this
616 </button>
617 </form>
618 </>
619 )}
620 </td>
621 </tr>
622 ))}
623 </tbody>
624 </table>
625 </Layout>
626 );
627 }
628);
629
630// View revision
631wikiRoutes.get(
632 "/:owner/:repo/wiki/:slug/revisions/:rev",
633 softAuth,
634 async (c) => {
635 const { owner: ownerName, repo: repoName, slug } = c.req.param();
636 const user = c.get("user");
637 const rev = Number(c.req.param("rev"));
638 const resolved = await resolveRepo(ownerName, repoName);
639 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
640
641 let rv: any = null;
642 try {
643 const [page] = await db
644 .select()
645 .from(wikiPages)
646 .where(
647 and(
648 eq(wikiPages.repositoryId, resolved.repo.id),
649 eq(wikiPages.slug, slug)
650 )
651 )
652 .limit(1);
653 if (page) {
654 const [r] = await db
655 .select()
656 .from(wikiRevisions)
657 .where(
658 and(
659 eq(wikiRevisions.pageId, page.id),
660 eq(wikiRevisions.revision, rev)
661 )
662 )
663 .limit(1);
664 if (r) rv = r;
665 }
666 } catch {
667 // leave null
668 }
669 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
670
671 return c.html(
672 <Layout title={`${rv.title} @ r${rev}`} user={user}>
673 <RepoHeader owner={ownerName} repo={repoName} />
674 <div style="margin-top: 16px; color: var(--text-muted);">
675 Viewing revision {rev} of{" "}
676 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{rv.title}</a>
677 </div>
678 <h1>{rv.title}</h1>
679 <div
680 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
681 />
682 </Layout>
683 );
684 }
685);
686
687// Revert
688wikiRoutes.post(
689 "/:owner/:repo/wiki/:slug/revert/:rev",
690 requireAuth,
691 async (c) => {
692 const { owner: ownerName, repo: repoName, slug } = c.req.param();
693 const rev = Number(c.req.param("rev"));
694 const user = c.get("user")!;
695 const resolved = await resolveRepo(ownerName, repoName);
696 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
697 if (user.id !== resolved.repo.ownerId) {
698 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
699 }
700 try {
701 const [page] = await db
702 .select()
703 .from(wikiPages)
704 .where(
705 and(
706 eq(wikiPages.repositoryId, resolved.repo.id),
707 eq(wikiPages.slug, slug)
708 )
709 )
710 .limit(1);
711 if (!page) {
712 return c.redirect(`/${ownerName}/${repoName}/wiki`);
713 }
714 const [target] = await db
715 .select()
716 .from(wikiRevisions)
717 .where(
718 and(
719 eq(wikiRevisions.pageId, page.id),
720 eq(wikiRevisions.revision, rev)
721 )
722 )
723 .limit(1);
724 if (target) {
725 const nextRev = page.revision + 1;
726 await db
727 .update(wikiPages)
728 .set({
729 title: target.title,
730 body: target.body,
731 revision: nextRev,
732 updatedAt: new Date(),
733 updatedBy: user.id,
734 })
735 .where(eq(wikiPages.id, page.id));
736 await db.insert(wikiRevisions).values({
737 pageId: page.id,
738 revision: nextRev,
739 title: target.title,
740 body: target.body,
741 message: `Reverted to revision ${rev}`,
742 authorId: user.id,
743 });
744 }
745 } catch {
746 // swallow
747 }
748 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
749 }
750);
751
752export default wikiRoutes;
Addedsrc/routes/workflows.tsx+600−0View fileUnifiedSplit
1/**
2 * Actions-equivalent workflow UI (Block C1).
3 *
4 * GET /:owner/:repo/actions — workflows + recent runs
5 * GET /:owner/:repo/actions/runs/:runId — run detail + job logs
6 * POST /:owner/:repo/actions/:workflowId/run — manual trigger (auth)
7 * POST /:owner/:repo/actions/runs/:runId/cancel — cancel a running run (auth)
8 *
9 * Render philosophy: keep the view shallow — the real execution happens in
10 * the runner (src/lib/workflow-runner.ts). This file is just navigation +
11 * manual triggers. Logs for each job are displayed inline (v1 has no
12 * streaming; workers write the final logs blob to the row).
13 */
14
15import { Hono } from "hono";
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 workflowJobs,
22 workflowRuns,
23 workflows,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getUnreadCount } from "../lib/unread";
30import { audit } from "../lib/notify";
31import { enqueueRun } from "../lib/workflow-runner";
32
33const actions = new Hono<AuthEnv>();
34actions.use("*", softAuth);
35
36async function loadRepo(owner: string, repo: string) {
37 const [row] = await db
38 .select({
39 id: repositories.id,
40 name: repositories.name,
41 defaultBranch: repositories.defaultBranch,
42 ownerId: repositories.ownerId,
43 starCount: repositories.starCount,
44 forkCount: repositories.forkCount,
45 })
46 .from(repositories)
47 .innerJoin(users, eq(repositories.ownerId, users.id))
48 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
49 .limit(1);
50 return row;
51}
52
53function relTime(d: Date | string | null): string {
54 if (!d) return "—";
55 const t = typeof d === "string" ? new Date(d) : d;
56 const diffMs = Date.now() - t.getTime();
57 const mins = Math.floor(diffMs / 60000);
58 if (mins < 1) return "just now";
59 if (mins < 60) return `${mins}m ago`;
60 const hrs = Math.floor(mins / 60);
61 if (hrs < 24) return `${hrs}h ago`;
62 const days = Math.floor(hrs / 24);
63 if (days < 30) return `${days}d ago`;
64 return t.toLocaleDateString();
65}
66
67function durationMs(start: Date | string | null, end: Date | string | null): string {
68 if (!start) return "";
69 const s = typeof start === "string" ? new Date(start) : start;
70 const e = end ? (typeof end === "string" ? new Date(end) : end) : new Date();
71 const ms = e.getTime() - s.getTime();
72 if (ms < 1000) return `${ms}ms`;
73 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
74 const m = Math.floor(ms / 60_000);
75 const s2 = Math.floor((ms % 60_000) / 1000);
76 return `${m}m ${s2}s`;
77}
78
79function statusColor(status: string, conclusion: string | null): string {
80 if (status === "running") return "var(--yellow, #e3b341)";
81 if (status === "queued") return "var(--text-muted)";
82 if (status === "cancelled") return "var(--text-muted)";
83 const concl = conclusion || status;
84 if (concl === "success") return "var(--green)";
85 if (concl === "failure") return "var(--red)";
86 return "var(--text-muted)";
87}
88
89function statusGlyph(status: string, conclusion: string | null): string {
90 if (status === "running") return "\u25D0"; // half-circle
91 if (status === "queued") return "\u25CB"; // hollow circle
92 if (status === "cancelled") return "\u2715"; // x
93 const concl = conclusion || status;
94 if (concl === "success") return "\u2713"; // check
95 if (concl === "failure") return "\u2717"; // heavy x
96 if (concl === "skipped") return "\u2013"; // en dash
97 return "\u25CF";
98}
99
100// ---------- List workflows + recent runs ----------
101
102actions.get("/:owner/:repo/actions", async (c) => {
103 const user = c.get("user");
104 const { owner, repo } = c.req.param();
105 const repoRow = await loadRepo(owner, repo);
106 if (!repoRow) return c.notFound();
107
108 let wfs: (typeof workflows.$inferSelect)[] = [];
109 let runs: (typeof workflowRuns.$inferSelect & { workflowName: string | null })[] =
110 [];
111 try {
112 wfs = await db
113 .select()
114 .from(workflows)
115 .where(eq(workflows.repositoryId, repoRow.id))
116 .orderBy(desc(workflows.updatedAt));
117
118 const joined = await db
119 .select({
120 id: workflowRuns.id,
121 workflowId: workflowRuns.workflowId,
122 repositoryId: workflowRuns.repositoryId,
123 runNumber: workflowRuns.runNumber,
124 event: workflowRuns.event,
125 ref: workflowRuns.ref,
126 commitSha: workflowRuns.commitSha,
127 triggeredBy: workflowRuns.triggeredBy,
128 status: workflowRuns.status,
129 conclusion: workflowRuns.conclusion,
130 queuedAt: workflowRuns.queuedAt,
131 startedAt: workflowRuns.startedAt,
132 finishedAt: workflowRuns.finishedAt,
133 createdAt: workflowRuns.createdAt,
134 workflowName: workflows.name,
135 })
136 .from(workflowRuns)
137 .leftJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
138 .where(eq(workflowRuns.repositoryId, repoRow.id))
139 .orderBy(desc(workflowRuns.queuedAt))
140 .limit(50);
141 runs = joined as typeof runs;
142 } catch (err) {
143 console.error("[actions] list:", err);
144 }
145
146 const unread = user ? await getUnreadCount(user.id) : 0;
147 const canRun = !!user && user.id === repoRow.ownerId;
148
149 return c.html(
150 <Layout
151 title={`Actions — ${owner}/${repo}`}
152 user={user}
153 notificationCount={unread}
154 >
155 <RepoHeader
156 owner={owner}
157 repo={repo}
158 starCount={repoRow.starCount}
159 forkCount={repoRow.forkCount}
160 currentUser={user?.username || null}
161 />
162 <RepoNav owner={owner} repo={repo} active="actions" />
163
164 <div style="display: grid; grid-template-columns: 280px 1fr; gap: 20px">
165 <aside>
166 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
167 Workflows
168 </h4>
169 {wfs.length === 0 ? (
170 <div class="panel" style="padding: 12px; font-size: 12px; color: var(--text-muted)">
171 No workflows yet. Add a YAML file under
172 {" "}
173 <code>.gluecron/workflows/</code> on your default branch.
174 </div>
175 ) : (
176 <div class="panel" style="overflow: hidden">
177 {wfs.map((w) => (
178 <div
179 style={`padding: 10px 12px; border-bottom: 1px solid var(--border); ${w.disabled ? "opacity: 0.5" : ""}`}
180 >
181 <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px">
182 <div style="flex: 1; min-width: 0">
183 <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
184 {w.name}
185 </div>
186 <div style="font-size: 11px; color: var(--text-muted); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
187 {w.path}
188 </div>
189 </div>
190 {canRun && !w.disabled && (
191 <form
192 method="POST"
193 action={`/${owner}/${repo}/actions/${w.id}/run`}
194 style="margin: 0"
195 >
196 <button type="submit" class="btn btn-sm" title="Trigger manual run">
197 Run
198 </button>
199 </form>
200 )}
201 </div>
202 </div>
203 ))}
204 </div>
205 )}
206 </aside>
207
208 <section>
209 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
210 Recent runs
211 </h4>
212 {runs.length === 0 ? (
213 <div class="empty-state">
214 <p>No workflow runs yet. Push a commit or trigger one manually.</p>
215 </div>
216 ) : (
217 <div class="panel" style="overflow: hidden">
218 {runs.map((r) => (
219 <a
220 href={`/${owner}/${repo}/actions/runs/${r.id}`}
221 style="display: flex; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
222 >
223 <span
224 style={`display: inline-block; min-width: 18px; text-align: center; color: ${statusColor(r.status, r.conclusion)}; font-weight: 700`}
225 title={r.conclusion || r.status}
226 >
227 {statusGlyph(r.status, r.conclusion)}
228 </span>
229 <div style="flex: 1; min-width: 0">
230 <div style="font-weight: 500">
231 {r.workflowName || "(workflow deleted)"}
232 {" "}
233 <span style="color: var(--text-muted); font-weight: 400">
234 #{r.runNumber}
235 </span>
236 </div>
237 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
238 <span>{r.event}</span>
239 {r.ref && (
240 <>
241 {" · "}
242 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
243 </>
244 )}
245 {r.commitSha && (
246 <>
247 {" · "}
248 <code>{r.commitSha.slice(0, 7)}</code>
249 </>
250 )}
251 {" · "}
252 <span>{relTime(r.queuedAt)}</span>
253 {r.startedAt && r.finishedAt && (
254 <>
255 {" · "}
256 <span>{durationMs(r.startedAt, r.finishedAt)}</span>
257 </>
258 )}
259 </div>
260 </div>
261 </a>
262 ))}
263 </div>
264 )}
265 </section>
266 </div>
267 </Layout>
268 );
269});
270
271// ---------- Run detail ----------
272
273actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
274 const user = c.get("user");
275 const { owner, repo, runId } = c.req.param();
276 const repoRow = await loadRepo(owner, repo);
277 if (!repoRow) return c.notFound();
278
279 let run: typeof workflowRuns.$inferSelect | null = null;
280 let workflowRow: typeof workflows.$inferSelect | null = null;
281 let jobs: (typeof workflowJobs.$inferSelect)[] = [];
282 try {
283 const [r] = await db
284 .select()
285 .from(workflowRuns)
286 .where(
287 and(
288 eq(workflowRuns.id, runId),
289 eq(workflowRuns.repositoryId, repoRow.id)
290 )
291 )
292 .limit(1);
293 run = r || null;
294 if (run) {
295 const [w] = await db
296 .select()
297 .from(workflows)
298 .where(eq(workflows.id, run.workflowId))
299 .limit(1);
300 workflowRow = w || null;
301 jobs = await db
302 .select()
303 .from(workflowJobs)
304 .where(eq(workflowJobs.runId, run.id))
305 .orderBy(workflowJobs.jobOrder);
306 }
307 } catch (err) {
308 console.error("[actions] run detail:", err);
309 }
310
311 if (!run) return c.notFound();
312
313 const unread = user ? await getUnreadCount(user.id) : 0;
314 const canCancel =
315 !!user &&
316 user.id === repoRow.ownerId &&
317 (run.status === "queued" || run.status === "running");
318
319 return c.html(
320 <Layout
321 title={`Run #${run.runNumber} — ${owner}/${repo}`}
322 user={user}
323 notificationCount={unread}
324 >
325 <RepoHeader
326 owner={owner}
327 repo={repo}
328 starCount={repoRow.starCount}
329 forkCount={repoRow.forkCount}
330 currentUser={user?.username || null}
331 />
332 <RepoNav owner={owner} repo={repo} active="actions" />
333
334 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; gap: 12px">
335 <div>
336 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
337 <a href={`/${owner}/${repo}/actions`}>Actions</a>
338 </div>
339 <h3 style="margin: 0">
340 <span
341 style={`color: ${statusColor(run.status, run.conclusion)}; margin-right: 6px`}
342 >
343 {statusGlyph(run.status, run.conclusion)}
344 </span>
345 {workflowRow?.name || "(deleted workflow)"}
346 {" "}
347 <span style="color: var(--text-muted); font-weight: 400">
348 #{run.runNumber}
349 </span>
350 </h3>
351 <div style="font-size: 12px; color: var(--text-muted); margin-top: 6px">
352 <span>{run.event}</span>
353 {run.ref && (
354 <>
355 {" · "}
356 <span>{run.ref.replace(/^refs\/heads\//, "")}</span>
357 </>
358 )}
359 {run.commitSha && (
360 <>
361 {" · "}
362 <a href={`/${owner}/${repo}/commit/${run.commitSha}`}>
363 <code>{run.commitSha.slice(0, 7)}</code>
364 </a>
365 </>
366 )}
367 {" · queued "}
368 <span>{relTime(run.queuedAt)}</span>
369 {run.startedAt && run.finishedAt && (
370 <>
371 {" · duration "}
372 <span>{durationMs(run.startedAt, run.finishedAt)}</span>
373 </>
374 )}
375 </div>
376 </div>
377 {canCancel && (
378 <form
379 method="POST"
380 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
381 onsubmit="return confirm('Cancel this run?')"
382 >
383 <button type="submit" class="btn btn-sm btn-danger">
384 Cancel
385 </button>
386 </form>
387 )}
388 </div>
389
390 {jobs.length === 0 ? (
391 <div class="empty-state">
392 <p>
393 {run.status === "queued"
394 ? "Queued — jobs will appear once the runner picks this up."
395 : "No jobs recorded for this run."}
396 </p>
397 </div>
398 ) : (
399 <div>
400 {jobs.map((j) => {
401 let steps: Array<{
402 name?: string;
403 run?: string;
404 status?: string;
405 exitCode?: number | null;
406 durationMs?: number;
407 stdout?: string;
408 stderr?: string;
409 }> = [];
410 try {
411 steps = JSON.parse(j.steps || "[]");
412 } catch {
413 steps = [];
414 }
415 return (
416 <details class="panel" style="margin-bottom: 16px; overflow: hidden" open>
417 <summary
418 style="padding: 10px 14px; cursor: pointer; display: flex; gap: 10px; align-items: center; background: var(--bg-tertiary)"
419 >
420 <span
421 style={`color: ${statusColor(j.status, j.conclusion)}; font-weight: 700`}
422 >
423 {statusGlyph(j.status, j.conclusion)}
424 </span>
425 <span style="flex: 1; font-weight: 500">{j.name}</span>
426 <span style="font-size: 12px; color: var(--text-muted)">
427 {j.startedAt && j.finishedAt
428 ? durationMs(j.startedAt, j.finishedAt)
429 : j.status}
430 </span>
431 </summary>
432 {steps.length > 0 && (
433 <div style="padding: 8px 14px; border-top: 1px solid var(--border)">
434 {steps.map((s, i) => (
435 <div
436 style="padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; gap: 10px; font-size: 13px"
437 >
438 <span
439 style={`color: ${statusColor(s.status || "", null)}; font-weight: 700; min-width: 18px`}
440 >
441 {statusGlyph(s.status || "", null)}
442 </span>
443 <div style="flex: 1; min-width: 0">
444 <div style="font-weight: 500">
445 {s.name || `Step ${i + 1}`}
446 </div>
447 {s.run && (
448 <code
449 style="display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
450 >
451 $ {s.run}
452 </code>
453 )}
454 </div>
455 {typeof s.durationMs === "number" && (
456 <span style="font-size: 11px; color: var(--text-muted)">
457 {s.durationMs < 1000
458 ? `${s.durationMs}ms`
459 : `${(s.durationMs / 1000).toFixed(1)}s`}
460 </span>
461 )}
462 {typeof s.exitCode === "number" && s.exitCode !== 0 && (
463 <span style="font-size: 11px; color: var(--red)">
464 exit {s.exitCode}
465 </span>
466 )}
467 </div>
468 ))}
469 </div>
470 )}
471 {j.logs && j.logs.length > 0 && (
472 <pre
473 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
474 >
475 {j.logs}
476 </pre>
477 )}
478 </details>
479 );
480 })}
481 </div>
482 )}
483 </Layout>
484 );
485});
486
487// ---------- Manual trigger ----------
488
489actions.post("/:owner/:repo/actions/:workflowId/run", requireAuth, async (c) => {
490 const user = c.get("user")!;
491 const { owner, repo, workflowId } = c.req.param();
492 const repoRow = await loadRepo(owner, repo);
493 if (!repoRow) return c.notFound();
494 if (repoRow.ownerId !== user.id) {
495 return c.redirect(`/${owner}/${repo}/actions`);
496 }
497
498 let workflowRow: typeof workflows.$inferSelect | null = null;
499 try {
500 const [w] = await db
501 .select()
502 .from(workflows)
503 .where(
504 and(
505 eq(workflows.id, workflowId),
506 eq(workflows.repositoryId, repoRow.id)
507 )
508 )
509 .limit(1);
510 workflowRow = w || null;
511 } catch (err) {
512 console.error("[actions] manual trigger lookup:", err);
513 }
514 if (!workflowRow) return c.notFound();
515 if (workflowRow.disabled) {
516 return c.redirect(`/${owner}/${repo}/actions`);
517 }
518
519 const ref = `refs/heads/${repoRow.defaultBranch || "main"}`;
520
521 const runId = await enqueueRun({
522 workflowId: workflowRow.id,
523 repositoryId: repoRow.id,
524 event: "manual",
525 ref,
526 commitSha: null,
527 triggeredBy: user.id,
528 });
529
530 await audit({
531 userId: user.id,
532 repositoryId: repoRow.id,
533 action: "workflow.manual_trigger",
534 targetType: "workflow",
535 targetId: workflowRow.id,
536 metadata: { runId },
537 });
538
539 if (runId) {
540 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
541 }
542 return c.redirect(`/${owner}/${repo}/actions`);
543});
544
545// ---------- Cancel a run ----------
546
547actions.post(
548 "/:owner/:repo/actions/runs/:runId/cancel",
549 requireAuth,
550 async (c) => {
551 const user = c.get("user")!;
552 const { owner, repo, runId } = c.req.param();
553 const repoRow = await loadRepo(owner, repo);
554 if (!repoRow) return c.notFound();
555 if (repoRow.ownerId !== user.id) {
556 return c.redirect(`/${owner}/${repo}/actions`);
557 }
558
559 try {
560 await db
561 .update(workflowRuns)
562 .set({
563 status: "cancelled",
564 conclusion: "cancelled",
565 finishedAt: new Date(),
566 })
567 .where(
568 and(
569 eq(workflowRuns.id, runId),
570 eq(workflowRuns.repositoryId, repoRow.id)
571 )
572 );
573 // Mark any queued/running jobs as cancelled for display. The worker
574 // will observe the parent run's status on its next check, but v1 runs
575 // a step to completion before checking.
576 await db
577 .update(workflowJobs)
578 .set({
579 status: "cancelled",
580 conclusion: "cancelled",
581 finishedAt: new Date(),
582 })
583 .where(eq(workflowJobs.runId, runId));
584 } catch (err) {
585 console.error("[actions] cancel:", err);
586 }
587
588 await audit({
589 userId: user.id,
590 repositoryId: repoRow.id,
591 action: "workflow.cancel",
592 targetType: "workflow_run",
593 targetId: runId,
594 });
595
596 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
597 }
598);
599
600export default actions;
Modifiedsrc/views/components.tsx+122−21View fileUnifiedSplit
1111 forkCount?: number;
1212 currentUser?: string | null;
1313 forkedFrom?: string | null;
14}> = ({ owner, repo, starCount, starred, forkCount, currentUser, forkedFrom }) => (
14 archived?: boolean;
15 isTemplate?: boolean;
16}> = ({
17 owner,
18 repo,
19 starCount,
20 starred,
21 forkCount,
22 currentUser,
23 forkedFrom,
24 archived,
25 isTemplate,
26}) => (
1527 <div class="repo-header">
1628 <div>
1729 <div style="display: flex; align-items: center; gap: 8px; font-size: 20px">
2234 <a href={`/${owner}/${repo}`} class="name">
2335 {repo}
2436 </a>
37 {archived && (
38 <span
39 class="badge"
40 style="background:var(--bg-secondary);color:var(--text-muted);font-size:11px;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:0.5px"
41 title="Read-only: pushes and new issues/PRs disabled"
42 >
43 Archived
44 </span>
45 )}
46 {isTemplate && (
47 <span
48 class="badge"
49 style="background:var(--bg-secondary);color:var(--accent);font-size:11px;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:0.5px"
50 title="This repository can be used as a template"
51 >
52 Template
53 </span>
54 )}
2555 </div>
2656 {forkedFrom && (
2757 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
6090export const RepoNav: FC<{
6191 owner: string;
6292 repo: string;
63 active: "code" | "commits" | "issues" | "pulls";
93 active:
94 | "code"
95 | "commits"
96 | "issues"
97 | "pulls"
98 | "releases"
99 | "actions"
100 | "gates"
101 | "insights"
102 | "explain"
103 | "changelog"
104 | "semantic"
105 | "wiki"
106 | "projects";
64107}> = ({ owner, repo, active }) => (
65108 <div class="repo-nav">
66109 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
72115 >
73116 Issues
74117 </a>
118 <a
119 href={`/${owner}/${repo}/wiki`}
120 class={active === "wiki" ? "active" : ""}
121 >
122 Wiki
123 </a>
75124 <a
76125 href={`/${owner}/${repo}/pulls`}
77126 class={active === "pulls" ? "active" : ""}
78127 >
79128 Pull Requests
80129 </a>
130 <a
131 href={`/${owner}/${repo}/projects`}
132 class={active === "projects" ? "active" : ""}
133 >
134 Projects
135 </a>
81136 <a
82137 href={`/${owner}/${repo}/commits`}
83138 class={active === "commits" ? "active" : ""}
84139 >
85140 Commits
86141 </a>
142 <a
143 href={`/${owner}/${repo}/actions`}
144 class={active === "actions" ? "active" : ""}
145 >
146 Actions
147 </a>
148 <a
149 href={`/${owner}/${repo}/releases`}
150 class={active === "releases" ? "active" : ""}
151 >
152 Releases
153 </a>
154 <a
155 href={`/${owner}/${repo}/gates`}
156 class={active === "gates" ? "active" : ""}
157 >
158 {"\u25CF"} Gates
159 </a>
160 <a
161 href={`/${owner}/${repo}/insights`}
162 class={active === "insights" ? "active" : ""}
163 >
164 Insights
165 </a>
166 <a
167 href={`/${owner}/${repo}/explain`}
168 class={active === "explain" ? "active" : ""}
169 style="margin-left: auto; color: #bc8cff"
170 >
171 {"\u2728"} Explain
172 </a>
173 <a href={`/${owner}/${repo}/ask`} style="color: #bc8cff">
174 {"\u2728"} Ask AI
175 </a>
87176 </div>
88177);
89178
244333 commits: GitCommit[];
245334 owner: string;
246335 repo: string;
247}> = ({ commits, owner, repo }) => (
336 verifications?: Record<string, { verified: boolean; reason: string }>;
337}> = ({ commits, owner, repo, verifications }) => (
248338 <div class="commit-list">
249 {commits.map((commit) => (
250 <div class="commit-item">
251 <div>
252 <div class="commit-message">
253 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
254 {commit.message}
255 </a>
256 </div>
257 <div class="commit-meta">
258 {commit.author} committed {formatRelativeDate(commit.date)}
339 {commits.map((commit) => {
340 const v = verifications?.[commit.sha];
341 return (
342 <div class="commit-item">
343 <div>
344 <div class="commit-message">
345 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
346 {commit.message}
347 </a>
348 {v?.verified && (
349 <span
350 title="Signed with a registered key"
351 style="margin-left:8px;font-size:10px;padding:1px 6px;border-radius:3px;background:var(--green,#2ea043);color:#fff;text-transform:uppercase;letter-spacing:.4px"
352 >
353 Verified
354 </span>
355 )}
356 </div>
357 <div class="commit-meta">
358 {commit.author} committed {formatRelativeDate(commit.date)}
359 </div>
259360 </div>
361 <a
362 href={`/${owner}/${repo}/commit/${commit.sha}`}
363 class="commit-sha"
364 >
365 {commit.sha.slice(0, 7)}
366 </a>
260367 </div>
261 <a
262 href={`/${owner}/${repo}/commit/${commit.sha}`}
263 class="commit-sha"
264 >
265 {commit.sha.slice(0, 7)}
266 </a>
267 </div>
268 ))}
368 );
369 })}
269370 </div>
270371);
271372
Modifiedsrc/views/layout.tsx+252−4View fileUnifiedSplit
44import { clientJs } from "./client-js";
55
66export const Layout: FC<
7 PropsWithChildren<{ title?: string; user?: User | null }>
8> = ({ children, title, user }) => {
7 PropsWithChildren<{
8 title?: string;
9 user?: User | null;
10 notificationCount?: number;
11 theme?: "dark" | "light";
12 }>
13> = ({ children, title, user, notificationCount, theme }) => {
14 const initialTheme = theme === "light" ? "light" : "dark";
915 return (
10 <html lang="en">
16 <html lang="en" data-theme={initialTheme}>
1117 <head>
1218 <meta charset="UTF-8" />
1319 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
20 <meta name="theme-color" content="#0d1117" />
21 <link rel="manifest" href="/manifest.webmanifest" />
22 <link rel="icon" type="image/svg+xml" href="/icon.svg" />
1423 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
24 <script>{themeInitScript}</script>
1525 <style>{css}</style>
1626 <style>{hljsThemeCss}</style>
1727 </head>
2232 <a href="/" class="logo">
2333 gluecron
2434 </a>
35 <div class="nav-search">
36 <form method="GET" action="/search">
37 <input
38 type="search"
39 name="q"
40 placeholder="Search (press /)"
41 aria-label="Search"
42 />
43 </form>
44 </div>
2545 <div class="nav-right">
46 <a
47 href="/theme/toggle"
48 class="nav-link nav-theme"
49 title="Toggle theme"
50 aria-label="Toggle theme"
51 >
52 <span class="theme-icon-dark">{"\u263E"}</span>
53 <span class="theme-icon-light">{"\u2600"}</span>
54 </a>
2655 <a href="/explore" class="nav-link">
2756 Explore
2857 </a>
2958 {user ? (
3059 <>
60 <a href="/ask" class="nav-link" title="Ask AI (Cmd+K)">
61 {"\u2728"} Ask
62 </a>
63 <a
64 href="/notifications"
65 class="nav-link nav-notifications"
66 title="Notifications"
67 >
68 {"\u2709"}
69 {notificationCount !== undefined && notificationCount > 0 && (
70 <span class="nav-badge">
71 {notificationCount > 99 ? "99+" : notificationCount}
72 </span>
73 )}
74 </a>
3175 <a href="/new" class="btn btn-sm btn-primary">
3276 + New
3377 </a>
67111 );
68112};
69113
114// Runs before paint — reads the theme cookie and flips data-theme so there's
115// no dark-to-light flash on load. SSR default is dark.
116const themeInitScript = `
117 (function(){
118 try {
119 var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
120 var t = m ? decodeURIComponent(m[1]) : 'dark';
121 if (t !== 'light' && t !== 'dark') t = 'dark';
122 document.documentElement.setAttribute('data-theme', t);
123 } catch(_){}
124 })();
125`;
126
127// Block G1 — register service worker for offline / install support.
128// Kept inline (and tiny) so we don't block first paint.
129const pwaRegisterScript = `
130 if ('serviceWorker' in navigator) {
131 window.addEventListener('load', function(){
132 navigator.serviceWorker.register('/sw.js').catch(function(){});
133 });
134 }
135`;
136
137const navScript = `
138 (function(){
139 var chord = null;
140 var chordTimer = null;
141 function isTyping(t){
142 t = t || {};
143 var tag = (t.tagName || '').toLowerCase();
144 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
145 }
146
147 // ---------- Block I4 — Command palette ----------
148 var COMMANDS = [
149 { label: 'Go to Dashboard', href: '/dashboard', kw: 'home' },
150 { label: 'Go to Explore', href: '/explore', kw: 'browse discover' },
151 { label: 'Go to Notifications', href: '/notifications', kw: 'inbox' },
152 { label: 'Go to Ask AI', href: '/ask', kw: 'chat assistant' },
153 { label: 'Create new repository', href: '/new', kw: 'add create' },
154 { label: 'Marketplace', href: '/marketplace', kw: 'apps store' },
155 { label: 'Installed apps', href: '/settings/apps', kw: 'my apps' },
156 { label: 'Register new app', href: '/developer/apps-new', kw: 'developer create' },
157 { label: 'Keyboard shortcuts', href: '/shortcuts', kw: 'help keys' },
158 { label: 'Settings (profile)', href: '/settings', kw: 'account' },
159 { label: '2FA settings', href: '/settings/2fa', kw: 'two factor security' },
160 { label: 'Passkeys settings', href: '/settings/passkeys', kw: 'webauthn' },
161 { label: 'Personal access tokens', href: '/settings/tokens', kw: 'pat api' },
162 { label: 'Billing + quotas', href: '/settings/billing', kw: 'plans money' },
163 { label: 'Audit log (personal)', href: '/settings/audit', kw: 'history' },
164 { label: 'Gists', href: '/gists', kw: 'snippets' },
165 { label: 'GraphQL explorer', href: '/api/graphql', kw: 'api query' },
166 { label: 'Admin dashboard', href: '/admin', kw: 'superuser' },
167 { label: 'Toggle theme', href: '/theme/toggle', kw: 'dark light mode' }
168 ];
169
170 function fuzzyMatch(item, q){
171 if (!q) return true;
172 var hay = (item.label + ' ' + (item.kw||'') + ' ' + item.href).toLowerCase();
173 q = q.toLowerCase();
174 var qi = 0;
175 for (var i = 0; i < hay.length && qi < q.length; i++) {
176 if (hay[i] === q[qi]) qi++;
177 }
178 return qi === q.length;
179 }
180
181 var backdrop, panel, input, list, selected = 0, filtered = COMMANDS.slice();
182
183 function render(){
184 if (!list) return;
185 var html = '';
186 for (var i = 0; i < filtered.length; i++) {
187 var item = filtered[i];
188 var cls = i === selected ? 'cmdk-item cmdk-active' : 'cmdk-item';
189 var bg = i === selected ? 'background:var(--bg);' : '';
190 html += '<div class="' + cls + '" data-idx="' + i + '" data-href="' + item.href + '"' +
191 ' style="padding:10px 16px;cursor:pointer;border-bottom:1px solid var(--border);' + bg + '">' +
192 '<div>' + item.label + '</div>' +
193 '<div style="font-size:11px;color:var(--text-muted)">' + item.href + '</div>' +
194 '</div>';
195 }
196 if (filtered.length === 0) {
197 html = '<div style="padding:16px;color:var(--text-muted);text-align:center">No matches.</div>';
198 }
199 list.innerHTML = html;
200 }
201
202 function openPalette(){
203 backdrop = document.getElementById('cmdk-backdrop');
204 panel = document.getElementById('cmdk-panel');
205 input = document.getElementById('cmdk-input');
206 list = document.getElementById('cmdk-list');
207 if (!backdrop || !panel) return;
208 backdrop.style.display = 'block';
209 panel.style.display = 'block';
210 input.value = '';
211 selected = 0;
212 filtered = COMMANDS.slice();
213 render();
214 input.focus();
215 }
216 function closePalette(){
217 if (backdrop) backdrop.style.display = 'none';
218 if (panel) panel.style.display = 'none';
219 }
220 function go(href){ closePalette(); window.location.href = href; }
221
222 document.addEventListener('click', function(e){
223 var t = e.target;
224 if (t && t.id === 'cmdk-backdrop') { closePalette(); return; }
225 var item = t && t.closest && t.closest('.cmdk-item');
226 if (item) { go(item.getAttribute('data-href')); }
227 });
228
229 document.addEventListener('input', function(e){
230 if (e.target && e.target.id === 'cmdk-input') {
231 var q = e.target.value;
232 filtered = COMMANDS.filter(function(c){ return fuzzyMatch(c, q); });
233 selected = 0;
234 render();
235 }
236 });
237
238 document.addEventListener('keydown', function(e){
239 // Palette-scoped keys take priority when open
240 if (panel && panel.style.display === 'block') {
241 if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; }
242 if (e.key === 'ArrowDown') {
243 e.preventDefault();
244 selected = Math.min(filtered.length - 1, selected + 1);
245 render();
246 return;
247 }
248 if (e.key === 'ArrowUp') {
249 e.preventDefault();
250 selected = Math.max(0, selected - 1);
251 render();
252 return;
253 }
254 if (e.key === 'Enter') {
255 e.preventDefault();
256 var item = filtered[selected];
257 if (item) go(item.href);
258 return;
259 }
260 return;
261 }
262
263 if (isTyping(e.target)) return;
264 // Single key shortcuts
265 if (e.key === '/') {
266 var el = document.querySelector('.nav-search input');
267 if (el) { e.preventDefault(); el.focus(); return; }
268 }
269 if ((e.metaKey||e.ctrlKey) && e.key.toLowerCase() === 'k') {
270 e.preventDefault();
271 openPalette();
272 return;
273 }
274 if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
275 e.preventDefault(); window.location.href = '/shortcuts'; return;
276 }
277 if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
278 e.preventDefault(); window.location.href = '/new'; return;
279 }
280 // "g" chord
281 if (e.key === 'g' && !e.ctrlKey && !e.metaKey) {
282 chord = 'g';
283 clearTimeout(chordTimer);
284 chordTimer = setTimeout(function(){ chord = null; }, 1200);
285 return;
286 }
287 if (chord === 'g') {
288 if (e.key === 'd') { e.preventDefault(); window.location.href = '/dashboard'; }
289 else if (e.key === 'n') { e.preventDefault(); window.location.href = '/notifications'; }
290 else if (e.key === 'e') { e.preventDefault(); window.location.href = '/explore'; }
291 else if (e.key === 'a') { e.preventDefault(); window.location.href = '/ask'; }
292 chord = null;
293 }
294 });
295 })();
296`;
297
70298const css = `
71 :root {
299 :root, :root[data-theme='dark'] {
72300 --bg: #0d1117;
73301 --bg-secondary: #161b22;
74302 --bg-tertiary: #21262d;
86314 --radius: 6px;
87315 }
88316
317 :root[data-theme='light'] {
318 --bg: #ffffff;
319 --bg-secondary: #f6f8fa;
320 --bg-tertiary: #eaeef2;
321 --border: #d0d7de;
322 --text: #1f2328;
323 --text-muted: #656d76;
324 --text-link: #0969da;
325 --accent: #0969da;
326 --accent-hover: #0550ae;
327 --green: #1a7f37;
328 --red: #cf222e;
329 --yellow: #9a6700;
330 }
331
332 /* Theme toggle — show the icon for the *opposite* theme so users see what they'll switch to. */
333 .nav-theme { display: inline-flex; align-items: center; font-size: 16px; line-height: 1; }
334 :root[data-theme='dark'] .theme-icon-dark { display: none; }
335 :root[data-theme='light'] .theme-icon-light { display: none; }
336
89337 * { margin: 0; padding: 0; box-sizing: border-box; }
90338
91339 body {
Addedsrc/views/reactions.tsx+68−0View fileUnifiedSplit
1/**
2 * Reactions bar — displays current counts + a "+ react" picker.
3 * Works without JavaScript: each emoji is its own POST form.
4 */
5
6import type { FC } from "hono/jsx";
7import {
8 ALLOWED_EMOJIS,
9 EMOJI_GLYPH,
10 type Emoji,
11 type ReactionSummary,
12 type TargetType,
13} from "../lib/reactions";
14
15export const ReactionsBar: FC<{
16 targetType: TargetType;
17 targetId: string;
18 summaries: ReactionSummary[];
19 canReact: boolean;
20}> = ({ targetType, targetId, summaries, canReact }) => {
21 const byEmoji = new Map<Emoji, ReactionSummary>();
22 for (const s of summaries) byEmoji.set(s.emoji, s);
23
24 const action = (emoji: Emoji) =>
25 `/api/reactions/${targetType}/${targetId}/${emoji}/toggle`;
26
27 const visible = summaries.filter((s) => s.count > 0);
28
29 return (
30 <div class="reactions" data-target={`${targetType}:${targetId}`}>
31 {visible.map((s) => (
32 <form method="POST" action={action(s.emoji)} style="display: inline">
33 <button
34 type="submit"
35 class={`reaction-btn ${s.reactedByMe ? "active" : ""}`}
36 title={s.emoji.replace(/_/g, " ")}
37 disabled={!canReact}
38 >
39 <span>{EMOJI_GLYPH[s.emoji]}</span>
40 <span class="reaction-count">{s.count}</span>
41 </button>
42 </form>
43 ))}
44 {canReact && (
45 <details class="reaction-picker">
46 <summary class="reaction-btn" title="Add reaction">
47 {"\u271A"}
48 </summary>
49 <div style="display: flex; gap: 4px; padding: 4px">
50 {ALLOWED_EMOJIS.filter((e) => !byEmoji.get(e)?.reactedByMe).map(
51 (emoji) => (
52 <form method="POST" action={action(emoji)} style="display: inline">
53 <button
54 type="submit"
55 class="reaction-btn"
56 title={emoji.replace(/_/g, " ")}
57 >
58 <span>{EMOJI_GLYPH[emoji]}</span>
59 </button>
60 </form>
61 )
62 )}
63 </div>
64 </details>
65 )}
66 </div>
67 );
68};
Addedvscode-extension/README.md+40−0View fileUnifiedSplit
1# Gluecron — VS Code Extension
2
3Talk to your Gluecron server straight from the editor. Explain files, open on
4the web, run semantic searches, scaffold failing tests.
5
6## Install (dev)
7
8```
9cd vscode-extension
10npm install
11npm run compile
12code --install-extension .
13```
14
15## Configure
16
17Add to your `settings.json`:
18
19```json
20{
21 "gluecron.host": "https://gluecron.com",
22 "gluecron.token": "glc_..."
23}
24```
25
26Tokens come from `/settings/tokens` on your Gluecron instance.
27
28## Commands
29
30| Command | What it does |
31|---|---|
32| `Gluecron: Explain This File` | 3-5 bullet summary via `/api/copilot/completions` |
33| `Gluecron: Open Current File on Web` | Opens `./owner/repo/blob/main/<path>#L<line>` |
34| `Gluecron: Semantic Search` | Prompts for a query, hits `/api/graphql` |
35| `Gluecron: Generate Tests for Current File` | Scaffolds a failing test via `/ai/tests?format=raw` |
36
37## Repo detection
38
39The extension reads `git config --get remote.origin.url` and strips the host
40prefix. If you're using a self-hosted Gluecron, set `gluecron.host` accordingly.
Addedvscode-extension/package.json+68−0View fileUnifiedSplit
1{
2 "name": "gluecron-vscode",
3 "displayName": "Gluecron",
4 "description": "VS Code integration for Gluecron — AI-native code intelligence",
5 "version": "0.1.0",
6 "publisher": "gluecron",
7 "license": "MIT",
8 "engines": {
9 "vscode": "^1.80.0"
10 },
11 "categories": [
12 "SCM Providers",
13 "Other"
14 ],
15 "activationEvents": [
16 "onStartupFinished"
17 ],
18 "main": "./dist/extension.js",
19 "contributes": {
20 "commands": [
21 {
22 "command": "gluecron.explainFile",
23 "title": "Gluecron: Explain This File"
24 },
25 {
26 "command": "gluecron.openOnWeb",
27 "title": "Gluecron: Open Current File on Web"
28 },
29 {
30 "command": "gluecron.searchSemantic",
31 "title": "Gluecron: Semantic Search"
32 },
33 {
34 "command": "gluecron.generateTests",
35 "title": "Gluecron: Generate Tests for Current File"
36 }
37 ],
38 "configuration": {
39 "title": "Gluecron",
40 "properties": {
41 "gluecron.host": {
42 "type": "string",
43 "default": "http://localhost:3000",
44 "description": "Gluecron server URL"
45 },
46 "gluecron.token": {
47 "type": "string",
48 "default": "",
49 "description": "Personal access token (glc_...)"
50 }
51 }
52 },
53 "menus": {
54 "editor/context": [
55 { "command": "gluecron.explainFile", "group": "gluecron" },
56 { "command": "gluecron.openOnWeb", "group": "gluecron" }
57 ]
58 }
59 },
60 "scripts": {
61 "compile": "tsc -p .",
62 "watch": "tsc -watch -p ."
63 },
64 "devDependencies": {
65 "@types/vscode": "^1.80.0",
66 "typescript": "^5.3.0"
67 }
68}
Addedvscode-extension/src/extension.ts+209−0View fileUnifiedSplit
1/**
2 * Block G4 — Gluecron VS Code extension.
3 *
4 * Commands:
5 * gluecron.explainFile — call `/api/v1/ai/explain-file` + show in a hover
6 * gluecron.openOnWeb — opens the current file on the Gluecron web UI
7 * gluecron.searchSemantic — quickPick -> /api/v1/search/semantic
8 * gluecron.generateTests — scaffold a failing test via /api/v1/ai/tests
9 *
10 * We keep this file zero-runtime-dependencies besides `vscode`. Everything
11 * else is pure stdlib + fetch.
12 */
13
14import * as vscode from "vscode";
15import { execSync } from "node:child_process";
16import { basename, relative } from "node:path";
17
18function getHost(): string {
19 return (
20 vscode.workspace.getConfiguration("gluecron").get<string>("host") ||
21 "http://localhost:3000"
22 );
23}
24
25function getToken(): string {
26 return (
27 vscode.workspace.getConfiguration("gluecron").get<string>("token") || ""
28 );
29}
30
31async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
32 const url = getHost().replace(/\/+$/, "") + path;
33 const token = getToken();
34 const res = await fetch(url, {
35 ...init,
36 headers: {
37 "content-type": "application/json",
38 accept: "application/json",
39 ...(token ? { authorization: `Bearer ${token}` } : {}),
40 ...(init?.headers || {}),
41 },
42 });
43 const text = await res.text();
44 if (!res.ok) throw new Error(`[${res.status}] ${text.slice(0, 200)}`);
45 try {
46 return JSON.parse(text) as T;
47 } catch {
48 return text as unknown as T;
49 }
50}
51
52/**
53 * Inspect the current workspace for a git remote whose URL matches a Gluecron
54 * host. Returns `{ owner, repo }` if found.
55 */
56export function detectGlueRepo(cwd: string, host: string): {
57 owner: string;
58 repo: string;
59} | null {
60 try {
61 const url = execSync("git config --get remote.origin.url", {
62 cwd,
63 encoding: "utf8",
64 }).trim();
65 if (!url) return null;
66 // host-agnostic parsing — look for /:owner/:repo at end
67 const cleaned = url
68 .replace(/^https?:\/\/[^/]+\//, "")
69 .replace(/^git@[^:]+:/, "")
70 .replace(/\.git$/, "");
71 const [owner, repo] = cleaned.split("/").filter(Boolean);
72 if (!owner || !repo) return null;
73 return { owner, repo };
74 } catch {
75 return null;
76 }
77}
78
79export function buildWebUrl(
80 host: string,
81 owner: string,
82 repo: string,
83 relPath: string,
84 line?: number
85): string {
86 const base = `${host.replace(/\/+$/, "")}/${owner}/${repo}/blob/main/${relPath}`;
87 return line ? `${base}#L${line + 1}` : base;
88}
89
90export function activate(context: vscode.ExtensionContext) {
91 const output = vscode.window.createOutputChannel("Gluecron");
92 output.appendLine("Gluecron activated.");
93
94 context.subscriptions.push(
95 vscode.commands.registerCommand("gluecron.openOnWeb", async () => {
96 const editor = vscode.window.activeTextEditor;
97 if (!editor) {
98 vscode.window.showInformationMessage("No active editor");
99 return;
100 }
101 const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
102 if (!folder) {
103 vscode.window.showWarningMessage("File is not in a workspace");
104 return;
105 }
106 const cwd = folder.uri.fsPath;
107 const rel = relative(cwd, editor.document.uri.fsPath);
108 const repo = detectGlueRepo(cwd, getHost());
109 if (!repo) {
110 vscode.window.showWarningMessage("No Gluecron remote detected");
111 return;
112 }
113 const url = buildWebUrl(
114 getHost(),
115 repo.owner,
116 repo.repo,
117 rel,
118 editor.selection.active.line
119 );
120 vscode.env.openExternal(vscode.Uri.parse(url));
121 })
122 );
123
124 context.subscriptions.push(
125 vscode.commands.registerCommand("gluecron.explainFile", async () => {
126 const editor = vscode.window.activeTextEditor;
127 if (!editor) return;
128 const content = editor.document.getText();
129 const path = basename(editor.document.fileName);
130 output.show(true);
131 output.appendLine(`Explaining ${path}...`);
132 try {
133 const res = await api<{ explanation?: string }>(
134 `/api/copilot/completions`,
135 {
136 method: "POST",
137 body: JSON.stringify({
138 prompt: `Explain this file in 3-5 bullet points:\n\n${content.slice(
139 0,
140 8000
141 )}`,
142 max_tokens: 400,
143 }),
144 }
145 );
146 output.appendLine(res.explanation || JSON.stringify(res, null, 2));
147 } catch (err) {
148 output.appendLine(`error: ${(err as Error).message}`);
149 }
150 })
151 );
152
153 context.subscriptions.push(
154 vscode.commands.registerCommand("gluecron.searchSemantic", async () => {
155 const q = await vscode.window.showInputBox({
156 prompt: "Semantic search across repo",
157 placeHolder: "how does auth work?",
158 });
159 if (!q) return;
160 const folder = vscode.workspace.workspaceFolders?.[0];
161 if (!folder) return;
162 const repo = detectGlueRepo(folder.uri.fsPath, getHost());
163 if (!repo) return;
164 const res = await api<{ results?: Array<{ path: string; score: number }> }>(
165 `/api/graphql`,
166 {
167 method: "POST",
168 body: JSON.stringify({
169 query: `{ search(q:"${q.replace(/"/g, "'")}", limit:10) { name ownerUsername } }`,
170 }),
171 }
172 );
173 output.show(true);
174 output.appendLine(JSON.stringify(res, null, 2));
175 })
176 );
177
178 context.subscriptions.push(
179 vscode.commands.registerCommand("gluecron.generateTests", async () => {
180 const editor = vscode.window.activeTextEditor;
181 if (!editor) return;
182 const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
183 if (!folder) return;
184 const repo = detectGlueRepo(folder.uri.fsPath, getHost());
185 if (!repo) return;
186 const rel = relative(folder.uri.fsPath, editor.document.uri.fsPath);
187 try {
188 const res = await api(
189 `/${repo.owner}/${repo.repo}/ai/tests?format=raw&path=${encodeURIComponent(
190 rel
191 )}`
192 );
193 const doc = await vscode.workspace.openTextDocument({
194 content: typeof res === "string" ? res : JSON.stringify(res, null, 2),
195 language: editor.document.languageId,
196 });
197 vscode.window.showTextDocument(doc);
198 } catch (err) {
199 vscode.window.showErrorMessage(
200 `Gluecron test generation failed: ${(err as Error).message}`
201 );
202 }
203 })
204 );
205}
206
207export function deactivate() {
208 // nothing to clean up
209}
Addedvscode-extension/tsconfig.json+15−0View fileUnifiedSplit
1{
2 "compilerOptions": {
3 "module": "commonjs",
4 "target": "ES2020",
5 "outDir": "dist",
6 "rootDir": "src",
7 "lib": ["ES2020"],
8 "strict": true,
9 "esModuleInterop": true,
10 "skipLibCheck": true,
11 "resolveJsonModule": true
12 },
13 "include": ["src/**/*"],
14 "exclude": ["node_modules", "dist"]
15}
016