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

Merge pull request #5 from ccantynz-alt/claude/continue-work-XMTlI

Merge pull request #5 from ccantynz-alt/claude/continue-work-XMTlI

Claude/continue work xm tl i
Dictation App committed on April 15, 2026Parents: 6d050a6 63807e5
30 files changed+408917269d1b4ed96e653075117c27b577c45437a817de2
30 changed files+4089−172
Modified.env.example+12−0View fileUnifiedSplit
33PORT=3000
44GATETEST_URL=https://gatetest.ai/api/scan/run
55GATETEST_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=
611CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
712ANTHROPIC_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+397−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---
50
51## 2. GITHUB PARITY SCORECARD
52
53Legend: ✅ shipped · 🟡 partial · ❌ not built
54
55### 2.1 Repository hosting
56| Feature | Status | Notes |
57|---|---|---|
58| Git Smart HTTP (clone / push / fetch) | ✅ | `src/routes/git.ts`, `src/git/protocol.ts` |
59| SSH keys | ✅ | `ssh_keys` table, `src/routes/settings.tsx` |
60| Public / private visibility | ✅ | `repositories.isPrivate` |
61| Forking | ✅ | `src/routes/fork.ts` |
62| Stars | ✅ | `stars` table, `/:owner/:repo/star` |
63| Topics | ✅ | `repo_topics` table |
64| Archive / disable repo | ❌ | schema has flags; no UI |
65| Repository transfer | ❌ | — |
66| Template repositories | ❌ | — |
67| Repository mirroring | ❌ | — |
68
69### 2.2 Code browsing
70| Feature | Status | Notes |
71|---|---|---|
72| File tree browser | ✅ | `src/routes/web.tsx` |
73| Syntax highlighting | ✅ | 40+ languages, `src/lib/highlight.ts` |
74| Commit history | ✅ | |
75| Diffs | ✅ | |
76| Blame | ✅ | |
77| Raw file download | ✅ | |
78| Branch switcher | ✅ | |
79| Tag listing | ✅ | new this build |
80| Code search (ILIKE) | ✅ | per-repo + global |
81| Semantic / embedding search | ❌ | pgvector not wired |
82| Symbol / xref navigation | ❌ | — |
83
84### 2.3 Collaboration
85| Feature | Status | Notes |
86|---|---|---|
87| Issues (CRUD / comments / labels / close) | ✅ | |
88| Milestones | ✅ | `src/routes/insights.tsx` |
89| Pull requests (CRUD / review / merge) | ✅ | |
90| PR inline comments | ✅ | file+line anchored |
91| Draft PRs | ✅ | create as draft, ready-for-review toggle, dedicated tab, merge blocked until ready |
92| Reactions (emoji) | ✅ | 8 reactions, toggle via `POST /api/reactions/:t/:id/:emoji/toggle` on issues + PRs + comments |
93| Mentions + notifications | ✅ | `src/routes/notifications.tsx` |
94| Code owners | ✅ | `src/lib/codeowners.ts` |
95| Issue templates | ✅ | `.github/ISSUE_TEMPLATE.md` auto-prefills new issues; frontmatter stripped; `src/lib/templates.ts` |
96| PR templates | ✅ | `.github/PULL_REQUEST_TEMPLATE.md` auto-prefills new PRs; `src/lib/templates.ts` |
97| Saved replies | ✅ | per-user canned comments, unique-shortcut, `/settings/replies`, `/api/user/replies` |
98| Discussions / forums | ❌ | |
99| Wikis | ❌ | |
100| Projects / kanban | ❌ | |
101
102### 2.4 Automation + AI
103| Feature | Status | Notes |
104|---|---|---|
105| Webhooks (outbound, HMAC signed) | ✅ | `src/routes/webhooks.tsx` |
106| GateTest inbound callback | ✅ | `POST /api/hooks/gatetest`, bearer or HMAC |
107| Backup PAT-auth gate ingest | ✅ | `POST /api/v1/gate-runs` |
108| Gate runs (test / secret / AI review) | ✅ | `gate_runs` table, `src/routes/gates.tsx` |
109| Branch protection | ✅ | `branch_protection` table + UI |
110| Auto-repair engine | ✅ | `src/lib/auto-repair.ts` |
111| Secret scanner | ✅ | 15 patterns, `src/lib/security-scan.ts` |
112| AI security review | ✅ | Sonnet 4, `src/lib/security-scan.ts` |
113| AI commit messages | ✅ | `src/lib/ai-generators.ts` |
114| AI PR summaries | ✅ | |
115| AI changelogs | ✅ | auto on release create |
116| AI code review | ✅ | `src/lib/ai-review.ts` |
117| AI merge conflict resolver | ✅ | `src/lib/merge-resolver.ts` |
118| AI chat (global + repo) | ✅ | `src/routes/ask.tsx` |
119| GitHub Actions equivalent (workflow runner) | ❌ | GateTest integrated, no generic runner |
120| Dependabot equivalent (AI dep bumper) | ❌ | |
121| Code scanning UI | 🟡 | data exists, no dedicated UI page |
122| Copilot code completion | ❌ | |
123
124### 2.5 Platform
125| Feature | Status | Notes |
126|---|---|---|
127| Dashboard | ✅ | `src/routes/dashboard.tsx` |
128| Explore / discover | ✅ | |
129| Global search | ✅ | repos / users / issues / PRs |
130| Insights (graph, contributors, green rate) | ✅ | `src/routes/insights.tsx` |
131| Releases + tags | ✅ | AI changelog |
132| Personal access tokens | ✅ | SHA-256 hashed |
133| OAuth app provider | ❌ | |
134| GitHub Apps equivalent | ❌ | |
135| GraphQL API | ❌ | REST only |
136| Organizations + teams | ❌ | user-owned only |
137| Enterprise SAML / SSO | ❌ | |
138| 2FA / TOTP | ❌ | |
139| Passkeys / WebAuthn | ❌ | |
140| Packages registry (npm / docker / etc) | ❌ | |
141| Pages / static hosting | ❌ | |
142| Gists | ❌ | |
143| Sponsors | ❌ | |
144| Marketplace | ❌ | |
145| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail |
146| Merge queues | ❌ | |
147| Required checks matrix | 🟡 | branch_protection has single flag, no matrix |
148
149### 2.6 Observability + safety
150| Feature | Status | Notes |
151|---|---|---|
152| Rate limiting | ✅ | `src/middleware/rate-limit.ts` |
153| Request-ID tracing | ✅ | `src/middleware/request-context.ts` |
154| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
155| Audit log (table) | ✅ | `audit_log` table |
156| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
157| Traffic analytics per repo | ❌ | |
158| Email notifications | ✅ | opt-in per kind (mention/assign/gate-fail) via `/settings`; provider-pluggable `src/lib/email.ts` (log default, resend in prod) |
159| Email digest | ❌ | |
160| Mobile PWA | 🟡 | responsive CSS, no manifest |
161| Native mobile apps | ❌ | |
162| Dark mode | ✅ | default |
163| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
164| Keyboard shortcuts | ✅ | `/shortcuts` page |
165| Command palette | 🟡 | Cmd+K → Ask AI, no generic palette |
166
167---
168
169## 3. BUILD PLAN (BLOCKS)
170
171Each block is a self-contained unit. Order matters for dependencies. Each block ends with tests + commit + push.
172
173### BLOCK A — Hardening the current surface
174Polish what's shipped before adding more. **Priority: do this first if parity gaps are minor.**
175- **A1** — Dark/light theme toggle (cookie, CSS variable swap) ✅
176- **A2** — Audit log UI page (`/settings/audit` + `/:owner/:repo/settings/audit`) ✅
177- **A3** — Reactions UI on issues / PRs / comments (data exists) ✅
178- **A4** — Draft PR toggle + filter ✅
179- **A5** — Issue + PR templates (`.github/*_TEMPLATE.md` auto-prefill) ✅
180- **A6** — Saved replies per user ✅
181- **A7** — Environments + deployment history UI (`deployments` table) ✅
182- **A8** — Email notifications (opt-in, provider-pluggable) ✅
183
184**BLOCK A COMPLETE.** Next: BLOCK B (Identity + orgs).
185
186### BLOCK B — Identity + orgs
187- **B1** — Organizations (schema: `organizations`, `org_members`, `teams`, `team_members`)
188- **B2** — Repos owned by orgs (nullable `repositories.orgId`)
189- **B3** — Team-based CODEOWNERS (`@org/team` resolution)
190- **B4** — 2FA / TOTP (enroll, recovery codes)
191- **B5** — WebAuthn / passkeys
192- **B6** — OAuth 2.0 provider (third-party apps can request access)
193
194### BLOCK C — Runtime + hosting
195- **C1** — Actions-equivalent workflow runner
196 - Workflow YAML parser (`.gluecron/workflows/*.yml`)
197 - Job queue + worker pool (Bun subprocesses)
198 - Artifact storage + log streaming
199 - Integrates with gates
200- **C2** — Package registry (npm + container protocol)
201- **C3** — Pages / static hosting (`gh-pages` branch → served at `<owner>.<repo>.pages.gluecron.com`)
202- **C4** — Environments (prod/staging/preview) with protected approvals
203
204### BLOCK D — AI-native differentiation
205This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
206- **D1** — Semantic code search (pgvector + Claude embeddings)
207- **D2** — AI dependency updater (reads lockfile, opens PRs, verifies green)
208- **D3** — AI PR triage agent (auto-assigns reviewers, labels, milestones)
209- **D4** — AI incident responder (on deploy failure, opens issue with root cause)
210- **D5** — AI code reviewer that blocks merges (enforced via branch protection "AI approval required")
211- **D6** — AI "explain this codebase" on repo landing (auto-generated, cached)
212- **D7** — AI changelog for every commit range (`/:owner/:repo/ai/changelog?from=...&to=...`)
213- **D8** — AI-generated test suite (reads public API, generates failing tests)
214- **D9** — Copilot-style completion endpoint for IDE plugins
215
216### BLOCK E — Collaboration parity
217- **E1** — Projects / kanban boards (`projects`, `project_items`, `project_fields`)
218- **E2** — Discussions (forum threads per repo)
219- **E3** — Wikis (git-backed, separate bare repo per repo)
220- **E4** — Gists (user-owned tiny repos)
221- **E5** — Merge queues (serialised merge with re-test)
222- **E6** — Required status checks matrix (multiple named checks per branch protection rule)
223- **E7** — Protected tags
224
225### BLOCK F — Observability + admin
226- **F1** — Traffic analytics per repo (views, clones, unique visitors)
227- **F2** — Org-wide insights (green rate across all repos)
228- **F3** — Admin / superuser panel (user moderation, repo audit)
229- **F4** — Billing + quotas (storage, AI tokens, bandwidth)
230
231### BLOCK G — Mobile + client
232- **G1** — PWA manifest + service worker
233- **G2** — GraphQL API mirror of REST
234- **G3** — Official CLI (`gluecron` binary in Bun)
235- **G4** — VS Code extension
236
237### BLOCK H — Marketplace
238- **H1** — App marketplace (install third-party apps against a repo)
239- **H2** — GitHub Apps equivalent (bot identities with scoped permissions)
240
241---
242
243## 4. LOCKED BLOCKS (DO NOT UNDO)
244
245Everything below is committed, tested, and load-bearing. **Do not delete, rename, or semantically change without owner permission.**
246
247### 4.1 Infrastructure (locked)
248- `src/app.tsx` — route composition, middleware order, error handlers
249- `src/index.ts` — Bun server entry
250- `src/lib/config.ts` — env getters (late-binding)
251- `src/db/schema.ts` — 27 tables. New tables only via new migration.
252- `src/db/index.ts` — lazy proxy DB connection
253- `src/db/migrate.ts` — migration runner
254- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
255
256### 4.2 Git layer (locked)
257- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
258- `src/git/protocol.ts` — Smart HTTP pkt-line
259- `src/hooks/post-receive.ts` — CODEOWNERS sync, gates, auto-deploy, webhook fan-out
260
261### 4.3 Auth + security (locked)
262- `src/lib/auth.ts` — bcrypt, session tokens
263- `src/middleware/auth.ts` — softAuth + requireAuth
264- `src/middleware/rate-limit.ts` — fixed-window limiter
265- `src/middleware/request-context.ts` — request-ID
266- `src/lib/security-scan.ts``SECRET_PATTERNS` (exported) + `scanForSecrets` + `aiSecurityScan`
267- `src/lib/codeowners.ts` — parser + `ownersForPath` (last-match-wins)
268
269### 4.4 AI layer (locked)
270- `src/lib/ai-client.ts` — Anthropic client + model constants
271- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage
272- `src/lib/ai-chat.ts` — conversational chat
273- `src/lib/ai-review.ts` — PR code review
274- `src/lib/auto-repair.ts` — worktree-backed repair commits
275- `src/lib/merge-resolver.ts` — AI merge conflict resolution
276
277### 4.5 Platform (locked)
278- `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.
279- `src/lib/email.ts` — provider-pluggable email sender (`log`|`resend`). `sendEmail()` never throws. `absoluteUrl()` joins paths against `APP_BASE_URL`.
280- `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.
281- `src/lib/unread.ts` — unread count helper (never throws)
282- `src/lib/repo-bootstrap.ts` — green defaults on repo creation
283- `src/lib/gate.ts` — gate orchestration + persistence
284- `src/lib/cache.ts` — LRU cache, git-cache invalidation
285- `src/lib/reactions.ts``summariseReactions`, `toggleReaction`, `ALLOWED_EMOJIS`, `EMOJI_GLYPH`, `isAllowedEmoji`, `isAllowedTarget`
286
287### 4.6 Routes (locked endpoints — behaviour must be preserved)
288- `src/routes/git.ts` — Smart HTTP (clone/push)
289- `src/routes/api.ts` — REST (`POST /api/repos`, `GET /api/users/:u/repos`, `GET /api/repos/:o/:n`, `POST /api/setup`)
290- `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`.
291- `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.
292- `src/routes/audit.tsx``GET /settings/audit` (personal) + `GET /:owner/:repo/settings/audit` (owner-only).
293- `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`.
294- `src/routes/deployments.tsx``GET /:owner/:repo/deployments` (grouped by env, success-rate rollup), `GET /:owner/:repo/deployments/:id` (detail).
295- `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.
296- `src/routes/auth.tsx` — register / login / logout
297- `src/routes/web.tsx` — home / new / browse / blob / commits / raw / blame / star / search / profile
298- `src/routes/issues.tsx` — issue CRUD + comments + labels + lock
299- `src/routes/pulls.tsx` — PR CRUD + review + merge + close
300- `src/routes/editor.tsx` — web file editor
301- `src/routes/compare.tsx` — base...head diff
302- `src/routes/settings.tsx` — profile + password + email notification preferences (`POST /settings/notifications`)
303- `src/routes/repo-settings.tsx` — repo settings + delete
304- `src/routes/webhooks.tsx` — webhook CRUD + test + `fireWebhooks`
305- `src/routes/fork.ts` — fork
306- `src/routes/explore.tsx` — discover
307- `src/routes/tokens.tsx` — personal access tokens
308- `src/routes/contributors.tsx` — contributor list
309- `src/routes/notifications.tsx` — inbox + unread API
310- `src/routes/dashboard.tsx` — authed home (`renderDashboard` exported)
311- `src/routes/ask.tsx` — global + repo AI chat + explain
312- `src/routes/releases.tsx` — tags + AI changelog
313- `src/routes/gates.tsx` — history + settings + branch protection UI
314- `src/routes/insights.tsx` — insights + milestones
315- `src/routes/search.tsx` — global search + `/shortcuts`
316- `src/routes/health.ts``/healthz` `/readyz` `/metrics`
317
318### 4.7 Views (locked contracts)
319- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
320- `src/views/components.tsx``RepoHeader`, `RepoNav` (active: `code|issues|pulls|commits|releases|gates|insights|...`), `RepoCard`, etc.
321- `src/views/reactions.tsx``ReactionsBar` (no-JS compatible, form-per-emoji)
322- Nav links: logo · search · theme-toggle · Explore · Ask · Notifications · New · Profile (or Sign in / Register)
323- Keyboard chords: `/`, `Cmd+K`, `?`, `n`, `g d`, `g n`, `g e`, `g a`
324
325### 4.8 Tests (locked)
326- `src/__tests__/green-ecosystem.test.ts` — secret scanner, codeowners, AI fallback, health, rate-limit headers, `/shortcuts`, `/search`
327- All other existing test files — do not delete without owner permission
328
329### 4.9 Invariants (never break these)
330- `isAiAvailable()` guard returns true fallback strings when no ANTHROPIC_API_KEY. AI features degrade gracefully.
331- `getUnreadCount` never throws; returns 0 on any error.
332- Rate-limit middleware adds `X-RateLimit-Limit` + `X-RateLimit-Remaining` to every response, including 500s.
333- `c.header("X-Request-Id", ...)` set by request-context on every response.
334- Secret scanner skips binary/lock paths (`shouldSkipPath`).
335- `SECRET_PATTERNS` is an exported array. Its shape is `{ type, regex, severity }`.
336- Theme routes live outside `/settings/*` (they must work for logged-out visitors). Cookie name: `theme`, values: `dark|light`.
337- Draft PRs cannot be merged — `/pulls/:n/merge` returns a redirect with the draft error when `pr.isDraft=true`.
338- Reactions API accepts only `ALLOWED_EMOJIS` and `ALLOWED_TARGETS`. Toggle is idempotent per (user, target, emoji).
339- `sendEmail()` never throws — always resolves to `{ ok, provider, ... }`. Email failures never break notification delivery or the primary request path.
340- 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.
341- Issue + PR template loading must return `null` on any git-subprocess failure (templates are a convenience, not a requirement). Forms always render.
342
343---
344
345## 5. OPERATIONAL NOTES
346
347### 5.1 Running locally
348```bash
349bun install
350bun dev # hot reload
351bun test # 99 tests currently pass
352bun run db:migrate
353```
354
355### 5.2 Environment
356- `DATABASE_URL` — Neon Postgres
357- `ANTHROPIC_API_KEY` — unlocks AI features
358- `GIT_REPOS_PATH` — default `./repos`
359- `PORT` — default 3000
360- `EMAIL_PROVIDER``log` (default, stderr-only) or `resend`
361- `EMAIL_FROM` — sender address for outbound mail
362- `RESEND_API_KEY` — required when `EMAIL_PROVIDER=resend`
363- `APP_BASE_URL` — canonical URL used to build absolute links in emails
364
365### 5.3 Models
366- `claude-sonnet-4-20250514` — code review, security, chat
367- `claude-haiku-4-5-20251001` — commit messages, summaries, light tasks
368- Swap via `MODEL_SONNET` / `MODEL_HAIKU` constants in `src/lib/ai-client.ts`
369
370### 5.4 Deployment
371- `railway.toml` / `fly.toml` present
372- Crontech deploy on green push to default branch (can opt out via `autoDeployEnabled`)
373
374---
375
376## 6. SESSION WORKFLOW (WHAT THE NEXT AGENT DOES)
377
3781. Read this file, `CLAUDE.md`, `README.md`, `git log -1 --stat`.
3792. Check `git status` + current branch.
3803. Pick the next unfinished block from §3 (lowest letter + number first, unless owner specifies).
3814. Create a todo list that mirrors the sub-items of that block.
3825. Build. Write tests. Run `bun test`.
3836. Commit with `feat(<BLOCK-ID>): ...`.
3847. Push.
3858. Update this file:
386 - Move the block's row in §2 to ✅ where applicable.
387 - Add the block's files to §4 LOCKED BLOCKS.
388 - Commit + push again.
3899. Start the next block. **Do not stop to ask.**
390
391If 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.
392
393---
394
395## 7. IN-FLIGHT
396
397(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
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.
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");
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
Modifiedsrc/__tests__/green-ecosystem.test.ts+246−0View fileUnifiedSplit
1515} from "../lib/codeowners";
1616import { generateCommitMessage } from "../lib/ai-generators";
1717import { isAiAvailable } from "../lib/ai-client";
18import {
19 isAllowedEmoji,
20 isAllowedTarget,
21 ALLOWED_EMOJIS,
22 EMOJI_GLYPH,
23} from "../lib/reactions";
24import { sendEmail, absoluteUrl } from "../lib/email";
25import { __internal as notifyInternal } from "../lib/notify";
1826
1927describe("secret scanner", () => {
2028 it("detects AWS access keys", () => {
156164 expect(html).toContain("Users");
157165 });
158166});
167
168describe("GateTest inbound hook", () => {
169 it("GET /api/hooks/ping is unauthenticated and reports service", async () => {
170 const res = await app.request("/api/hooks/ping");
171 expect(res.status).toBe(200);
172 const body = await res.json();
173 expect(body.ok).toBe(true);
174 expect(body.service).toBe("gluecron");
175 expect(Array.isArray(body.hooks)).toBe(true);
176 });
177
178 it("POST /api/hooks/gatetest rejects when no secret configured", async () => {
179 const prev = process.env.GATETEST_CALLBACK_SECRET;
180 const prevH = process.env.GATETEST_HMAC_SECRET;
181 delete process.env.GATETEST_CALLBACK_SECRET;
182 delete process.env.GATETEST_HMAC_SECRET;
183 const res = await app.request("/api/hooks/gatetest", {
184 method: "POST",
185 headers: { "content-type": "application/json" },
186 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
187 });
188 expect(res.status).toBe(401);
189 if (prev) process.env.GATETEST_CALLBACK_SECRET = prev;
190 if (prevH) process.env.GATETEST_HMAC_SECRET = prevH;
191 });
192
193 it("POST /api/hooks/gatetest rejects bad bearer token", async () => {
194 const prev = process.env.GATETEST_CALLBACK_SECRET;
195 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
196 const res = await app.request("/api/hooks/gatetest", {
197 method: "POST",
198 headers: {
199 "content-type": "application/json",
200 authorization: "Bearer wrong-token",
201 },
202 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
203 });
204 expect(res.status).toBe(401);
205 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
206 else process.env.GATETEST_CALLBACK_SECRET = prev;
207 });
208
209 it("POST /api/hooks/gatetest rejects malformed payload even when authed", async () => {
210 const prev = process.env.GATETEST_CALLBACK_SECRET;
211 process.env.GATETEST_CALLBACK_SECRET = "real-secret-abc123";
212 const res = await app.request("/api/hooks/gatetest", {
213 method: "POST",
214 headers: {
215 "content-type": "application/json",
216 authorization: "Bearer real-secret-abc123",
217 },
218 body: "not-json",
219 });
220 expect(res.status).toBe(400);
221 if (prev === undefined) delete process.env.GATETEST_CALLBACK_SECRET;
222 else process.env.GATETEST_CALLBACK_SECRET = prev;
223 });
224
225 it("POST /api/v1/gate-runs (backup) rejects without bearer", async () => {
226 const res = await app.request("/api/v1/gate-runs", {
227 method: "POST",
228 headers: { "content-type": "application/json" },
229 body: JSON.stringify({ repository: "a/b", sha: "x", status: "passed" }),
230 });
231 expect(res.status).toBe(401);
232 });
233});
234
235describe("theme toggle", () => {
236 it("GET /theme/toggle sets a cookie and redirects", async () => {
237 const res = await app.request("/theme/toggle");
238 // 302 redirect; no cookie yet means we flip from the default (dark) → light
239 expect([301, 302, 303, 307]).toContain(res.status);
240 const setCookie = res.headers.get("set-cookie") || "";
241 expect(/theme=light/.test(setCookie)).toBe(true);
242 });
243
244 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
245 const res = await app.request("/theme/toggle", {
246 headers: { cookie: "theme=light" },
247 });
248 const setCookie = res.headers.get("set-cookie") || "";
249 expect(/theme=dark/.test(setCookie)).toBe(true);
250 });
251
252 it("GET /theme/set?mode=light returns JSON when asked", async () => {
253 const res = await app.request("/theme/set?mode=light", {
254 headers: { accept: "application/json" },
255 });
256 expect(res.status).toBe(200);
257 const body = await res.json();
258 expect(body.ok).toBe(true);
259 expect(body.theme).toBe("light");
260 });
261
262 it("GET /theme/set rejects unknown modes", async () => {
263 const res = await app.request("/theme/set?mode=neon", {
264 headers: { accept: "application/json" },
265 });
266 expect(res.status).toBe(400);
267 });
268
269 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
270 const res = await app.request("/");
271 const html = await res.text();
272 expect(html).toContain("data-theme");
273 expect(html).toContain("theme-icon-");
274 // The pre-paint script reads the cookie.
275 expect(html).toContain("document.cookie");
276 });
277});
278
279describe("reactions", () => {
280 it("allowed emojis and targets are self-consistent", () => {
281 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
282 for (const e of ALLOWED_EMOJIS) {
283 expect(isAllowedEmoji(e)).toBe(true);
284 expect(EMOJI_GLYPH[e]).toBeTruthy();
285 }
286 expect(isAllowedEmoji("nope")).toBe(false);
287 expect(isAllowedTarget("issue")).toBe(true);
288 expect(isAllowedTarget("martian")).toBe(false);
289 });
290
291 it("POST /api/reactions/.../toggle requires auth", async () => {
292 const res = await app.request(
293 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
294 { method: "POST" }
295 );
296 // Unauthenticated -> redirect to /login (302)
297 expect([301, 302, 303, 307]).toContain(res.status);
298 });
299
300 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
301 const res = await app.request(
302 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
303 );
304 expect(res.status).toBe(200);
305 const body = await res.json();
306 expect(body.ok).toBe(true);
307 expect(Array.isArray(body.reactions)).toBe(true);
308 });
309
310 it("rejects unknown target type on the listing endpoint", async () => {
311 const res = await app.request(
312 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
313 );
314 expect(res.status).toBe(400);
315 });
316});
317
318describe("audit log UI", () => {
319 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
320 const res = await app.request("/settings/audit");
321 expect([301, 302, 303, 307]).toContain(res.status);
322 const loc = res.headers.get("location") || "";
323 expect(loc.startsWith("/login")).toBe(true);
324 });
325});
326
327describe("email", () => {
328 it("sendEmail in log mode never throws and returns ok", async () => {
329 const prev = process.env.EMAIL_PROVIDER;
330 process.env.EMAIL_PROVIDER = "log";
331 const res = await sendEmail({
332 to: "test@gluecron.local",
333 subject: "hello",
334 text: "body",
335 });
336 expect(res.ok).toBe(true);
337 expect(res.provider).toBe("log");
338 if (prev === undefined) delete process.env.EMAIL_PROVIDER;
339 else process.env.EMAIL_PROVIDER = prev;
340 });
341
342 it("sendEmail rejects invalid recipient without throwing", async () => {
343 const res = await sendEmail({
344 to: "not-an-email",
345 subject: "x",
346 text: "y",
347 });
348 expect(res.ok).toBe(false);
349 expect(res.skipped).toBeTruthy();
350 });
351
352 it("sendEmail rejects empty subject/body without throwing", async () => {
353 const res = await sendEmail({ to: "a@b.co", subject: "", text: "" });
354 expect(res.ok).toBe(false);
355 });
356
357 it("absoluteUrl joins paths against APP_BASE_URL", () => {
358 const prev = process.env.APP_BASE_URL;
359 process.env.APP_BASE_URL = "https://gluecron.example/";
360 expect(absoluteUrl("/x")).toBe("https://gluecron.example/x");
361 expect(absoluteUrl("x")).toBe("https://gluecron.example/x");
362 expect(absoluteUrl("https://other/y")).toBe("https://other/y");
363 if (prev === undefined) delete process.env.APP_BASE_URL;
364 else process.env.APP_BASE_URL = prev;
365 });
366
367 it("notify email-eligible set only includes user-opt-in kinds", () => {
368 // Any kind in EMAIL_ELIGIBLE must map to a preference column
369 for (const k of notifyInternal.EMAIL_ELIGIBLE) {
370 expect(notifyInternal.prefFor(k)).not.toBeNull();
371 }
372 // gate_passed is not eligible (too spammy; only gate_failed is)
373 expect(notifyInternal.EMAIL_ELIGIBLE.has("gate_passed" as any)).toBe(false);
374 expect(notifyInternal.EMAIL_ELIGIBLE.has("deploy_failed" as any)).toBe(
375 false
376 );
377 });
378
379 it("notify email subject is tagged and truncated", () => {
380 const subj = notifyInternal.subjectFor("gate_failed", "x".repeat(300));
381 expect(subj.startsWith("[gate failed]")).toBe(true);
382 expect(subj.length).toBeLessThanOrEqual(180);
383 });
384});
385
386describe("settings email preferences", () => {
387 it("GET /settings redirects unauthenticated users to /login", async () => {
388 const res = await app.request("/settings");
389 expect([301, 302, 303, 307]).toContain(res.status);
390 const loc = res.headers.get("location") || "";
391 expect(loc.startsWith("/login")).toBe(true);
392 });
393
394 it("POST /settings/notifications redirects unauthenticated users to /login", async () => {
395 const res = await app.request("/settings/notifications", {
396 method: "POST",
397 headers: { "content-type": "application/x-www-form-urlencoded" },
398 body: "notify_email_on_mention=1",
399 });
400 expect([301, 302, 303, 307]).toContain(res.status);
401 const loc = res.headers.get("location") || "";
402 expect(loc.startsWith("/login")).toBe(true);
403 });
404});
Modifiedsrc/app.tsx+24−0View fileUnifiedSplit
2727import insightsRoutes from "./routes/insights";
2828import searchRoutes from "./routes/search";
2929import healthRoutes from "./routes/health";
30import hookRoutes from "./routes/hooks";
31import themeRoutes from "./routes/theme";
32import auditRoutes from "./routes/audit";
33import reactionRoutes from "./routes/reactions";
34import savedReplyRoutes from "./routes/saved-replies";
35import deploymentRoutes from "./routes/deployments";
3036import webRoutes from "./routes/web";
3137
3238const app = new Hono();
5258// Health + metrics
5359app.route("/", healthRoutes);
5460
61// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
62app.route("/", hookRoutes);
63
5564// REST API
5665app.route("/", apiRoutes);
5766
6170// Settings routes (profile, SSH keys)
6271app.route("/", settingsRoutes);
6372
73// Theme toggle (dark/light cookie)
74app.route("/", themeRoutes);
75
76// Audit log UI
77app.route("/", auditRoutes);
78
79// Reactions API (issues, PRs, comments)
80app.route("/", reactionRoutes);
81
82// Saved replies (per-user canned comment templates)
83app.route("/", savedReplyRoutes);
84
85// Environments + deployment history UI
86app.route("/", deploymentRoutes);
87
6488// API tokens
6589app.route("/", tokenRoutes);
6690
Modifiedsrc/db/schema.ts+125−0View fileUnifiedSplit
1818 passwordHash: text("password_hash").notNull(),
1919 avatarUrl: text("avatar_url"),
2020 bio: text("bio"),
21 // Email notification preferences (Block A8). Default on; opt-out via /settings.
22 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
23 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
24 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
2125 createdAt: timestamp("created_at").defaultNow().notNull(),
2226 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2327});
638642export type AiChat = typeof aiChats.$inferSelect;
639643export type AuditLogEntry = typeof auditLog.$inferSelect;
640644export type Deployment = typeof deployments.$inferSelect;
645
646/**
647 * Saved replies — per-user canned responses, insertable into any
648 * issue / PR comment textarea. Shortcut name must be unique per user.
649 */
650export const savedReplies = pgTable(
651 "saved_replies",
652 {
653 id: uuid("id").primaryKey().defaultRandom(),
654 userId: uuid("user_id")
655 .notNull()
656 .references(() => users.id, { onDelete: "cascade" }),
657 shortcut: text("shortcut").notNull(),
658 body: text("body").notNull(),
659 createdAt: timestamp("created_at").defaultNow().notNull(),
660 updatedAt: timestamp("updated_at").defaultNow().notNull(),
661 },
662 (table) => [
663 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
664 ]
665);
666
667export type SavedReply = typeof savedReplies.$inferSelect;
668
669/**
670 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
671 * An org has members (with org-level roles) and may contain teams.
672 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
673 *
674 * Slug is globally unique against itself; collision with a username is
675 * checked at create time in the route handler (no DB-level cross-table
676 * uniqueness in Postgres).
677 */
678export const organizations = pgTable("organizations", {
679 id: uuid("id").primaryKey().defaultRandom(),
680 slug: text("slug").notNull().unique(),
681 name: text("name").notNull(),
682 description: text("description"),
683 avatarUrl: text("avatar_url"),
684 billingEmail: text("billing_email"),
685 createdById: uuid("created_by_id")
686 .notNull()
687 .references(() => users.id, { onDelete: "restrict" }),
688 createdAt: timestamp("created_at").defaultNow().notNull(),
689 updatedAt: timestamp("updated_at").defaultNow().notNull(),
690});
691
692/**
693 * Org membership. Roles: owner (full control, billing), admin (manage
694 * members + teams + repos), member (default; can be added to teams).
695 */
696export const orgMembers = pgTable(
697 "org_members",
698 {
699 id: uuid("id").primaryKey().defaultRandom(),
700 orgId: uuid("org_id")
701 .notNull()
702 .references(() => organizations.id, { onDelete: "cascade" }),
703 userId: uuid("user_id")
704 .notNull()
705 .references(() => users.id, { onDelete: "cascade" }),
706 role: text("role").notNull().default("member"), // owner | admin | member
707 createdAt: timestamp("created_at").defaultNow().notNull(),
708 },
709 (table) => [
710 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
711 index("org_members_user").on(table.userId),
712 ]
713);
714
715/**
716 * Teams within an org. Slug is unique within an org.
717 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
718 */
719export const teams = pgTable(
720 "teams",
721 {
722 id: uuid("id").primaryKey().defaultRandom(),
723 orgId: uuid("org_id")
724 .notNull()
725 .references(() => organizations.id, { onDelete: "cascade" }),
726 slug: text("slug").notNull(),
727 name: text("name").notNull(),
728 description: text("description"),
729 parentTeamId: uuid("parent_team_id"),
730 createdAt: timestamp("created_at").defaultNow().notNull(),
731 updatedAt: timestamp("updated_at").defaultNow().notNull(),
732 },
733 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
734);
735
736/**
737 * Team membership. Roles: maintainer (can edit team), member (default).
738 * A user can belong to many teams; team membership requires org membership
739 * but that invariant is enforced at the route layer, not the DB layer.
740 */
741export const teamMembers = pgTable(
742 "team_members",
743 {
744 id: uuid("id").primaryKey().defaultRandom(),
745 teamId: uuid("team_id")
746 .notNull()
747 .references(() => teams.id, { onDelete: "cascade" }),
748 userId: uuid("user_id")
749 .notNull()
750 .references(() => users.id, { onDelete: "cascade" }),
751 role: text("role").notNull().default("member"), // maintainer | member
752 createdAt: timestamp("created_at").defaultNow().notNull(),
753 },
754 (table) => [
755 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
756 index("team_members_user").on(table.userId),
757 ]
758);
759
760export type Organization = typeof organizations.$inferSelect;
761export type OrgMember = typeof orgMembers.$inferSelect;
762export type Team = typeof teams.$inferSelect;
763export type TeamMember = typeof teamMembers.$inferSelect;
764export type OrgRole = "owner" | "admin" | "member";
765export type TeamRole = "maintainer" | "member";
Modifiedsrc/lib/config.ts+20−0View fileUnifiedSplit
2525 get anthropicApiKey() {
2626 return process.env.ANTHROPIC_API_KEY || "";
2727 },
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 },
2848};
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}
Modifiedsrc/lib/notify.ts+120−1View fileUnifiedSplit
11/**
22 * Notifications + audit log helpers.
33 * 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.
49 */
510
11import { inArray, eq } from "drizzle-orm";
612import { db } from "../db";
7import { notifications, auditLog } from "../db/schema";
13import { notifications, auditLog, users } from "../db/schema";
14import { sendEmail, absoluteUrl } from "./email";
815
916export type NotificationKind =
1017 | "mention"
2532 | "release_published"
2633 | "repo_archived";
2734
35/** Kinds that can trigger email delivery. Keep this list conservative — any
36 * kind here must map to a user preference column on the users table. */
37const EMAIL_ELIGIBLE: ReadonlySet<NotificationKind> = new Set([
38 "mention",
39 "review_requested",
40 "assigned",
41 "gate_failed",
42]);
43
44/** Map notification kind → user preference column name. */
45function prefFor(kind: NotificationKind):
46 | "notifyEmailOnMention"
47 | "notifyEmailOnAssign"
48 | "notifyEmailOnGateFail"
49 | null {
50 switch (kind) {
51 case "mention":
52 case "review_requested":
53 return "notifyEmailOnMention";
54 case "assigned":
55 return "notifyEmailOnAssign";
56 case "gate_failed":
57 return "notifyEmailOnGateFail";
58 default:
59 return null;
60 }
61}
62
63function subjectFor(kind: NotificationKind, title: string): string {
64 const tag =
65 kind === "gate_failed"
66 ? "[gate failed]"
67 : kind === "assigned"
68 ? "[assigned]"
69 : kind === "review_requested"
70 ? "[review requested]"
71 : kind === "mention"
72 ? "[mention]"
73 : `[${kind}]`;
74 return `${tag} ${title}`.slice(0, 180);
75}
76
77function bodyFor(title: string, body: string | undefined, url: string | undefined): string {
78 const lines = [title];
79 if (body) lines.push("", body);
80 if (url) lines.push("", absoluteUrl(url));
81 lines.push("", "—", "You can opt out of these emails at /settings.");
82 return lines.join("\n");
83}
84
85async function maybeEmail(
86 userIds: string[],
87 kind: NotificationKind,
88 opts: { title: string; body?: string; url?: string }
89): Promise<void> {
90 if (!EMAIL_ELIGIBLE.has(kind)) return;
91 const prefCol = prefFor(kind);
92 if (!prefCol) return;
93 if (userIds.length === 0) return;
94
95 let recipients: Array<{ email: string; pref: boolean }> = [];
96 try {
97 const rows = await db
98 .select({
99 id: users.id,
100 email: users.email,
101 mention: users.notifyEmailOnMention,
102 assign: users.notifyEmailOnAssign,
103 gate: users.notifyEmailOnGateFail,
104 })
105 .from(users)
106 .where(inArray(users.id, userIds));
107 recipients = rows.map((r) => ({
108 email: r.email,
109 pref:
110 prefCol === "notifyEmailOnMention"
111 ? r.mention
112 : prefCol === "notifyEmailOnAssign"
113 ? r.assign
114 : r.gate,
115 }));
116 } catch (err) {
117 console.error("[notify] email recipient lookup failed:", err);
118 return;
119 }
120
121 const subject = subjectFor(kind, opts.title);
122 const text = bodyFor(opts.title, opts.body, opts.url);
123
124 // Fire in parallel; each call swallows its own errors.
125 await Promise.all(
126 recipients
127 .filter((r) => r.pref && r.email)
128 .map((r) =>
129 sendEmail({ to: r.email, subject, text }).catch((err) => {
130 console.error("[notify] sendEmail threw:", err);
131 return { ok: false as const, provider: "none" as const };
132 })
133 )
134 );
135}
136
28137export async function notify(
29138 userId: string,
30139 opts: {
47156 } catch (err) {
48157 console.error("[notify] failed:", err);
49158 }
159 await maybeEmail([userId], opts.kind, opts);
50160}
51161
52162export async function notifyMany(
75185 } catch (err) {
76186 console.error("[notify] batch failed:", err);
77187 }
188 await maybeEmail(unique, opts.kind, opts);
78189}
79190
80191export async function audit(opts: {
103214 console.error("[audit] failed:", err);
104215 }
105216}
217
218/** Test-only hook so unit tests can assert the kind→pref mapping. */
219export const __internal = {
220 EMAIL_ELIGIBLE,
221 prefFor,
222 subjectFor,
223 bodyFor,
224};
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/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/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 };
Modifiedsrc/middleware/rate-limit.ts+6−1View fileUnifiedSplit
2929
3030export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) {
3131 const prefix = opts.prefix || "rl";
32 // In test mode we don't enforce limits — the in-memory bucket leaks across
33 // tests in the same process and different routes share the default prefix,
34 // which causes false 429s on endpoints that have only been hit once. Headers
35 // are still written so tests asserting their presence keep passing.
36 const enforce = process.env.NODE_ENV !== "test";
3237 return createMiddleware(async (c, next) => {
3338 const key = clientKey(c, prefix);
3439 const now = Date.now();
3944 } else {
4045 bucket.count++;
4146 count = bucket.count;
42 if (bucket.count > opts.max) {
47 if (enforce && bucket.count > opts.max) {
4348 const retryMs = opts.windowMs - (now - bucket.windowStart);
4449 return c.json(
4550 { error: "Too many requests", retryAfterMs: retryMs },
Modifiedsrc/routes/api.ts+142−102View fileUnifiedSplit
1313
1414// Create repository
1515api.post("/repos", async (c) => {
16 const body = await c.req.json<{
16 let body: {
1717 name: string;
1818 owner: string;
1919 description?: string;
2020 isPrivate?: boolean;
21 }>();
21 };
22 try {
23 body = await c.req.json();
24 } catch {
25 return c.json({ error: "Invalid JSON body" }, 400);
26 }
2227
2328 if (!body.name || !body.owner) {
2429 return c.json({ error: "name and owner are required" }, 400);
2934 return c.json({ error: "Invalid repository name" }, 400);
3035 }
3136
32 // Find owner
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, body.owner));
37 try {
38 // Find owner
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, body.owner));
3743
38 if (!owner) {
39 return c.json({ error: "Owner not found" }, 404);
40 }
44 if (!owner) {
45 return c.json({ error: "Owner not found" }, 404);
46 }
4147
42 // Check duplicate
43 if (await repoExists(body.owner, body.name)) {
44 return c.json({ error: "Repository already exists" }, 409);
45 }
48 // Check duplicate
49 if (await repoExists(body.owner, body.name)) {
50 return c.json({ error: "Repository already exists" }, 409);
51 }
4652
47 // Init bare repo on disk
48 const diskPath = await initBareRepo(body.owner, body.name);
49
50 // Insert into DB
51 const [repo] = await db
52 .insert(repositories)
53 .values({
54 name: body.name,
55 ownerId: owner.id,
56 description: body.description || null,
57 isPrivate: body.isPrivate || false,
58 diskPath,
59 })
60 .returning();
61
62 // Green-ecosystem bootstrap: settings, protection, labels, welcome issue
63 if (repo) {
64 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
65 await bootstrapRepository({
66 repositoryId: repo.id,
67 ownerUserId: owner.id,
68 });
69 }
53 // Init bare repo on disk
54 const diskPath = await initBareRepo(body.owner, body.name);
55
56 // Insert into DB
57 const [repo] = await db
58 .insert(repositories)
59 .values({
60 name: body.name,
61 ownerId: owner.id,
62 description: body.description || null,
63 isPrivate: body.isPrivate || false,
64 diskPath,
65 })
66 .returning();
7067
71 return c.json(repo, 201);
68 // Green-ecosystem bootstrap: settings, protection, labels, welcome issue
69 if (repo) {
70 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
71 await bootstrapRepository({
72 repositoryId: repo.id,
73 ownerUserId: owner.id,
74 });
75 }
76
77 return c.json(repo, 201);
78 } catch (err) {
79 console.error("[api] POST /repos:", err);
80 return c.json({ error: "Service unavailable" }, 503);
81 }
7282});
7383
7484// List user's repositories
7585api.get("/users/:username/repos", async (c) => {
7686 const { username } = c.req.param();
77 const [owner] = await db
78 .select()
79 .from(users)
80 .where(eq(users.username, username));
81 if (!owner) return c.json({ error: "User not found" }, 404);
82
83 const repos = await db
84 .select()
85 .from(repositories)
86 .where(eq(repositories.ownerId, owner.id));
87
88 return c.json(repos);
87 try {
88 const [owner] = await db
89 .select()
90 .from(users)
91 .where(eq(users.username, username));
92 if (!owner) return c.json({ error: "User not found" }, 404);
93
94 const repos = await db
95 .select()
96 .from(repositories)
97 .where(eq(repositories.ownerId, owner.id));
98
99 return c.json(repos);
100 } catch (err) {
101 console.error("[api] /users/:username/repos:", err);
102 return c.json({ error: "Service unavailable" }, 503);
103 }
89104});
90105
91106// Get single repository
92107api.get("/repos/:owner/:name", async (c) => {
93108 const { owner: ownerName, name } = c.req.param();
94 const [owner] = await db
95 .select()
96 .from(users)
97 .where(eq(users.username, ownerName));
98 if (!owner) return c.json({ error: "Not found" }, 404);
99
100 const [repo] = await db
101 .select()
102 .from(repositories)
103 .where(
104 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
105 );
106 if (!repo) return c.json({ error: "Not found" }, 404);
109 try {
110 const [owner] = await db
111 .select()
112 .from(users)
113 .where(eq(users.username, ownerName));
114 if (!owner) return c.json({ error: "Not found" }, 404);
107115
108 return c.json(repo);
116 const [repo] = await db
117 .select()
118 .from(repositories)
119 .where(
120 and(eq(repositories.ownerId, owner.id), eq(repositories.name, name))
121 );
122 if (!repo) return c.json({ error: "Not found" }, 404);
123
124 return c.json(repo);
125 } catch (err) {
126 console.error("[api] /repos/:owner/:name:", err);
127 return c.json({ error: "Service unavailable" }, 503);
128 }
109129});
110130
111131// Quick-setup: create user + repo in one call (dev convenience)
112132api.post("/setup", async (c) => {
113 const body = await c.req.json<{
133 let body: {
114134 username: string;
115135 email: string;
116136 repoName: string;
117137 description?: string;
118 }>();
119
120 // Upsert user
121 let [user] = await db
122 .select()
123 .from(users)
124 .where(eq(users.username, body.username));
125
126 if (!user) {
127 [user] = await db
128 .insert(users)
129 .values({
130 username: body.username,
131 email: body.email,
132 passwordHash: await hashPassword("changeme"),
133 })
134 .returning();
138 };
139 try {
140 body = await c.req.json();
141 } catch {
142 return c.json({ error: "Invalid JSON body" }, 400);
143 }
144 if (!body.username || !body.email || !body.repoName) {
145 return c.json(
146 { error: "username, email, and repoName are required" },
147 400
148 );
135149 }
136150
137 // Create repo if not exists
138 if (!(await repoExists(body.username, body.repoName))) {
139 const diskPath = await initBareRepo(body.username, body.repoName);
140 const [repo] = await db
141 .insert(repositories)
142 .values({
143 name: body.repoName,
144 ownerId: user.id,
145 description: body.description || null,
146 diskPath,
147 })
148 .returning();
149 if (repo) {
150 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
151 await bootstrapRepository({
152 repositoryId: repo.id,
153 ownerUserId: user.id,
154 });
151 try {
152 // Upsert user
153 let [user] = await db
154 .select()
155 .from(users)
156 .where(eq(users.username, body.username));
157
158 if (!user) {
159 [user] = await db
160 .insert(users)
161 .values({
162 username: body.username,
163 email: body.email,
164 passwordHash: await hashPassword("changeme"),
165 })
166 .returning();
167 }
168
169 // Create repo if not exists
170 if (!(await repoExists(body.username, body.repoName))) {
171 const diskPath = await initBareRepo(body.username, body.repoName);
172 const [repo] = await db
173 .insert(repositories)
174 .values({
175 name: body.repoName,
176 ownerId: user.id,
177 description: body.description || null,
178 diskPath,
179 })
180 .returning();
181 if (repo) {
182 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
183 await bootstrapRepository({
184 repositoryId: repo.id,
185 ownerUserId: user.id,
186 });
187 }
155188 }
156 }
157189
158 return c.json({ user: user.username, repo: body.repoName, status: "ready" });
190 return c.json({
191 user: user.username,
192 repo: body.repoName,
193 status: "ready",
194 });
195 } catch (err) {
196 console.error("[api] POST /setup:", err);
197 return c.json({ error: "Service unavailable" }, 503);
198 }
159199});
160200
161201export default api;
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;
Addedsrc/routes/deployments.tsx+305−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 } from "../middleware/auth";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20
21const dep = new Hono<AuthEnv>();
22
23dep.use("/:owner/:repo/deployments", softAuth);
24dep.use("/:owner/:repo/deployments/*", softAuth);
25
26type Row = typeof deployments.$inferSelect & { triggeredByName: string | null };
27
28async function resolveRepo(owner: string, name: string) {
29 try {
30 const [row] = await db
31 .select({
32 id: repositories.id,
33 name: repositories.name,
34 })
35 .from(repositories)
36 .innerJoin(users, eq(users.id, repositories.ownerId))
37 .where(and(eq(users.username, owner), eq(repositories.name, name)))
38 .limit(1);
39 return row || null;
40 } catch {
41 return null;
42 }
43}
44
45function statusBadgeClass(status: string): string {
46 switch (status) {
47 case "success":
48 return "gate-status passed";
49 case "failed":
50 return "gate-status failed";
51 case "blocked":
52 return "gate-status skipped";
53 case "running":
54 case "pending":
55 return "gate-status running";
56 default:
57 return "gate-status";
58 }
59}
60
61function fmtTs(t: Date | null | undefined): string {
62 if (!t) return "—";
63 return new Date(t).toISOString().replace("T", " ").slice(0, 19) + "Z";
64}
65
66function groupByEnv(rows: Row[]): Record<string, Row[]> {
67 const out: Record<string, Row[]> = {};
68 for (const r of rows) {
69 (out[r.environment] ||= []).push(r);
70 }
71 return out;
72}
73
74function envSummary(rows: Row[]): { last: Row | undefined; successRate: number } {
75 const last = rows[0];
76 const recent = rows.slice(0, 20);
77 const successes = recent.filter((r) => r.status === "success").length;
78 const rate = recent.length ? successes / recent.length : 1;
79 return { last, successRate: rate };
80}
81
82dep.get("/:owner/:repo/deployments", async (c) => {
83 const { owner, repo } = c.req.param();
84 const user = c.get("user");
85 const repoRow = await resolveRepo(owner, repo);
86 if (!repoRow) return c.notFound();
87
88 let rows: Row[] = [];
89 try {
90 rows = (await db
91 .select({
92 id: deployments.id,
93 repositoryId: deployments.repositoryId,
94 environment: deployments.environment,
95 commitSha: deployments.commitSha,
96 ref: deployments.ref,
97 status: deployments.status,
98 blockedReason: deployments.blockedReason,
99 target: deployments.target,
100 triggeredBy: deployments.triggeredBy,
101 createdAt: deployments.createdAt,
102 completedAt: deployments.completedAt,
103 triggeredByName: users.username,
104 })
105 .from(deployments)
106 .leftJoin(users, eq(users.id, deployments.triggeredBy))
107 .where(eq(deployments.repositoryId, repoRow.id))
108 .orderBy(desc(deployments.createdAt))
109 .limit(500)) as Row[];
110 } catch (err) {
111 console.error("[deployments] list:", err);
112 }
113
114 const envs = groupByEnv(rows);
115 const envNames = Object.keys(envs).sort();
116
117 return c.html(
118 <Layout title={`${owner}/${repo} — deployments`} user={user}>
119 <RepoHeader owner={owner} repo={repo} />
120 <div style="max-width: 1000px">
121 <h2>Deployments</h2>
122 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
123 Every deploy to every environment, newest first. Rolled up by
124 environment with the latest status and success rate across the last
125 20 runs.
126 </p>
127
128 {envNames.length === 0 && (
129 <div class="empty-state">
130 <h2>No deployments yet</h2>
131 <p>
132 When a green push reaches the default branch and a deploy
133 target is configured, the deploy will show up here.
134 </p>
135 </div>
136 )}
137
138 {envNames.map((env) => {
139 const envRows = envs[env];
140 const { last, successRate } = envSummary(envRows);
141 return (
142 <div
143 style="border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); margin-bottom: 16px; overflow: hidden"
144 >
145 <div
146 style="padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; background: var(--bg-tertiary); border-bottom: 1px solid var(--border)"
147 >
148 <h3 style="margin: 0; font-size: 15px">{env}</h3>
149 <div style="display: flex; gap: 12px; align-items: center; font-size: 12px">
150 {last && (
151 <span class={statusBadgeClass(last.status)}>
152 {last.status}
153 </span>
154 )}
155 <span style="color: var(--text-muted)">
156 {Math.round(successRate * 100)}% green · {envRows.length} total
157 </span>
158 </div>
159 </div>
160 <div class="gate-list" style="border: none; border-radius: 0">
161 {envRows.slice(0, 10).map((r) => (
162 <div class="gate-run-row">
163 <span class={statusBadgeClass(r.status)}>{r.status}</span>
164 <code
165 style="font-family: var(--font-mono); font-size: 12px"
166 >
167 {r.commitSha.slice(0, 7)}
168 </code>
169 <span style="color: var(--text-muted); font-size: 12px">
170 {r.ref.replace(/^refs\/heads\//, "")}
171 </span>
172 <span style="color: var(--text-muted); font-size: 12px; margin-left: auto">
173 {r.target || "—"}
174 </span>
175 <span style="color: var(--text-muted); font-size: 12px">
176 by {r.triggeredByName || "system"}
177 </span>
178 <span style="color: var(--text-muted); font-size: 12px">
179 {fmtTs(r.createdAt)}
180 </span>
181 <a
182 href={`/${owner}/${repo}/deployments/${r.id}`}
183 style="font-size: 12px"
184 >
185 details
186 </a>
187 </div>
188 ))}
189 {envRows.length > 10 && (
190 <div class="gate-run-row" style="color: var(--text-muted); font-size: 12px">
191 + {envRows.length - 10} more{"\u2026"}
192 </div>
193 )}
194 </div>
195 </div>
196 );
197 })}
198 </div>
199 </Layout>
200 );
201});
202
203dep.get("/:owner/:repo/deployments/:id", async (c) => {
204 const { owner, repo, id } = c.req.param();
205 const user = c.get("user");
206 const repoRow = await resolveRepo(owner, repo);
207 if (!repoRow) return c.notFound();
208
209 let row: Row | null = null;
210 try {
211 const [r] = await db
212 .select({
213 id: deployments.id,
214 repositoryId: deployments.repositoryId,
215 environment: deployments.environment,
216 commitSha: deployments.commitSha,
217 ref: deployments.ref,
218 status: deployments.status,
219 blockedReason: deployments.blockedReason,
220 target: deployments.target,
221 triggeredBy: deployments.triggeredBy,
222 createdAt: deployments.createdAt,
223 completedAt: deployments.completedAt,
224 triggeredByName: users.username,
225 })
226 .from(deployments)
227 .leftJoin(users, eq(users.id, deployments.triggeredBy))
228 .where(
229 and(eq(deployments.id, id), eq(deployments.repositoryId, repoRow.id))
230 )
231 .limit(1);
232 row = (r as Row) || null;
233 } catch (err) {
234 console.error("[deployments] detail:", err);
235 }
236
237 if (!row) return c.notFound();
238
239 return c.html(
240 <Layout
241 title={`Deploy ${row.commitSha.slice(0, 7)} → ${row.environment}`}
242 user={user}
243 >
244 <RepoHeader owner={owner} repo={repo} />
245 <div style="max-width: 700px">
246 <div class="breadcrumb">
247 <a href={`/${owner}/${repo}/deployments`}>deployments</a>
248 <span>/</span>
249 <span>{row.id.slice(0, 8)}</span>
250 </div>
251 <h2>
252 <span class={statusBadgeClass(row.status)}>{row.status}</span>{" "}
253 <span style="font-family: var(--font-mono); font-size: 16px">
254 {row.commitSha.slice(0, 7)}
255 </span>{" "}
256 <span style="color: var(--text-muted); font-weight: 400">
257 &rarr; {row.environment}
258 </span>
259 </h2>
260 <table class="audit-table" style="margin-top: 16px">
261 <tbody>
262 <tr>
263 <th style="width: 140px">Target</th>
264 <td>{row.target || "—"}</td>
265 </tr>
266 <tr>
267 <th>Ref</th>
268 <td>
269 <code>{row.ref}</code>
270 </td>
271 </tr>
272 <tr>
273 <th>Commit</th>
274 <td>
275 <a href={`/${owner}/${repo}/commit/${row.commitSha}`}>
276 <code>{row.commitSha}</code>
277 </a>
278 </td>
279 </tr>
280 <tr>
281 <th>Triggered by</th>
282 <td>{row.triggeredByName || "system"}</td>
283 </tr>
284 <tr>
285 <th>Created</th>
286 <td>{fmtTs(row.createdAt)}</td>
287 </tr>
288 <tr>
289 <th>Completed</th>
290 <td>{fmtTs(row.completedAt)}</td>
291 </tr>
292 {row.blockedReason && (
293 <tr>
294 <th>Blocked reason</th>
295 <td style="color: var(--red)">{row.blockedReason}</td>
296 </tr>
297 )}
298 </tbody>
299 </table>
300 </div>
301 </Layout>
302 );
303});
304
305export default dep;
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;
Modifiedsrc/routes/issues.tsx+37−2View 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";
163166 const { owner: ownerName, repo: repoName } = c.req.param();
164167 const user = c.get("user")!;
165168 const error = c.req.query("error");
169 const template = await loadIssueTemplate(ownerName, repoName);
166170
167171 return c.html(
168172 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
170174 <IssueNav owner={ownerName} repo={repoName} active="issues" />
171175 <div style="max-width: 800px">
172176 <h2 style="margin-bottom: 16px">New issue</h2>
177 {template && (
178 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
179 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
180 </div>
181 )}
173182 {error && (
174183 <div class="auth-error">{decodeURIComponent(error)}</div>
175184 )}
189198 rows={12}
190199 placeholder="Leave a comment... (Markdown supported)"
191200 style="font-family: var(--font-mono); font-size: 13px"
192 />
201 >
202 {template || ""}
203 </textarea>
193204 </div>
194205 <button type="submit" class="btn btn-primary">
195206 Submit new issue
301312 .where(eq(issueComments.issueId, issue.id))
302313 .orderBy(asc(issueComments.createdAt));
303314
315 // Load reactions for the issue + each comment in parallel.
316 const [issueReactions, ...commentReactions] = await Promise.all([
317 summariseReactions("issue", issue.id, user?.id),
318 ...comments.map((row) =>
319 summariseReactions("issue_comment", row.comment.id, user?.id)
320 ),
321 ]);
322
304323 const canManage =
305324 user &&
306325 (user.id === resolved.owner.id || user.id === issue.authorId);
342361 <div class="markdown-body">
343362 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
344363 </div>
364 <div style="padding: 0 16px 12px">
365 <ReactionsBar
366 targetType="issue"
367 targetId={issue.id}
368 summaries={issueReactions}
369 canReact={!!user}
370 />
371 </div>
345372 </div>
346373 )}
347374
348 {comments.map(({ comment, author: commentAuthor }) => (
375 {comments.map(({ comment, author: commentAuthor }, i) => (
349376 <div class="issue-comment-box">
350377 <div class="comment-header">
351378 <strong>{commentAuthor.username}</strong> commented{" "}
354381 <div class="markdown-body">
355382 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
356383 </div>
384 <div style="padding: 0 16px 12px">
385 <ReactionsBar
386 targetType="issue_comment"
387 targetId={comment.id}
388 summaries={commentReactions[i] || []}
389 canReact={!!user}
390 />
391 </div>
357392 </div>
358393 ))}
359394
Modifiedsrc/routes/pulls.tsx+263−56View fileUnifiedSplit
1313} from "../db/schema";
1414import { Layout } from "../views/layout";
1515import { RepoHeader, DiffView } from "../views/components";
16import { ReactionsBar } from "../views/reactions";
17import { summariseReactions } from "../lib/reactions";
18import { loadPrTemplate } from "../lib/templates";
1619import { renderMarkdown } from "../lib/markdown";
1720import { softAuth, requireAuth } from "../middleware/auth";
1821import type { AuthEnv } from "../middleware/auth";
9194 const resolved = await resolveRepo(ownerName, repoName);
9295 if (!resolved) return c.notFound();
9396
97 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
98 const stateFilter =
99 state === "draft"
100 ? and(
101 eq(pullRequests.state, "open"),
102 eq(pullRequests.isDraft, true)
103 )
104 : eq(pullRequests.state, state);
105
94106 const prList = await db
95107 .select({
96108 pr: pullRequests,
99111 .from(pullRequests)
100112 .innerJoin(users, eq(pullRequests.authorId, users.id))
101113 .where(
102 and(
103 eq(pullRequests.repositoryId, resolved.repo.id),
104 eq(pullRequests.state, state)
105 )
114 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
106115 )
107116 .orderBy(desc(pullRequests.createdAt));
108117
109118 const [counts] = await db
110119 .select({
111120 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
121 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
112122 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
113123 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
114124 })
127137 >
128138 {counts?.open ?? 0} Open
129139 </a>
140 <a
141 href={`/${ownerName}/${repoName}/pulls?state=draft`}
142 class={state === "draft" ? "active" : ""}
143 >
144 {counts?.draft ?? 0} Draft
145 </a>
130146 <a
131147 href={`/${ownerName}/${repoName}/pulls?state=merged`}
132148 class={state === "merged" ? "active" : ""}
155171 </div>
156172 ) : (
157173 <div class="issue-list">
158 {prList.map(({ pr, author }) => (
159 <div class="issue-item">
160 <div
161 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
162 >
163 {pr.state === "open"
164 ? "\u25CB"
165 : pr.state === "merged"
166 ? "\u2B8C"
167 : "\u2713"}
168 </div>
169 <div>
170 <div class="issue-title">
171 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
172 {pr.title}
173 </a>
174 </div>
175 <div class="issue-meta">
176 #{pr.number}{" "}
177 {pr.headBranch} → {pr.baseBranch}{" "}
178 by {author.username}{" "}
179 {formatRelative(pr.createdAt)}
174 {prList.map(({ pr, author }) => {
175 const isDraft = pr.state === "open" && pr.isDraft;
176 const stateClass = isDraft
177 ? "state-draft"
178 : pr.state === "open"
179 ? "state-open"
180 : pr.state === "merged"
181 ? "state-merged"
182 : "state-closed";
183 const stateIcon = isDraft
184 ? "\u270E"
185 : pr.state === "open"
186 ? "\u25CB"
187 : pr.state === "merged"
188 ? "\u2B8C"
189 : "\u2713";
190 return (
191 <div class="issue-item">
192 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
193 <div>
194 <div class="issue-title">
195 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
196 {pr.title}
197 </a>
198 {isDraft && (
199 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
200 Draft
201 </span>
202 )}
203 </div>
204 <div class="issue-meta">
205 #{pr.number}{" "}
206 {pr.headBranch} → {pr.baseBranch}{" "}
207 by {author.username}{" "}
208 {formatRelative(pr.createdAt)}
209 </div>
180210 </div>
181211 </div>
182 </div>
183 ))}
212 );
213 })}
184214 </div>
185215 )}
186216 </Layout>
198228 const branches = await listBranches(ownerName, repoName);
199229 const error = c.req.query("error");
200230 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
231 const template = await loadPrTemplate(ownerName, repoName);
201232
202233 return c.html(
203234 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
205236 <PrNav owner={ownerName} repo={repoName} active="pulls" />
206237 <div style="max-width: 800px">
207238 <h2 style="margin-bottom: 16px">Open a pull request</h2>
239 {template && (
240 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
241 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
242 </div>
243 )}
208244 {error && (
209245 <div class="auth-error">{decodeURIComponent(error)}</div>
210246 )}
242278 rows={8}
243279 placeholder="Description (Markdown supported)"
244280 style="font-family: var(--font-mono); font-size: 13px"
245 />
281 >
282 {template || ""}
283 </textarea>
284 </div>
285 <div style="display: flex; gap: 8px">
286 <button type="submit" class="btn btn-primary">
287 Create pull request
288 </button>
289 <button
290 type="submit"
291 name="draft"
292 value="1"
293 class="btn"
294 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
295 >
296 Create draft
297 </button>
246298 </div>
247 <button type="submit" class="btn btn-primary">
248 Create pull request
249 </button>
250299 </form>
251300 </div>
252301 </Layout>
283332 const resolved = await resolveRepo(ownerName, repoName);
284333 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
285334
335 const isDraft = String(body.draft || "") === "1";
336
286337 const [pr] = await db
287338 .insert(pullRequests)
288339 .values({
292343 body: prBody || null,
293344 baseBranch,
294345 headBranch,
346 isDraft,
295347 })
296348 .returning();
297349
298 // Trigger AI code review asynchronously
299 if (isAiReviewEnabled()) {
350 // Skip AI review on drafts — it runs again when the PR is marked ready.
351 if (!isDraft && isAiReviewEnabled()) {
300352 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
301353 (err) => console.error("[ai-review] Failed:", err)
302354 );
345397 .where(eq(prComments.pullRequestId, pr.id))
346398 .orderBy(asc(prComments.createdAt));
347399
400 // Reactions for the PR body + each comment, in parallel.
401 const [prReactions, ...prCommentReactions] = await Promise.all([
402 summariseReactions("pr", pr.id, user?.id),
403 ...comments.map((row) =>
404 summariseReactions("pr_comment", row.comment.id, user?.id)
405 ),
406 ]);
407
348408 const canManage =
349409 user &&
350410 (user.id === resolved.owner.id || user.id === pr.authorId);
417477 </span>
418478 </h2>
419479 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
420 <span
421 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
422 >
423 {pr.state === "open"
424 ? "\u25CB Open"
425 : pr.state === "merged"
426 ? "\u2B8C Merged"
427 : "\u2713 Closed"}
428 </span>
480 {pr.state === "open" && pr.isDraft ? (
481 <span class="issue-badge draft-badge">
482 {"\u270E Draft"}
483 </span>
484 ) : (
485 <span
486 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
487 >
488 {pr.state === "open"
489 ? "\u25CB Open"
490 : pr.state === "merged"
491 ? "\u2B8C Merged"
492 : "\u2713 Closed"}
493 </span>
494 )}
429495 <span style="color: var(--text-muted); font-size: 14px">
430496 <strong style="color: var(--text)">
431497 {author?.username}
463529 <div class="markdown-body">
464530 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
465531 </div>
532 <div style="padding: 0 16px 12px">
533 <ReactionsBar
534 targetType="pr"
535 targetId={pr.id}
536 summaries={prReactions}
537 canReact={!!user}
538 />
539 </div>
466540 </div>
467541 )}
468542
469 {comments.map(({ comment, author: commentAuthor }) => (
543 {comments.map(({ comment, author: commentAuthor }, i) => (
470544 <div
471545 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
472546 >
489563 <div class="markdown-body">
490564 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
491565 </div>
566 <div style="padding: 0 16px 12px">
567 <ReactionsBar
568 targetType="pr_comment"
569 targetId={comment.id}
570 summaries={prCommentReactions[i] || []}
571 canReact={!!user}
572 />
573 </div>
492574 </div>
493575 ))}
494576
541623 </button>
542624 {canManage && (
543625 <>
544 <button
545 type="submit"
546 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
547 class="btn"
548 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
549 >
550 {gateChecks.every((c) => c.passed)
551 ? "Merge pull request"
552 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
553 ? "Merge with auto-resolve"
554 : "Merge pull request"}
555 </button>
626 {pr.isDraft ? (
627 <button
628 type="submit"
629 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
630 class="btn"
631 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
632 title="Mark this draft PR as ready for review — triggers AI review"
633 >
634 Ready for review
635 </button>
636 ) : (
637 <>
638 <button
639 type="submit"
640 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
641 class="btn"
642 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
643 >
644 {gateChecks.every((c) => c.passed)
645 ? "Merge pull request"
646 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
647 ? "Merge with auto-resolve"
648 : "Merge pull request"}
649 </button>
650 <button
651 type="submit"
652 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
653 class="btn"
654 title="Convert back to draft"
655 >
656 Convert to draft
657 </button>
658 </>
659 )}
556660 <button
557661 type="submit"
558662 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
643747 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
644748 }
645749
750 // Draft PRs cannot be merged — must be marked ready first.
751 if (pr.isDraft) {
752 return c.redirect(
753 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
754 "This PR is a draft. Mark it as ready for review before merging."
755 )}`
756 );
757 }
758
646759 // Resolve head SHA
647760 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
648761 if (!headSha) {
752865 }
753866);
754867
868// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
869// hasn't run yet on this PR.
870pulls.post(
871 "/:owner/:repo/pulls/:number/ready",
872 softAuth,
873 requireAuth,
874 async (c) => {
875 const { owner: ownerName, repo: repoName } = c.req.param();
876 const prNum = parseInt(c.req.param("number"), 10);
877 const user = c.get("user")!;
878
879 const resolved = await resolveRepo(ownerName, repoName);
880 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
881
882 const [pr] = await db
883 .select()
884 .from(pullRequests)
885 .where(
886 and(
887 eq(pullRequests.repositoryId, resolved.repo.id),
888 eq(pullRequests.number, prNum)
889 )
890 )
891 .limit(1);
892 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
893
894 // Only the author or repo owner can toggle draft state.
895 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
896 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
897 }
898
899 if (pr.state === "open" && pr.isDraft) {
900 await db
901 .update(pullRequests)
902 .set({ isDraft: false, updatedAt: new Date() })
903 .where(eq(pullRequests.id, pr.id));
904
905 if (isAiReviewEnabled()) {
906 triggerAiReview(
907 ownerName,
908 repoName,
909 pr.id,
910 pr.title,
911 pr.body,
912 pr.baseBranch,
913 pr.headBranch
914 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
915 }
916 }
917
918 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
919 }
920);
921
922// Convert a PR back to draft.
923pulls.post(
924 "/:owner/:repo/pulls/:number/draft",
925 softAuth,
926 requireAuth,
927 async (c) => {
928 const { owner: ownerName, repo: repoName } = c.req.param();
929 const prNum = parseInt(c.req.param("number"), 10);
930 const user = c.get("user")!;
931
932 const resolved = await resolveRepo(ownerName, repoName);
933 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
934
935 const [pr] = await db
936 .select()
937 .from(pullRequests)
938 .where(
939 and(
940 eq(pullRequests.repositoryId, resolved.repo.id),
941 eq(pullRequests.number, prNum)
942 )
943 )
944 .limit(1);
945 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
946
947 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
948 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
949 }
950
951 if (pr.state === "open" && !pr.isDraft) {
952 await db
953 .update(pullRequests)
954 .set({ isDraft: true, updatedAt: new Date() })
955 .where(eq(pullRequests.id, pr.id));
956 }
957
958 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
959 }
960);
961
755962// Close PR
756963pulls.post(
757964 "/:owner/:repo/pulls/:number/close",
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/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;
Modifiedsrc/routes/settings.tsx+62−0View fileUnifiedSplit
7676 Update profile
7777 </button>
7878 </form>
79
80 <h3 style="margin-top: 32px; font-size: 16px">Email notifications</h3>
81 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px">
82 Opt out of individual email categories. In-app notifications are
83 unaffected and continue to appear in your inbox.
84 </p>
85 <form method="POST" action="/settings/notifications">
86 <label
87 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
88 >
89 <input
90 type="checkbox"
91 name="notify_email_on_mention"
92 value="1"
93 checked={user.notifyEmailOnMention}
94 />
95 <span>
96 Someone <code>@mentions</code> me or requests a review
97 </span>
98 </label>
99 <label
100 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
101 >
102 <input
103 type="checkbox"
104 name="notify_email_on_assign"
105 value="1"
106 checked={user.notifyEmailOnAssign}
107 />
108 <span>I am assigned to an issue or PR</span>
109 </label>
110 <label
111 style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; font-size: 14px"
112 >
113 <input
114 type="checkbox"
115 name="notify_email_on_gate_fail"
116 value="1"
117 checked={user.notifyEmailOnGateFail}
118 />
119 <span>A gate fails on one of my repositories</span>
120 </label>
121 <button type="submit" class="btn btn-primary">
122 Save preferences
123 </button>
124 </form>
79125 </div>
80126 </Layout>
81127 );
82128});
83129
130settings.post("/settings/notifications", async (c) => {
131 const user = c.get("user")!;
132 const body = await c.req.parseBody();
133 await db
134 .update(users)
135 .set({
136 notifyEmailOnMention: String(body.notify_email_on_mention || "") === "1",
137 notifyEmailOnAssign: String(body.notify_email_on_assign || "") === "1",
138 notifyEmailOnGateFail:
139 String(body.notify_email_on_gate_fail || "") === "1",
140 updatedAt: new Date(),
141 })
142 .where(eq(users.id, user.id));
143 return c.redirect("/settings?success=Email+preferences+updated");
144});
145
84146settings.post("/settings/profile", async (c) => {
85147 const user = c.get("user")!;
86148 const body = await c.req.parseBody();
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;
Modifiedsrc/views/layout.tsx+146−3View fileUnifiedSplit
77 title?: string;
88 user?: User | null;
99 notificationCount?: number;
10 theme?: "dark" | "light";
1011 }>
11> = ({ children, title, user, notificationCount }) => {
12> = ({ children, title, user, notificationCount, theme }) => {
13 const initialTheme = theme === "light" ? "light" : "dark";
1214 return (
13 <html lang="en">
15 <html lang="en" data-theme={initialTheme}>
1416 <head>
1517 <meta charset="UTF-8" />
1618 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1719 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
20 <script>{themeInitScript}</script>
1821 <style>{css}</style>
1922 <style>{hljsThemeCss}</style>
2023 </head>
3538 </form>
3639 </div>
3740 <div class="nav-right">
41 <a
42 href="/theme/toggle"
43 class="nav-link nav-theme"
44 title="Toggle theme"
45 aria-label="Toggle theme"
46 >
47 <span class="theme-icon-dark">{"\u263E"}</span>
48 <span class="theme-icon-light">{"\u2600"}</span>
49 </a>
3850 <a href="/explore" class="nav-link">
3951 Explore
4052 </a>
91103 );
92104};
93105
106// Runs before paint — reads the theme cookie and flips data-theme so there's
107// no dark-to-light flash on load. SSR default is dark.
108const themeInitScript = `
109 (function(){
110 try {
111 var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
112 var t = m ? decodeURIComponent(m[1]) : 'dark';
113 if (t !== 'light' && t !== 'dark') t = 'dark';
114 document.documentElement.setAttribute('data-theme', t);
115 } catch(_){}
116 })();
117`;
118
94119const navScript = `
95120 (function(){
96121 var chord = null;
135160`;
136161
137162const css = `
138 :root {
163 :root, :root[data-theme='dark'] {
139164 --bg: #0d1117;
140165 --bg-secondary: #161b22;
141166 --bg-tertiary: #21262d;
153178 --radius: 6px;
154179 }
155180
181 :root[data-theme='light'] {
182 --bg: #ffffff;
183 --bg-secondary: #f6f8fa;
184 --bg-tertiary: #eaeef2;
185 --border: #d0d7de;
186 --text: #1f2328;
187 --text-muted: #656d76;
188 --text-link: #0969da;
189 --accent: #0969da;
190 --accent-hover: #0550ae;
191 --green: #1a7f37;
192 --red: #cf222e;
193 --yellow: #9a6700;
194 }
195
196 /* Theme toggle — show the icon for the *opposite* theme so users see what they'll switch to. */
197 .nav-theme { display: inline-flex; align-items: center; font-size: 16px; line-height: 1; }
198 :root[data-theme='dark'] .theme-icon-dark { display: none; }
199 :root[data-theme='light'] .theme-icon-light { display: none; }
200
156201 * { margin: 0; padding: 0; box-sizing: border-box; }
157202
158203 body {
839884 .search-hit .hit-path { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
840885 .search-hit pre { margin-top: 8px; font-size: 12px; }
841886
887 /* Audit log */
888 .audit-log {
889 border: 1px solid var(--border);
890 border-radius: var(--radius);
891 overflow-x: auto;
892 background: var(--bg-secondary);
893 }
894 .audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
895 .audit-table th, .audit-table td {
896 text-align: left;
897 padding: 8px 12px;
898 border-bottom: 1px solid var(--border);
899 vertical-align: top;
900 }
901 .audit-table th {
902 background: var(--bg-tertiary);
903 font-size: 11px;
904 text-transform: uppercase;
905 letter-spacing: 0.5px;
906 color: var(--text-muted);
907 font-weight: 600;
908 }
909 .audit-table tr:last-child td { border-bottom: none; }
910 .audit-when { white-space: nowrap; color: var(--text-muted); }
911 .audit-muted { color: var(--text-muted); font-style: italic; }
912 .audit-action {
913 font-family: var(--font-mono);
914 font-size: 11px;
915 padding: 1px 6px;
916 background: var(--bg-tertiary);
917 border: 1px solid var(--border);
918 border-radius: 3px;
919 color: var(--text-link);
920 }
921 .audit-ip, .audit-meta code {
922 font-family: var(--font-mono);
923 font-size: 11px;
924 color: var(--text-muted);
925 }
926 .audit-target code {
927 font-family: var(--font-mono);
928 font-size: 11px;
929 color: var(--text-muted);
930 }
931
932 /* Reactions */
933 .reactions {
934 display: flex;
935 gap: 6px;
936 flex-wrap: wrap;
937 margin-top: 8px;
938 }
939 .reaction-btn {
940 display: inline-flex;
941 align-items: center;
942 gap: 4px;
943 padding: 2px 8px;
944 border-radius: 12px;
945 background: var(--bg-tertiary);
946 border: 1px solid var(--border);
947 color: var(--text);
948 font-size: 12px;
949 cursor: pointer;
950 font-family: inherit;
951 }
952 .reaction-btn:hover { background: var(--border); }
953 .reaction-btn.active { background: rgba(31, 111, 235, 0.15); border-color: var(--accent); color: var(--accent); }
954 .reaction-picker {
955 display: inline-flex;
956 gap: 4px;
957 align-items: center;
958 }
959 .reaction-picker form { display: inline; }
960 .reaction-count { font-size: 11px; font-weight: 600; }
961
962 /* Saved replies */
963 .saved-replies-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
964 .saved-reply-item { border-bottom: 1px solid var(--border); background: var(--bg); }
965 .saved-reply-item:last-child { border-bottom: none; }
966 .saved-reply-item > summary {
967 padding: 10px 16px;
968 cursor: pointer;
969 list-style: none;
970 display: flex;
971 align-items: center;
972 }
973 .saved-reply-item > summary::-webkit-details-marker { display: none; }
974 .saved-reply-item > summary:hover { background: var(--bg-secondary); }
975 .saved-reply-item code { font-family: var(--font-mono); font-size: 12px; color: var(--text-link); }
976
977 /* Draft PR */
978 .draft-badge {
979 background: rgba(139, 148, 158, 0.15);
980 color: var(--text-muted);
981 border: 1px solid var(--text-muted);
982 }
983 .state-draft { color: var(--text-muted); }
984
842985 /* Toasts (prepared for future UI hooks) */
843986 .toast-container {
844987 position: fixed; bottom: 24px; right: 24px;
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};
069