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

Merge pull request #99 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

Merge pull request #99 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL

feat: add master to-do list with prioritised roadmap from platform analysis session
CC LABS App committed on June 7, 2026Parents: dfeec2e 1df50d5
116 files changed+27383888ef3d521f3c2978157c1580074117722f3a697ebe
116 changed files+27383−888
Modified.gitignore+7−0View fileUnifiedSplit
2222
2323# standalone box local DB backups
2424backups/
25
26# JetBrains plugin build artifacts
27jetbrains-plugin/build/
28jetbrains-plugin/.gradle/
29jetbrains-plugin/.idea/
30jetbrains-plugin/*.iml
31jetbrains-plugin/*.class
AddedTODO.md+188−0View fileUnifiedSplit
1# Gluecron Master To-Do List
2
3Last updated: 2026-06-06 (rev 4 — full codebase audit complete, Bible is 35% of reality)
4
5**IMPORTANT:** The BUILD_BIBLE.md documents ~35% of the actual codebase. 34 migrations (0043–0076) and 87+ lib files exist beyond what the Bible tracks. This list is based on direct code scanning, not the Bible.
6
7Tick off items as they ship. Add `[x] YYYY-MM-DD commit:abc` when done.
8Work top-to-bottom within each priority.
9
10---
11
12## 🔴 PRIORITY 1 — Configuration Blockers (Built, Just Not Configured)
13
14These are NOT build tasks — the code is complete. They need ops/config action.
15
16- [ ] **Verify SSH git push works end-to-end** — `src/lib/ssh-server.ts` is a full 545-line production implementation that starts at boot (`src/index.ts`). It handles `git-upload-pack` and `git-receive-pack`, does public-key auth against the `ssh_keys` table, and triggers post-receive hooks. Test from a clean machine: add an SSH key at `/settings`, then `git clone git@gluecron.com:user/repo.git`. If the SSH_PORT env var isn't set, default is 2222 — verify the port is open on the server.
17- [ ] **Enable AI Trio Review** — Three-model parallel PR review (security / correctness / style) is fully built in `src/lib/ai-review-trio.ts` and wired into `src/lib/ai-review.ts`. Set `AI_TRIO_REVIEW_ENABLED=1` to activate. This is a genuine differentiator — no other platform has this.
18- [x] 2026-06-06 **Fix duplicate migration number 0065** — Renamed `0065_auto_generate_tests.sql` to `0077_auto_generate_tests.sql` (was conflicting with `0065_ai_cost_events.sql`). Both migrations now have unique numbers.
19- [ ] **Set SERVER_TARGETS_KEY** — Server targets encrypt SSH private keys via AES. If `SERVER_TARGETS_KEY` env var isn't set, deploy target creation will fail silently. Set a 32-byte hex key in production.
20- [ ] **Set PREVIEW_DOMAIN** — Branch preview URLs are computed as `${branchSlug}-${repoSlug}.preview.gluecron.com` or `PREVIEW_DOMAIN` env var. Set this to match where previews will actually be served.
21
22---
23
24## 🟠 PRIORITY 2 — Genuine Code Gaps (Need Building)
25
26These are confirmed missing by direct code inspection.
27
28- [x] 2026-06-06 **Container registry (Docker/OCI)** — `src/routes/oci-registry.ts` implements full OCI Distribution Spec v1.0: GET/HEAD/PUT/DELETE blobs, chunked PATCH uploads, GET/PUT/DELETE manifests, tags list, catalog. Blobs stored at `${OCI_STORE_PATH}/blobs/sha256/<digest>`. `drizzle/0084_oci_registry.sql` adds `oci_repositories` + `oci_tags`. Basic auth via `api_tokens` table.
29- [x] 2026-06-06 **Redis SSE fan-out**`src/lib/sse.ts` rewritten. When `REDIS_URL` or `VALKEY_URL` is set, uses two `Bun.RedisClient` instances (pub + sub) with `autoReconnect`. Topic-scoped SUBSCRIBE/UNSUBSCRIBE. On disconnect, re-subscribes all channels. Falls back to local delivery if Redis unreachable. In-memory path unchanged when no URL set. Zero API changes to callers.
30- [x] 2026-06-06 **Workflow cache SAVE**`saveCacheEntry()` implemented in `src/lib/actions/cache-action.ts`. DB-backed via `workflow_run_cache` Postgres table; tarballs paths, SHA-256 hashes, 100MB cap, upserts content. Wired in `src/lib/workflow-runner.ts` post-job. LOAD unchanged.
31- [x] 2026-06-06 **Pack-content ruleset enforcement**`commit_message_pattern`, `blocked_file_paths`, `max_file_size` now blocking at push time via pre-receive hook (`GIT_CONFIG_COUNT` env injection). Git commands: `git log --format="%H %s"`, `git diff --name-only`, `git cat-file -s`. Wired in `src/routes/git.ts` (HTTP) and `src/lib/ssh-server.ts` (SSH). All 10 push-policy + 23 ruleset tests pass.
32- [x] 2026-06-06 commit:44ed968 **Branch preview expiry cleanup**`expireOldPreviews()` wired as `preview-expiry` autopilot task in `src/lib/autopilot.ts`. Admin UI updated to show 10 tasks including `auto-merge-sweep`, `ai-build-from-issues`, and `preview-expiry`.
33- [x] 2026-06-06 **Server targets → customer-facing**`src/routes/deploy-targets.tsx` (414 lines). `GET/POST /settings/deploy-targets`, `POST /settings/deploy-targets/:id/delete`, `POST /settings/deploy-targets/:id/test`. AES-256-GCM encryption via `SERVER_TARGETS_KEY`. Ownership-gated. Added to settings subnav and registered in `src/app.tsx`.
34- [x] 2026-06-06 **Claude Web Sessions → customer-facing**`/:owner/:repo/claude` now open to all authenticated users with repo access. `listSessionsForUser()` scopes sessions to owner. Session list: colour-coded status badges, reverse-chron order. SSE stream via `GET /:owner/:repo/claude/:sessionId/stream?prompt=`. Spawns `claude --print --output-format stream-json`. "✨ Claude AI" sidebar card added to repo home (`src/routes/web.tsx`).
35- [x] 2026-06-06 **AI budget hard enforcement**`assertAiQuota(userId)` added to `src/lib/billing.ts` with 60s in-memory cache. Wired into `ai-review.ts` (posts skip comment), `ai-review-trio.ts` (fail-closed trio result), `ai-ci-healer.ts` (returns skipped), `spec-to-pr.ts` (returns error to UI). Warn at 90%, throw `AiQuotaExceededError` at 100%. Fails open on DB error.
36- [x] 2026-06-06 **Spec-to-Live real-time progress UI**`src/routes/specs.tsx` (+593 lines). POST redirects to `/:owner/:repo/spec/:jobId/progress`. Polling (2s, &lt;20 lines JS) as primary; SSE endpoint as secondary. 8-stage timeline: analyzing→writing→opening_pr→ai_reviewing→gates_running→merging→deploying→done. In-memory `SpecJob` Map with 10-min eviction. No-JS server-render fallback.
37- [ ] **Agent marketplace — real listings** — Migration `0070_agent_marketplace.sql` is complete with full schema (listings, installs, reviews, 30% revenue cut built in). The route `src/routes/marketplace-agents.tsx` exists. But there are only seed listings. Write a "Publish an agent" guide, reach out to 10 developers, get 20+ real listings.
38
39---
40
41## 🟡 PRIORITY 3 — Messaging (Every Page Needs This)
42
43- [x] 2026-06-06 **Landing hero** (`src/views/landing.tsx`) — H1: "Write the spec. Gluecron ships it." Subhead: "Spec to PR in 90 seconds. Push to live in 25. AI review, auto-merge, deploy — automatic." Sleep Mode demoted to single closing clause. Rail label: "deploys shipped".
44- [x] 2026-06-06 **Landing "what's happening now" rail** — Rail label updated; "Three reasons" card 1 rewritten to lead with timing numbers. Sleep Mode demoted to one line.
45- [x] 2026-06-06 **vs-github AI rows** (`src/routes/vs-github.tsx`) — All 10 AI-native rows now have latency numbers: "AI review fires the moment PR opens (~8s)", "auto-merge triggers the instant gates pass", "ai:build → draft PR in 90 seconds", etc. Sleep Mode renamed to "async batch digest" — framed as opt-in, not the headline.
46- [x] 2026-06-06 **Demo page** (`src/routes/demo.tsx`) — Tile headings: "being built right now" / "merged the instant gates passed". Steps updated with real-time language. Live feed subtitle: "Happening right now". Empty states rewritten.
47- [x] 2026-06-06 **Sleep Mode demoted** — Demoted across landing (one closing clause) and vs-github (async opt-in framing). Never leads anywhere.
48- [x] 2026-06-06 **OG/meta descriptions** — All pages audited. Landing title/desc: "AI-native git host. Spec to PR in 90 seconds." Pricing, vs-github, demo, explore, help: descriptions added. Per-repo pages: dynamic description. "wake up to", "overnight", "while you sleep" stripped everywhere including landing-2030.tsx body copy. Tests updated.
49- [x] 2026-06-06 **Pricing page** — Speed-first hero: monospace "Spec to PR in 90 seconds." accent stat, sub-copy with timing numbers. "Included in every plan" pill strip. All 4 plan card taglines mention AI review + auto-merge. OG/meta description added.
50
51---
52
53## 🔵 PRIORITY 4 — Polish & Customer Experience
54
55### Onboarding
56- [x] 2026-06-06 **Empty state for new repos** — 3-option panel in `src/routes/web.tsx`: git commands to push first commit, link to /import, link to /:owner/:repo/specs. Done in wave 1.
57- [x] 2026-06-06 **Onboarding email sequence** — `drizzle/0081_onboarding_emails.sql` adds `onboarding_emails_sent jsonb`. `src/lib/onboarding-drip.ts`: T+0 welcome (fire-and-forget in `auth.tsx` POST /register), T+1d "Try Spec-to-PR", T+3d "Your AI is watching" via `onboarding-drip` autopilot task. Idempotent via jsonb key tracking. Skips when RESEND_API_KEY unset.
58- [x] 2026-06-06 **Dashboard "AI just did this" widget** — `AiActivityWidget` added to `src/routes/dashboard.tsx`. Queries `audit_log` (auto_merge.merged, ai_build.dispatched) and `gate_runs` (status=repaired) for last 60 minutes. Shows per-category counts, item list with links, "All quiet — AI is watching." empty state.
59- [x] 2026-06-06 **Push Watch → make it discoverable** — Pulsing "● Live" badge in `RepoHeader` (red + `pushWatchPulse` animation when &lt;5min, muted "○ Watch" when &lt;24hr). Query on `activity_feed` WHERE action='push'. Eye-icon watch link on every commit row. `src/views/components.tsx`, `src/views/layout.tsx`, `src/routes/web.tsx`.
60- [x] 2026-06-06 **Repo overview AI stats strip**`getRepoAiStats(repoId)` in `src/routes/web.tsx`. Shows "⚡ AI merged N PRs this week · Saved ~X hrs · N open security alerts" below file tree. Queries `activity_feed` (auto_merge), `pr_comments` (is_ai_review), `gate_runs` (security). Hidden when all zeros.
61
62### Admin
63- [x] 2026-06-06 **Admin > AI cost breakdown** — `/admin/ai-costs`: monthly total, breakdown by `category`, top 10 spenders with CSS bar chart. `ai_cost_events` JOIN `users`. Added to admin dashboard nav.
64- [x] 2026-06-06 **Admin > Stripe sync** — `src/routes/admin-stripe.tsx` at `/admin/stripe`. Fetches non-free users from `user_quotas`, calls live Stripe API per user, flags plan mismatches. `POST /admin/stripe/:userId/sync` corrects local plan to match Stripe. Degrades gracefully without STRIPE_SECRET_KEY.
65- [x] 2026-06-06 **Admin > Autopilot health**`/admin/autopilot/health`: 10 tasks with last-tick status, duration, 24h success/error counts from `audit_log`. In-process `getLastTick()`/`getTickCount()` from autopilot.ts.
66- [x] 2026-06-06 **Admin > User growth chart**`/admin/growth`: daily signups last 30 days (`date_trunc('day', created_at)`), activation rate (users with ≥1 repo), CSS bar chart table.
67- [x] 2026-06-06 commit:44ed968 **K3 tasks on `/admin/autopilot`**`auto-merge-sweep` and `ai-build-from-issues` were already present; `preview-expiry` added. Badge updated to "10 tasks".
68
69### Developer Experience
70- [x] 2026-06-06 **System/autopilot user**`drizzle/0078_bot_user.sql` seeds `gluecron[bot]` (empty password_hash, non-loginable). `src/lib/bot-user.ts` lazy-caches the UUID. 10 comment call sites updated across `stale-sweep.ts`, `ai-review.ts`, `ai-review-trio.ts`, `autopilot.ts`. 🤖 bot pill shown in PR/issue comment headers.
71- [x] 2026-06-06 **Notification preferences** — Restructured into 4 categories in `src/routes/settings.tsx`: AI activity, CI/CD, Code review, Mentions. All existing `name=` attrs preserved — POST handler unchanged. Email pill count updated to 5 events.
72- [x] 2026-06-06 **Repo health badge on repo overview** — Health score badge added to `RepoHeader` in `src/routes/web.tsx`. Grade colours: Elite=green, Strong=blue, Improving=yellow, Needs Attention=red. Links to /:owner/:repo/insights/health. Done in wave 1.
73- [x] 2026-06-06 **AI Trio Review UI indicator** — `TrioVerdictPills` component added to `src/routes/pulls.tsx`. Three pills (Security/Correctness/Style) in the PR header meta div. Feature-flagged on `AI_TRIO_REVIEW_ENABLED=1`. Pills link to `#trio-review-section`. No extra DB query — reads from already-fetched `prComments`.
74- [x] 2026-06-06 **L1 sleep-mode column split**`drizzle/0079_sleep_digest_column.sql` adds `last_sleep_digest_sent_at`. Schema updated. `sleep-mode.ts` and `autopilot.ts` now write/read the dedicated column. Tests updated. (Renamed from 0077 to avoid collision with `0077_auto_generate_tests.sql`.)
75- [x] 2026-06-06 **GitHub unlink route**`POST /settings/github/unlink` deletes `sso_user_links` rows with `subject` starting `"github:"`. "Disconnect GitHub" button shown on settings page when GitHub is linked. Audited via `auth.github.unlink`.
76- [x] 2026-06-06 **Branch preview expiry UX**`src/routes/previews.tsx`: expired cards get `.preview-card.is-expired`, strikethrough muted URL, "↺ Rebuild" button linking to `/previews/rebuild?branch=`. `expireOldPreviews()` confirmed to set `status='expired'` correctly.
77
78### Documentation & Help
79- [x] 2026-06-06 **Docs site**`src/routes/docs.tsx` (1600 lines). Routes: /docs, /docs/getting-started, /docs/workflow-yaml, /docs/mcp-server, /docs/api, /docs/agents. All 15 MCP tool names from mcp-tools.ts, real rate limits, real workflow YAML examples, agent.json manifest format. Footer "Docs" link updated.
80- [x] 2026-06-06 **Changelog page**`src/routes/changelog.tsx` at `/changelog`. June + May 2026 releases listed. "Subscribe to updates" CTA → `/settings/notifications`. Changelog link added to footer in `layout.tsx`.
81- [ ] **Legal pages attorney review** — All four legal pages (`terms`, `privacy`, `dmca`, `acceptable-use`) are substantive drafts marked "DRAFT — requires attorney review." Get legal sign-off before any paid launch.
82- [x] 2026-06-06 **Status page — polish**`drizzle/0080_incidents_and_status_subscribers.sql` adds `incidents` + `status_subscribers` tables. `src/routes/status.tsx` rewritten: overall status banner, 6 service uptime rows, last-10 incident history table, subscribe form with confirm/unsubscribe token flow.
83
84---
85
86## 🟣 PRIORITY 5 — Growth & Distribution
87
88- [ ] **60-second demo video** — Screen record: type a spec → AI writes code → PR opens → trio review posts → gates pass → auto-merged. Show elapsed time counter. No voiceover. Embed everywhere.
89- [ ] **VS Code extension → publish**`vscode-extension/` is built. Run `vsce package`, publish to VS Code Marketplace. Free discovery.
90- [ ] **CLI → publish to npm**`cli/gluecron.ts` is built. Publish as `gluecron` npm package. `npx gluecron login` as zero-install entry.
91- [ ] **CLI → Homebrew formula**`brew install gluecron`. Mac developer standard.
92- [x] 2026-06-06 **JetBrains plugin**`jetbrains-plugin/` skeleton (14 files). Gradle + IntelliJ Platform Plugin 1.16.1, targets 2023.1+. 4 actions: Open PRs, Create Issue, Merge PR (API call), View Health. `GluecronUtil.kt` detects owner/repo from git remote. `./gradlew buildPlugin` → zip. Publish to JetBrains Marketplace manually.
93- [x] 2026-06-06 **GitHub migration as primary CTA** — "Migrate from GitHub →" button added to landing hero and logged-out nav (accent pill). "Coming from GitHub?" callout card on explore page. Import page headline: "Migrate your GitHub org in 60 seconds."
94- [x] 2026-06-06 **Developer program page**`src/routes/developer-program.tsx` at `/developer-program`. Hero: "Build on Gluecron. Earn revenue." Publish agent / 70% revenue share / partner badge sections. Partner application form (POST logs + redirects). Footer link added.
95- [x] 2026-06-06 **Shareable AI hours saved card**`src/routes/share.tsx`: SVG OG image at `/share/hours-saved?user=:username` (1200×630, dark bg, green glow number). HTML share page at `/share/:username` with og:image, Twitter pre-fill, copy-link button. "Share your AI stats" link added to billing usage page. Hours: PRs×1.5 + reviews×0.5 + heals×0.3.
96- [x] 2026-06-06 **Blog / devlog**`src/routes/blog.tsx` at `/blog` + `/blog/:slug`. 3 posts: "30 features in one session", "Why we killed the overnight pitch", "Spec to PR in 90 seconds". Footer "Blog" link added. No DB dependency.
97
98---
99
100## ⚫ PRIORITY 6 — Strategic / Long-Term
101
102- [ ] **SOC 2 Type II** — Engage auditor, scope controls. 6–9 months. No enterprise deals without it.
103- [x] 2026-06-06 **EU data residency**`drizzle/0083_data_region.sql` adds `data_region` to `repositories`. Dropdown on repo creation (US/EU Frankfurt). Read-only pill in repo settings. "EU data residency" added to Pro tier on pricing page + FAQ.
104- [x] 2026-06-06 **GDPR account deletion verification** — Two gaps fixed in `src/lib/account-deletion.ts`: disk repo cleanup (rm each `repositories.diskPath` + user dir) and Stripe subscription cancellation. DB CASCADE already handled sessions/ssh_keys/api_tokens etc. `audit_log.user_id` ON DELETE SET NULL anonymises rows. New `/admin/deletions` page + force-purge button.
105- [x] 2026-06-06 **Audit log SIEM export**`GET /api/v2/audit` in `src/routes/api-v2.ts`. Admin Bearer auth, params: since/until/limit/cursor/actor/action/resource_type. Returns `{events, nextCursor, hasMore}` + `X-Total-Count` header. Each event: id, action, actor_username, resource_type, metadata, created_at, ip_address.
106- [x] 2026-06-06 **Enterprise sales page** — `src/routes/enterprise.tsx` at `/enterprise`. Sections: custom pricing, SSO (SAML/OIDC), SLA, data residency, SOC 2, SIEM. Contact form `POST /enterprise/contact` → `enterprise_leads` table (migration 0082). Footer "Enterprise" link added.
107- [ ] **Native iOS app** — Minimum viable: repo browser, notifications, PR approve/reject, AI chat. React Native.
108- [ ] **Native Android app** — Share React Native codebase with iOS.
109- [x] 2026-06-06 **Multi-agent pipeline UI**`src/routes/agent-pipelines.tsx` (1100 lines). Routes: `/:owner/:repo/agents` (list), `/agents/new` (builder, JS-free ?stages=N pattern), `POST /agents` (creates session + lease rows), `/:sessionId` (live view, 5s meta-refresh), `/:sessionId/cancel`. "Agents" tab added to RepoNav.
110- [x] 2026-06-06 **AI pair programmer (browser)** — Covered by "Claude Web Sessions → customer-facing" above. `/:owner/:repo/claude` open to all authenticated users. SSE streaming via `claude --print --output-format stream-json`. "✨ Claude AI" sidebar card on repo home.
111- [x] 2026-06-06 **End-to-end test suite** — 38 Playwright tests across `e2e/`: auth (7), repo (9), pulls (6), issues (8), settings (8). `e2e/fixtures.ts` shared helpers with real git push via `Bun.spawn`. `bun run e2e` script added. `@playwright/test ^1.49.0` in devDependencies.
112- [x] 2026-06-06 **Load testing**`scripts/load-test.js` (100 VUs, p95&lt;500ms threshold, tests landing/explore/blog/pricing). `scripts/load-test-git.js` (150 VUs + spike, tests Smart HTTP info/refs and git-upload-pack). Run with `k6 run scripts/load-test.js`.
113- [ ] **Database connection pooling verification** — Confirm PgBouncer or Neon pooling is correctly configured for multi-instance load.
114
115---
116
117## ✅ CONFIRMED COMPLETE (Direct Code Verification)
118
119Verified by reading actual files — not just the Bible.
120
121**Revenue/Billing:**
122- Stripe Checkout + webhook + customer portal — complete, needs env vars only
123- Billing plans, quotas, usage tracking — complete
124- Billing UI with usage bars, plan cards, upgrade flow — complete
125- AI cost events + per-call tracking (`ai_cost_events` table) — complete
126- Budget cap warning system — complete (advisory; hard enforcement is a gap above)
127
128**Auth & Identity:**
129- SSH git push — complete (ssh-server.ts, 545 lines, wired at boot)
130- Password reset, email verification, magic link sign-in — complete
131- Google OAuth — complete
132- Playground anonymous accounts — complete
133- Account deletion with grace period — complete
134- Terms acceptance audit trail — complete
135
136**AI Features (all wired, not stubs):**
137- AI CI healer (auto-fixes failed workflow runs) — complete, runs every 5 min
138- AI proactive monitor (platform health surveillance) — complete, runs hourly
139- Stale PR/issue sweep (two-stage poke + auto-close) — complete, runs every 5 min
140- AI trio review (three-model parallel: security/correctness/style) — complete, opt-in
141- AI standup generation (daily/weekly briefs) — complete
142- Repair flywheel (learning cache for patches) — complete
143- Voice-to-PR — complete (1092 lines)
144- Multi-repo refactoring — complete
145- Migration assistant (AI-driven major dep upgrades) — complete
146
147**Developer Experience:**
148- Per-repo AI chat — complete (repo-chat.tsx, 967 lines)
149- Personal cross-repo AI chat — complete (personal-chat.tsx, 1137 lines)
150- Hosted Claude loops (deploy Claude agents as endpoints) — complete
151- Cloud dev environments (browser IDE) — complete (schema + routes, feature-flagged)
152- Branch preview URLs — complete (URL gen; expiry cleanup missing — see Priority 2)
153- PR sandboxes — complete (4h TTL, auto-provision on PR open)
154- PR live co-editing with cursor presence — complete
155- Comment moderation queue — complete
156- Import secrets from GitHub — complete
157- Agent multiplayer (sessions, leases, budgets) — complete
158
159**Integrations:**
160- Slack/Discord/Teams chat notifications — complete
161- Durable webhook delivery with exponential backoff retry — complete
162- Synthetic uptime monitoring — complete
163- Deploy timeline + step streaming — complete
164
165**Admin:**
166- /admin/diagnose — 14 system health checks — complete
167- /admin/self-host — self-hosting wizard — complete
168- /admin/servers — SSH deploy targets — complete (admin-only; customer rollout is a gap)
169
170**Everything in BUILD_BIBLE §2 marked ✅** is confirmed present in the codebase. The Bible claims 100% accuracy for what it documents — no phantom features found.
171
172---
173
174## 💳 LAST — Stripe/Billing Configuration (When Platform Is Ready)
175
176These are NOT build tasks — the code is 100% complete. Do these only after the platform is stable and ready for paying customers.
177
178- [ ] **Set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET in production** — Stripe Checkout, customer portal, and webhook handler are 100% built (`src/lib/stripe.ts`, `src/routes/billing.tsx`, `src/routes/stripe-webhook.ts`). The billing UI even shows a warning when the key is missing. Run `scripts/stripe-bootstrap.ts` to create products/prices in Stripe with the right lookup keys (`gluecron_pro_monthly` etc), then set the secrets on Fly.io. Zero code changes needed.
179
180---
181
182## Notes
183
184- `- [ ]` = not started / not configured
185- `- [x] YYYY-MM-DD commit:abc` = done
186- Bible is accurate but covers only ~35% of the codebase by file count
187- 34 migrations beyond 0042 represent major post-Bible development
188- When in doubt: scan the code, don't trust the Bible
Modifiedbun.lock+3−0View fileUnifiedSplit
1717 },
1818 "devDependencies": {
1919 "@types/bun": "^1.3.14",
20 "@types/k6": "^2.0.0",
2021 "drizzle-kit": "^0.31.10",
2122 "typescript": "^5.7.0",
2223 },
125126
126127 "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
127128
129 "@types/k6": ["@types/k6@2.0.0", "", {}, "sha512-ztO2fVOAQxtCpF6VWTn8O6FW6Z4DpjhY+XvD2GpCe/+fAfekD45bQ+vrM0B0C0c5ajLpVgWTMlLu9dKZ1nI+wg=="],
130
128131 "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
129132
130133 "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
Renameddrizzle/0065_auto_generate_tests.sqldrizzle/0077_auto_generate_tests.sql+0−0View fileUnifiedSplit
No textual changes.
Addeddrizzle/0077_discussion_categories.sql+25−0View fileUnifiedSplit
1-- Gluecron migration 0077: Discussion categories table.
2--
3-- Adds per-repo discussion_categories so discussions can be organised into
4-- named buckets (General, Q&A, Announcements, Ideas) rather than relying on
5-- a bare text enum. The existing discussions.category text column is kept for
6-- backwards-compatibility; new code reads categories from this table.
7--
8-- is_answerable = true means the category surfaces a "Mark as answer" button
9-- (GitHub's Q&A category behaviour).
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "discussion_categories" (
13 "id" serial PRIMARY KEY NOT NULL,
14 "repository_id" uuid NOT NULL,
15 "name" text NOT NULL,
16 "emoji" text NOT NULL DEFAULT '💬',
17 "description" text,
18 "is_answerable" boolean NOT NULL DEFAULT false,
19 CONSTRAINT "discussion_categories_repo_fk" FOREIGN KEY ("repository_id")
20 REFERENCES "repositories"("id") ON DELETE CASCADE
21);
22
23--> statement-breakpoint
24CREATE INDEX IF NOT EXISTS "discussion_categories_repo"
25 ON "discussion_categories" ("repository_id");
Addeddrizzle/0077_session_metadata.sql+7−0View fileUnifiedSplit
1-- Migration 0077: Add IP address, user-agent, and last-seen timestamp to sessions.
2-- Powers the /settings/sessions management page (SOC 2 session visibility).
3
4ALTER TABLE sessions
5 ADD COLUMN IF NOT EXISTS ip text,
6 ADD COLUMN IF NOT EXISTS user_agent text,
7 ADD COLUMN IF NOT EXISTS last_seen_at timestamp;
Addeddrizzle/0078_bot_user.sql+51−0View fileUnifiedSplit
1-- Migration 0078: Seed the gluecron[bot] synthetic user.
2--
3-- All autopilot / AI-review comments are credited to this row rather than
4-- the PR/issue author. The password_hash is deliberately empty so that no
5-- bcrypt comparison can ever succeed, making this account non-loginable.
6INSERT INTO users (
7 username,
8 email,
9 password_hash,
10 display_name,
11 bio,
12 is_admin,
13 notify_email_on_mention,
14 notify_email_on_assign,
15 notify_email_on_gate_fail,
16 notify_email_digest_weekly,
17 notify_email_on_pending_comment,
18 sleep_mode_enabled,
19 sleep_mode_digest_hour_utc,
20 notify_push_on_mention,
21 notify_push_on_assign,
22 notify_push_on_review_request,
23 notify_push_on_deploy_failed,
24 is_playground,
25 personal_semantic_index_enabled,
26 created_at,
27 updated_at
28) VALUES (
29 'gluecron[bot]',
30 'bot@gluecron.com',
31 '',
32 'Gluecron Bot',
33 'AI autopilot system',
34 false,
35 false,
36 false,
37 false,
38 false,
39 false,
40 false,
41 9,
42 false,
43 false,
44 false,
45 false,
46 false,
47 false,
48 NOW(),
49 NOW()
50)
51ON CONFLICT (username) DO NOTHING;
Addeddrizzle/0078_login_attempts.sql+17−0View fileUnifiedSplit
1-- Migration 0078: Login attempt tracking for account lockout (SOC 2 CC6.1).
2-- Records failed login attempts per email+IP. After 10 failures in 1 hour,
3-- auth.login.locked is emitted and logins from that email are blocked for 15 min.
4
5CREATE TABLE IF NOT EXISTS login_attempts (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 email text NOT NULL,
8 ip text NOT NULL,
9 success boolean NOT NULL DEFAULT false,
10 created_at timestamp NOT NULL DEFAULT now()
11);
12
13CREATE INDEX IF NOT EXISTS login_attempts_email_created
14 ON login_attempts (email, created_at);
15
16CREATE INDEX IF NOT EXISTS login_attempts_ip_created
17 ON login_attempts (ip, created_at);
Addeddrizzle/0079_sleep_digest_column.sql+5−0View fileUnifiedSplit
1-- Migration 0077 — L1 Sleep-mode digest column split.
2-- Splits the shared `last_digest_sent_at` anchor into two independent
3-- columns so the sleep-mode daily digest and the weekly digest maintain
4-- their own cooldown timers and no longer reset each other.
5ALTER TABLE users ADD COLUMN IF NOT EXISTS last_sleep_digest_sent_at timestamptz;
Addeddrizzle/0080_incidents_and_status_subscribers.sql+39−0View fileUnifiedSplit
1-- 0077 — Status page: incident history + subscriber list.
2--
3-- incidents: manually-filed or autopilot-detected outage records shown on
4-- the public /status page. severity: 'minor' | 'major' | 'critical'.
5-- status: 'investigating' | 'identified' | 'monitoring' | 'resolved'.
6--
7-- status_subscribers: email addresses that have opted-in to receive alerts
8-- when a new incident is filed. Confirmation token is single-use; once
9-- the user clicks the verify link confirmed_at is set.
10
11CREATE TABLE IF NOT EXISTS "incidents" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
13 "title" text NOT NULL,
14 "severity" text NOT NULL DEFAULT 'minor',
15 "status" text NOT NULL DEFAULT 'resolved',
16 "started_at" timestamptz NOT NULL DEFAULT now(),
17 "resolved_at" timestamptz,
18 "body" text,
19 "created_at" timestamptz NOT NULL DEFAULT now(),
20 "updated_at" timestamptz NOT NULL DEFAULT now()
21);
22
23CREATE INDEX IF NOT EXISTS "idx_incidents_started_at"
24 ON "incidents" ("started_at" DESC);
25
26CREATE INDEX IF NOT EXISTS "idx_incidents_status"
27 ON "incidents" ("status");
28
29CREATE TABLE IF NOT EXISTS "status_subscribers" (
30 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31 "email" text NOT NULL UNIQUE,
32 "confirmed_at" timestamptz,
33 "confirm_token" text UNIQUE,
34 "unsubscribe_token" text UNIQUE,
35 "created_at" timestamptz NOT NULL DEFAULT now()
36);
37
38CREATE INDEX IF NOT EXISTS "idx_status_subscribers_email"
39 ON "status_subscribers" ("email");
Addeddrizzle/0081_onboarding_emails.sql+8−0View fileUnifiedSplit
1-- Onboarding email drip sequence — tracks which drip emails each user has
2-- already received so they are never sent twice. The `emails_sent` jsonb
3-- column is a set of string keys (e.g. "welcome", "day1", "day3").
4--
5-- Strictly additive — no existing table or column is touched.
6
7ALTER TABLE "users"
8 ADD COLUMN IF NOT EXISTS "onboarding_emails_sent" jsonb NOT NULL DEFAULT '[]'::jsonb;
Addeddrizzle/0082_enterprise_leads.sql+16−0View fileUnifiedSplit
1-- Enterprise leads table — captures contact form submissions from /enterprise.
2-- Used to route sales conversations to the enterprise team.
3-- Strictly additive; no existing tables are touched.
4
5CREATE TABLE IF NOT EXISTS "enterprise_leads" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 "name" text NOT NULL,
8 "company" text NOT NULL,
9 "email" text NOT NULL,
10 "team_size" text NOT NULL,
11 "message" text,
12 "ip" text,
13 "created_at" timestamp DEFAULT now() NOT NULL
14);
15
16CREATE INDEX IF NOT EXISTS "enterprise_leads_created" ON "enterprise_leads" ("created_at");
Addeddrizzle/0083_data_region.sql+7−0View fileUnifiedSplit
1-- Migration 0077 — per-repo data region selector.
2-- Allows repository owners to opt into EU data residency (Frankfurt).
3-- Default is 'us'; 'eu' requires a Pro plan or higher (enforced in the
4-- repo-creation and repo-settings routes — the DB column itself is
5-- unconstrained so future regions (e.g. 'apac') can be added without
6-- a schema change).
7ALTER TABLE repositories ADD COLUMN IF NOT EXISTS data_region text NOT NULL DEFAULT 'us';
Addeddrizzle/0084_oci_registry.sql+36−0View fileUnifiedSplit
1-- OCI / Docker container registry tables (Block OCI-1)
2--
3-- oci_repositories — image namespaces, one per (owner, name) pair.
4-- name is the full "owner/image" string as used in `docker push`.
5--
6-- oci_tags — mutable tag → manifest-digest pointers, analogous to git refs.
7-- Each push that specifies a tag upserts the row so the tag always resolves
8-- to the most-recently-pushed manifest digest.
9--
10-- Blob files live on disk at ${OCI_STORE_PATH}/blobs/sha256/<hex> and are
11-- referenced only by digest; no DB row is needed for individual blobs.
12
13CREATE TABLE IF NOT EXISTS oci_repositories (
14 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15 owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16 name TEXT NOT NULL,
17 visibility TEXT NOT NULL DEFAULT 'private',
18 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19
20 CONSTRAINT oci_repositories_owner_name UNIQUE (owner_id, name)
21);
22
23CREATE INDEX IF NOT EXISTS idx_oci_repositories_owner ON oci_repositories (owner_id);
24
25CREATE TABLE IF NOT EXISTS oci_tags (
26 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
27 repository_id UUID NOT NULL REFERENCES oci_repositories(id) ON DELETE CASCADE,
28 tag TEXT NOT NULL,
29 manifest_digest TEXT NOT NULL,
30 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32
33 CONSTRAINT oci_tags_repo_tag UNIQUE (repository_id, tag)
34);
35
36CREATE INDEX IF NOT EXISTS idx_oci_tags_repo ON oci_tags (repository_id);
Addeddrizzle/0086_explain_cache.sql+10−0View fileUnifiedSplit
1-- Migration 0077: repo_explain_cache table
2-- Stores the structured AI analysis result (JSON) for the "Explain This Repo"
3-- feature. Keyed per-repo; replaced on regeneration.
4CREATE TABLE IF NOT EXISTS repo_explain_cache (
5 id serial PRIMARY KEY,
6 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
7 result jsonb NOT NULL,
8 created_at timestamp DEFAULT now(),
9 UNIQUE(repo_id)
10);
Addeddrizzle/0087_pr_preview_builder.sql+35−0View fileUnifiedSplit
1-- Migration 0077 — PR preview builder.
2--
3-- Adds per-repo preview build configuration to the repositories table.
4-- When preview_build_command is set, every PR push triggers a build (clone +
5-- run command + serve output). The resulting static files are served from
6-- /previews/:owner/:repo/:branch/* by the preview route handler.
7--
8-- Also creates pr_previews, a PR-scoped sibling to the existing branch_previews
9-- table (migration 0062). branch_previews tracks one row per branch (upserted on
10-- every push); pr_previews tracks one row per PR with richer build metadata
11-- (log, build time, command used) and a status that is updated after each build.
12
13-- ── Per-repo preview build config ──────────────────────────────────────────
14ALTER TABLE repositories ADD COLUMN IF NOT EXISTS preview_build_command text;
15ALTER TABLE repositories ADD COLUMN IF NOT EXISTS preview_output_dir text DEFAULT 'dist';
16
17-- ── pr_previews ─────────────────────────────────────────────────────────────
18CREATE TABLE IF NOT EXISTS pr_previews (
19 id serial PRIMARY KEY,
20 repo_id text NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
21 pr_id text NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
22 branch_name text NOT NULL,
23 head_sha text NOT NULL,
24 status text NOT NULL DEFAULT 'building', -- building | ready | failed
25 build_log text,
26 preview_url text,
27 build_command text,
28 output_dir text DEFAULT 'dist',
29 build_duration_ms integer,
30 created_at timestamp DEFAULT now(),
31 updated_at timestamp DEFAULT now()
32);
33
34CREATE INDEX IF NOT EXISTS pr_previews_pr_id_idx ON pr_previews(pr_id);
35CREATE INDEX IF NOT EXISTS pr_previews_repo_id_idx ON pr_previews(repo_id);
Addede2e/auth.spec.ts+167−0View fileUnifiedSplit
1/**
2 * E2E — Auth flows
3 *
4 * Covers: registration, login, logout, wrong password.
5 * Each test creates its own isolated user to avoid cross-test state.
6 */
7
8import { test, expect } from "@playwright/test";
9import { uid, TEST_PASSWORD } from "./fixtures";
10
11// ---------------------------------------------------------------------------
12// Register
13// ---------------------------------------------------------------------------
14
15test.describe("Registration", () => {
16 test("happy path — new user registers and lands on dashboard", async ({ page }) => {
17 const username = uid("reg");
18 const email = `${username}@test.example`;
19
20 await page.goto("/register");
21 await page.fill('input[name="username"]', username);
22 await page.fill('input[name="email"]', email);
23 await page.fill('input[name="password"]', TEST_PASSWORD);
24 await page.click('button[type="submit"]');
25
26 // Expect redirect away from /register
27 await expect(page).not.toHaveURL(/\/register/);
28
29 // Should surface the username somewhere on the page (nav, dashboard, etc.)
30 await expect(page.locator("body")).toContainText(username, { timeout: 8_000 });
31 });
32
33 test("duplicate username — shows error message", async ({ page }) => {
34 const username = uid("dup");
35 const email = `${username}@test.example`;
36
37 // Register once
38 await page.goto("/register");
39 await page.fill('input[name="username"]', username);
40 await page.fill('input[name="email"]', email);
41 await page.fill('input[name="password"]', TEST_PASSWORD);
42 await page.click('button[type="submit"]');
43 await page.waitForURL(/\/(dashboard|[a-z])/);
44
45 // Navigate away, then try registering again with same username
46 await page.goto("/register");
47 await page.fill('input[name="username"]', username);
48 await page.fill('input[name="email"]', `other-${email}`);
49 await page.fill('input[name="password"]', TEST_PASSWORD);
50 await page.click('button[type="submit"]');
51
52 // Should stay on /register (or show an error URL) with an error message
53 const body = page.locator("body");
54 await expect(body).toContainText(/already|taken|exists/i, { timeout: 5_000 });
55 });
56
57 test("missing fields — stays on register page", async ({ page }) => {
58 await page.goto("/register");
59 // Submit with no data — browser or server should block/redirect back
60 await page.click('button[type="submit"]');
61
62 // We either stay on /register or get an error param
63 const url = page.url();
64 const onRegisterOrError =
65 url.includes("/register") ||
66 url.includes("error") ||
67 (await page.locator(".auth-error, [role=alert]").count()) > 0;
68 expect(onRegisterOrError).toBeTruthy();
69 });
70});
71
72// ---------------------------------------------------------------------------
73// Login
74// ---------------------------------------------------------------------------
75
76test.describe("Login", () => {
77 test("happy path — existing user logs in", async ({ page }) => {
78 const username = uid("login");
79 const email = `${username}@test.example`;
80
81 // Pre-register
82 await page.goto("/register");
83 await page.fill('input[name="username"]', username);
84 await page.fill('input[name="email"]', email);
85 await page.fill('input[name="password"]', TEST_PASSWORD);
86 await page.click('button[type="submit"]');
87 await page.waitForURL(/\/(dashboard|[a-z])/);
88
89 // Logout then log back in
90 await page.goto("/logout");
91 await page.waitForURL(/\/(login|register|)/);
92
93 await page.goto("/login");
94 await page.fill('input[name="username"]', username);
95 await page.fill('input[name="password"]', TEST_PASSWORD);
96 await page.click('button[type="submit"]');
97
98 await expect(page).not.toHaveURL(/\/login/);
99 await expect(page.locator("body")).toContainText(username, { timeout: 8_000 });
100 });
101
102 test("wrong password — shows error, stays on login page", async ({ page }) => {
103 const username = uid("badpw");
104 const email = `${username}@test.example`;
105
106 // Pre-register
107 await page.goto("/register");
108 await page.fill('input[name="username"]', username);
109 await page.fill('input[name="email"]', email);
110 await page.fill('input[name="password"]', TEST_PASSWORD);
111 await page.click('button[type="submit"]');
112 await page.waitForURL(/\/(dashboard|[a-z])/);
113
114 // Logout
115 await page.goto("/logout");
116
117 // Try wrong password
118 await page.goto("/login");
119 await page.fill('input[name="username"]', username);
120 await page.fill('input[name="password"]', "WrongPassword999!");
121 await page.click('button[type="submit"]');
122
123 // Should stay on login or get an error
124 const body = page.locator("body");
125 await expect(body).toContainText(/invalid|incorrect|wrong|failed/i, {
126 timeout: 5_000,
127 });
128 });
129
130 test("unknown username — shows error", async ({ page }) => {
131 await page.goto("/login");
132 await page.fill('input[name="username"]', "totally_nonexistent_xyz_abc");
133 await page.fill('input[name="password"]', TEST_PASSWORD);
134 await page.click('button[type="submit"]');
135
136 const body = page.locator("body");
137 await expect(body).toContainText(/invalid|not found|incorrect/i, {
138 timeout: 5_000,
139 });
140 });
141});
142
143// ---------------------------------------------------------------------------
144// Logout
145// ---------------------------------------------------------------------------
146
147test.describe("Logout", () => {
148 test("logged-in user can log out and is redirected", async ({ page }) => {
149 const username = uid("lgout");
150 const email = `${username}@test.example`;
151
152 await page.goto("/register");
153 await page.fill('input[name="username"]', username);
154 await page.fill('input[name="email"]', email);
155 await page.fill('input[name="password"]', TEST_PASSWORD);
156 await page.click('button[type="submit"]');
157 await page.waitForURL(/\/(dashboard|[a-z])/);
158
159 // Logout
160 await page.goto("/logout");
161 await page.waitForURL(/\/(login|register|)/);
162
163 // Accessing a protected route should redirect to login
164 await page.goto("/settings");
165 await expect(page).toHaveURL(/\/login/);
166 });
167});
Addede2e/fixtures.ts+247−0View fileUnifiedSplit
1/**
2 * Shared test helpers for Gluecron E2E tests.
3 *
4 * All helpers accept a Playwright `Page` instance (or raw fetch for API calls)
5 * so they integrate naturally with Playwright's context/browser model.
6 */
7
8import { type Page } from "@playwright/test";
9import * as path from "path";
10import * as os from "os";
11import * as fs from "fs/promises";
12
13// ---------------------------------------------------------------------------
14// Constants
15// ---------------------------------------------------------------------------
16
17export const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000";
18
19/** Default password used for all test accounts. */
20export const TEST_PASSWORD = "TestPass123!";
21
22// ---------------------------------------------------------------------------
23// Unique ID generator — keeps test data isolated even with parallel shards.
24// ---------------------------------------------------------------------------
25
26let _seq = 0;
27export function uid(prefix = "u"): string {
28 _seq++;
29 return `${prefix}${Date.now()}${_seq}`;
30}
31
32// ---------------------------------------------------------------------------
33// Auth helpers
34// ---------------------------------------------------------------------------
35
36/**
37 * Registers a fresh user via the web UI and leaves the page logged in.
38 * Returns the username so callers can build repo URLs.
39 */
40export async function createTestUser(
41 page: Page,
42 prefix = "tuser"
43): Promise<string> {
44 const username = uid(prefix);
45 const email = `${username}@test.example`;
46
47 await page.goto("/register");
48 await page.fill('input[name="username"]', username);
49 await page.fill('input[name="email"]', email);
50 await page.fill('input[name="password"]', TEST_PASSWORD);
51 await page.click('button[type="submit"]');
52
53 // After successful registration, server redirects to /dashboard or similar.
54 await page.waitForURL(/\/(dashboard|[a-z])/);
55
56 return username;
57}
58
59/**
60 * Logs an existing user in via the web UI.
61 */
62export async function loginUser(
63 page: Page,
64 username: string,
65 password = TEST_PASSWORD
66): Promise<void> {
67 await page.goto("/login");
68 await page.fill('input[name="username"]', username);
69 await page.fill('input[name="password"]', password);
70 await page.click('button[type="submit"]');
71 await page.waitForURL(/\/(dashboard|[a-z])/);
72}
73
74/**
75 * Logs the current user out via the UI.
76 */
77export async function logoutUser(page: Page): Promise<void> {
78 // Try the nav logout link first; fall back to direct form POST.
79 const logoutLink = page.locator('a[href="/logout"]').first();
80 if (await logoutLink.isVisible()) {
81 await logoutLink.click();
82 } else {
83 await page.goto("/logout");
84 }
85 await page.waitForURL(/\/(login|register|$)/);
86}
87
88// ---------------------------------------------------------------------------
89// Repository helpers
90// ---------------------------------------------------------------------------
91
92/**
93 * Creates a repository via the web UI.
94 * Assumes the user is already logged in.
95 * Returns the repo name.
96 */
97export async function createTestRepo(
98 page: Page,
99 owner: string,
100 namePrefix = "repo"
101): Promise<string> {
102 const repoName = uid(namePrefix);
103
104 await page.goto("/new");
105 await page.fill('input[name="name"]', repoName);
106
107 // Optional description
108 const descField = page.locator('input[name="description"], textarea[name="description"]');
109 if (await descField.isVisible()) {
110 await descField.fill("E2E test repo");
111 }
112
113 await page.click('button[type="submit"]');
114
115 // Should redirect to the new repo page.
116 await page.waitForURL(new RegExp(`/${owner}/${repoName}`));
117
118 return repoName;
119}
120
121// ---------------------------------------------------------------------------
122// Git helpers
123// ---------------------------------------------------------------------------
124
125/**
126 * Clones a repo to a temporary directory, adds a file, and pushes back.
127 * Uses HTTP credentials (username + password) embedded in the URL.
128 *
129 * Returns the temp directory path (caller can clean up).
130 */
131export async function pushTestCommit(opts: {
132 owner: string;
133 repo: string;
134 username: string;
135 password?: string;
136 fileName?: string;
137 fileContent?: string;
138 commitMsg?: string;
139 branch?: string;
140}): Promise<string> {
141 const {
142 owner,
143 repo,
144 username,
145 password = TEST_PASSWORD,
146 fileName = "README.md",
147 fileContent = `# ${repo}\n\nE2E test commit.\n`,
148 commitMsg = "Add test file",
149 branch = "main",
150 } = opts;
151
152 const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gc-e2e-"));
153
154 const baseUrl = BASE_URL.replace("://", `://${username}:${encodeURIComponent(password)}@`);
155 const repoUrl = `${baseUrl}/${owner}/${repo}.git`;
156
157 // git init + set up remote
158 await spawnGit(tmpDir, ["init", "-b", branch]);
159 await spawnGit(tmpDir, ["remote", "add", "origin", repoUrl]);
160 await spawnGit(tmpDir, ["config", "user.email", `${username}@test.example`]);
161 await spawnGit(tmpDir, ["config", "user.name", username]);
162
163 // Write file
164 await fs.writeFile(path.join(tmpDir, fileName), fileContent, "utf8");
165
166 // Commit and push
167 await spawnGit(tmpDir, ["add", "."]);
168 await spawnGit(tmpDir, ["commit", "-m", commitMsg]);
169 await spawnGit(tmpDir, ["push", "-u", "origin", branch]);
170
171 return tmpDir;
172}
173
174/**
175 * Pushes a second commit on a new branch (useful for creating PRs).
176 * Returns the branch name.
177 */
178export async function pushFeatureBranch(opts: {
179 repoDir: string;
180 username: string;
181 branchName?: string;
182 fileName?: string;
183 fileContent?: string;
184 commitMsg?: string;
185}): Promise<string> {
186 const {
187 repoDir,
188 username,
189 branchName = uid("feat-"),
190 fileName = "feature.md",
191 fileContent = "Feature branch file.\n",
192 commitMsg = "Add feature file",
193 } = opts;
194
195 await spawnGit(repoDir, ["config", "user.email", `${username}@test.example`]);
196 await spawnGit(repoDir, ["config", "user.name", username]);
197 await spawnGit(repoDir, ["checkout", "-b", branchName]);
198
199 await fs.writeFile(path.join(repoDir, fileName), fileContent, "utf8");
200 await spawnGit(repoDir, ["add", "."]);
201 await spawnGit(repoDir, ["commit", "-m", commitMsg]);
202 await spawnGit(repoDir, ["push", "-u", "origin", branchName]);
203
204 return branchName;
205}
206
207// ---------------------------------------------------------------------------
208// Internal: spawn a git subprocess
209// ---------------------------------------------------------------------------
210
211async function spawnGit(cwd: string, args: string[]): Promise<string> {
212 const proc = Bun.spawn(["git", ...args], {
213 cwd,
214 env: {
215 ...process.env,
216 // Suppress interactive prompts
217 GIT_TERMINAL_PROMPT: "0",
218 GIT_ASKPASS: "echo",
219 },
220 stdout: "pipe",
221 stderr: "pipe",
222 });
223
224 const exitCode = await proc.exited;
225 const stdout = await new Response(proc.stdout).text();
226 const stderr = await new Response(proc.stderr).text();
227
228 if (exitCode !== 0) {
229 throw new Error(
230 `git ${args.join(" ")} failed (exit ${exitCode}):\n${stderr}\n${stdout}`
231 );
232 }
233
234 return stdout.trim();
235}
236
237// ---------------------------------------------------------------------------
238// Cleanup helper
239// ---------------------------------------------------------------------------
240
241export async function cleanupDir(dir: string): Promise<void> {
242 try {
243 await fs.rm(dir, { recursive: true, force: true });
244 } catch {
245 // Best effort
246 }
247}
Addede2e/issues.spec.ts+235−0View fileUnifiedSplit
1/**
2 * E2E — Issue tracker flows
3 *
4 * Covers: create issue, add comment, close issue, label management.
5 */
6
7import { test, expect } from "@playwright/test";
8import {
9 uid,
10 TEST_PASSWORD,
11 pushTestCommit,
12 cleanupDir,
13} from "./fixtures";
14
15// ---------------------------------------------------------------------------
16// Shared state — one user + one repo, one issue URL reused across suites.
17// ---------------------------------------------------------------------------
18
19let owner: string;
20let repoName: string;
21let tmpDir: string;
22/** URL of an issue we create in beforeAll for the comment/close/label tests. */
23let sharedIssueUrl: string;
24
25test.beforeAll(async ({ browser }) => {
26 owner = uid("issueuser");
27 repoName = uid("issuerepo");
28
29 const page = await browser.newPage();
30
31 // Register
32 await page.goto("/register");
33 await page.fill('input[name="username"]', owner);
34 await page.fill('input[name="email"]', `${owner}@test.example`);
35 await page.fill('input[name="password"]', TEST_PASSWORD);
36 await page.click('button[type="submit"]');
37 await page.waitForURL(/\/(dashboard|[a-z])/);
38
39 // Create repo
40 await page.goto("/new");
41 await page.fill('input[name="name"]', repoName);
42 await page.click('button[type="submit"]');
43 await page.waitForURL(new RegExp(`/${owner}/${repoName}`));
44
45 await page.close();
46
47 // Push a commit so the repo isn't empty (some routes need an existing HEAD)
48 tmpDir = await pushTestCommit({
49 owner,
50 repo: repoName,
51 username: owner,
52 password: TEST_PASSWORD,
53 fileName: "README.md",
54 fileContent: `# ${repoName}\n`,
55 commitMsg: "Initial commit",
56 });
57
58 // Create a shared issue via the web UI
59 const page2 = await browser.newPage();
60 await page2.goto("/login");
61 await page2.fill('input[name="username"]', owner);
62 await page2.fill('input[name="password"]', TEST_PASSWORD);
63 await page2.click('button[type="submit"]');
64 await page2.waitForURL(/\/(dashboard|[a-z])/);
65
66 await page2.goto(`/${owner}/${repoName}/issues/new`);
67 const titleInput = page2.locator('input[name="title"]');
68 await titleInput.waitFor({ timeout: 8_000 });
69 await titleInput.fill(`Shared issue ${uid("iss")}`);
70
71 const bodyArea = page2.locator('textarea[name="body"]');
72 if (await bodyArea.isVisible()) {
73 await bodyArea.fill("Issue body for shared E2E issue.");
74 }
75
76 await page2.click('button[type="submit"]');
77 await page2.waitForURL(new RegExp(`/${owner}/${repoName}/issues/\\d+`));
78 sharedIssueUrl = page2.url();
79 await page2.close();
80});
81
82test.afterAll(async () => {
83 await cleanupDir(tmpDir);
84});
85
86// ---------------------------------------------------------------------------
87// Helper
88// ---------------------------------------------------------------------------
89
90async function loginAsOwner(page: any): Promise<void> {
91 await page.goto("/login");
92 await page.fill('input[name="username"]', owner);
93 await page.fill('input[name="password"]', TEST_PASSWORD);
94 await page.click('button[type="submit"]');
95 await page.waitForURL(/\/(dashboard|[a-z])/);
96}
97
98// ---------------------------------------------------------------------------
99// Create issue
100// ---------------------------------------------------------------------------
101
102test.describe("Issue creation", () => {
103 test("issues list page is accessible", async ({ page }) => {
104 await page.goto(`/${owner}/${repoName}/issues`);
105 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/issues`));
106 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
107 });
108
109 test("can open new issue form", async ({ page }) => {
110 await loginAsOwner(page);
111 await page.goto(`/${owner}/${repoName}/issues/new`);
112 await expect(page.locator('input[name="title"]')).toBeVisible({ timeout: 8_000 });
113 });
114
115 test("can create a new issue", async ({ page }) => {
116 await loginAsOwner(page);
117 await page.goto(`/${owner}/${repoName}/issues/new`);
118
119 const titleInput = page.locator('input[name="title"]');
120 await titleInput.waitFor({ timeout: 8_000 });
121
122 const issueTitle = `New issue ${uid("ni")}`;
123 await titleInput.fill(issueTitle);
124
125 const bodyArea = page.locator('textarea[name="body"]');
126 if (await bodyArea.isVisible()) {
127 await bodyArea.fill("This is an E2E-created issue.");
128 }
129
130 await page.click('button[type="submit"]');
131
132 // Should redirect to the issue detail page
133 await expect(page).toHaveURL(
134 new RegExp(`/${owner}/${repoName}/issues/\\d+`)
135 );
136 await expect(page.locator("body")).toContainText(issueTitle);
137 });
138
139 test("issue appears in the issues list", async ({ page }) => {
140 await page.goto(`/${owner}/${repoName}/issues`);
141 // The shared issue created in beforeAll should be listed
142 await expect(page.locator("body")).toContainText(/Shared issue|issue/i, {
143 timeout: 8_000,
144 });
145 });
146});
147
148// ---------------------------------------------------------------------------
149// Comment on issue
150// ---------------------------------------------------------------------------
151
152test.describe("Issue comments", () => {
153 test("can post a comment on an issue", async ({ page }) => {
154 await loginAsOwner(page);
155 await page.goto(sharedIssueUrl);
156
157 const commentBox = page.locator(
158 'textarea[name="body"], textarea[name="comment"]'
159 ).first();
160 await expect(commentBox).toBeVisible({ timeout: 8_000 });
161
162 const commentText = `E2E comment ${uid("cm")}`;
163 await commentBox.fill(commentText);
164 await page.click('button[type="submit"]');
165
166 await expect(page.locator("body")).toContainText(commentText, {
167 timeout: 8_000,
168 });
169 });
170});
171
172// ---------------------------------------------------------------------------
173// Close issue
174// ---------------------------------------------------------------------------
175
176test.describe("Issue lifecycle", () => {
177 let closeIssueUrl: string;
178
179 test.beforeAll(async ({ browser }) => {
180 // Create a fresh issue just for the close test
181 const page = await browser.newPage();
182 await loginAsOwner(page);
183
184 await page.goto(`/${owner}/${repoName}/issues/new`);
185 const titleInput = page.locator('input[name="title"]');
186 await titleInput.waitFor({ timeout: 8_000 });
187 await titleInput.fill(`Close-me issue ${uid("cl")}`);
188 await page.click('button[type="submit"]');
189 await page.waitForURL(new RegExp(`/${owner}/${repoName}/issues/\\d+`));
190 closeIssueUrl = page.url();
191 await page.close();
192 });
193
194 test("can close an open issue", async ({ page }) => {
195 await loginAsOwner(page);
196 await page.goto(closeIssueUrl);
197
198 const closeBtn = page.locator(
199 'button:has-text("Close"), button[value="close"], form[action*="close"] button'
200 ).first();
201 await expect(closeBtn).toBeVisible({ timeout: 8_000 });
202 await closeBtn.click();
203
204 await expect(page.locator("body")).toContainText(/closed/i, {
205 timeout: 8_000,
206 });
207 });
208
209 test("can reopen a closed issue", async ({ page }) => {
210 await loginAsOwner(page);
211 await page.goto(closeIssueUrl);
212
213 const reopenBtn = page.locator(
214 'button:has-text("Reopen"), button[value="reopen"], form[action*="reopen"] button'
215 ).first();
216 await expect(reopenBtn).toBeVisible({ timeout: 8_000 });
217 await reopenBtn.click();
218
219 await expect(page.locator("body")).toContainText(/open/i, {
220 timeout: 8_000,
221 });
222 });
223});
224
225// ---------------------------------------------------------------------------
226// Labels
227// ---------------------------------------------------------------------------
228
229test.describe("Issue labels", () => {
230 test("labels page is accessible", async ({ page }) => {
231 await page.goto(`/${owner}/${repoName}/labels`);
232 // Page should load (may be empty if no labels yet)
233 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
234 });
235});
Addede2e/playwright.config.ts+45−0View fileUnifiedSplit
1import { defineConfig, devices } from "@playwright/test";
2
3/**
4 * Playwright E2E configuration.
5 *
6 * Targets a locally running Gluecron server at http://localhost:3000.
7 * Run the server first with `bun dev` or `bun start`, then: `bun run e2e`
8 */
9export default defineConfig({
10 testDir: "./",
11 testMatch: "**/*.spec.ts",
12
13 /* Maximum time one test can run. */
14 timeout: 30_000,
15
16 /* Fail the build on CI if you accidentally left `test.only`. */
17 forbidOnly: !!process.env.CI,
18
19 /* No retries in CI — flaky tests should be fixed, not hidden. */
20 retries: process.env.CI ? 0 : 0,
21
22 /* Run tests serially by default to avoid auth/DB contention. */
23 workers: process.env.CI ? 1 : 1,
24
25 /* Reporter */
26 reporter: process.env.CI
27 ? [["github"], ["list"]]
28 : [["list"], ["html", { open: "never" }]],
29
30 use: {
31 baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
32 /* Collect trace on first retry to ease debugging. */
33 trace: "on-first-retry",
34 /* Give each navigation a generous budget. */
35 navigationTimeout: 15_000,
36 actionTimeout: 10_000,
37 },
38
39 projects: [
40 {
41 name: "chromium",
42 use: { ...devices["Desktop Chrome"] },
43 },
44 ],
45});
Addede2e/pulls.spec.ts+264−0View fileUnifiedSplit
1/**
2 * E2E — Pull request flows
3 *
4 * Covers: create PR, add comment, merge PR, close PR.
5 *
6 * Strategy: beforeAll pushes an initial commit to main, then pushes a
7 * feature branch. We create a PR from the feature branch against main
8 * and exercise the PR lifecycle within a single spec run.
9 */
10
11import { test, expect } from "@playwright/test";
12import {
13 uid,
14 TEST_PASSWORD,
15 pushTestCommit,
16 pushFeatureBranch,
17 cleanupDir,
18} from "./fixtures";
19
20// ---------------------------------------------------------------------------
21// Shared state
22// ---------------------------------------------------------------------------
23
24let owner: string;
25let repoName: string;
26let featureBranch: string;
27let tmpDir: string;
28
29test.beforeAll(async ({ browser }) => {
30 owner = uid("pruser");
31 repoName = uid("prerepo");
32 featureBranch = uid("feat-");
33
34 const page = await browser.newPage();
35
36 // Register
37 await page.goto("/register");
38 await page.fill('input[name="username"]', owner);
39 await page.fill('input[name="email"]', `${owner}@test.example`);
40 await page.fill('input[name="password"]', TEST_PASSWORD);
41 await page.click('button[type="submit"]');
42 await page.waitForURL(/\/(dashboard|[a-z])/);
43
44 // Create repo
45 await page.goto("/new");
46 await page.fill('input[name="name"]', repoName);
47 await page.click('button[type="submit"]');
48 await page.waitForURL(new RegExp(`/${owner}/${repoName}`));
49
50 await page.close();
51
52 // Push initial commit to main
53 tmpDir = await pushTestCommit({
54 owner,
55 repo: repoName,
56 username: owner,
57 password: TEST_PASSWORD,
58 fileName: "README.md",
59 fileContent: `# ${repoName}\n`,
60 commitMsg: "Initial commit",
61 });
62
63 // Push feature branch
64 featureBranch = await pushFeatureBranch({
65 repoDir: tmpDir,
66 username: owner,
67 branchName: featureBranch,
68 fileName: "feature.md",
69 fileContent: "Feature branch content.\n",
70 commitMsg: "Add feature file",
71 });
72});
73
74test.afterAll(async () => {
75 await cleanupDir(tmpDir);
76});
77
78// ---------------------------------------------------------------------------
79// Helper: log in the page as owner
80// ---------------------------------------------------------------------------
81
82async function loginAsOwner(page: Parameters<typeof test>[1] extends never ? never : any): Promise<void> {
83 await page.goto("/login");
84 await page.fill('input[name="username"]', owner);
85 await page.fill('input[name="password"]', TEST_PASSWORD);
86 await page.click('button[type="submit"]');
87 await page.waitForURL(/\/(dashboard|[a-z])/);
88}
89
90// ---------------------------------------------------------------------------
91// Create PR
92// ---------------------------------------------------------------------------
93
94test.describe("Pull request creation", () => {
95 test("PR list page is accessible", async ({ page }) => {
96 await page.goto(`/${owner}/${repoName}/pulls`);
97 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/pulls`));
98 // Even if empty, the page should render without error
99 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
100 });
101
102 test("can open a new PR from the compare page", async ({ page }) => {
103 await loginAsOwner(page);
104
105 // Navigate to compare page with the feature branch
106 await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`);
107
108 // Should show a PR creation form
109 const titleInput = page.locator('input[name="title"]');
110 await expect(titleInput).toBeVisible({ timeout: 8_000 });
111
112 // Fill in PR details
113 const prTitle = `E2E PR ${uid("pr")}`;
114 await titleInput.fill(prTitle);
115
116 await page.click('button[type="submit"]');
117
118 // Should redirect to the new PR
119 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`));
120 await expect(page.locator("body")).toContainText(prTitle);
121 });
122});
123
124// ---------------------------------------------------------------------------
125// PR comment
126// ---------------------------------------------------------------------------
127
128test.describe("Pull request comment", () => {
129 let prUrl: string;
130
131 test.beforeAll(async ({ browser }) => {
132 // Create a PR programmatically so we have a URL to comment on
133 const page = await browser.newPage();
134 await loginAsOwner(page);
135
136 await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`);
137 const titleInput = page.locator('input[name="title"]');
138 await titleInput.waitFor({ timeout: 8_000 });
139 await titleInput.fill(`Comment-test PR ${uid("c")}`);
140 await page.click('button[type="submit"]');
141 await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`));
142 prUrl = page.url();
143 await page.close();
144 });
145
146 test("can post a comment on a PR", async ({ page }) => {
147 await loginAsOwner(page);
148 await page.goto(prUrl);
149
150 const commentBox = page.locator(
151 'textarea[name="body"], textarea[name="comment"]'
152 ).first();
153 await expect(commentBox).toBeVisible({ timeout: 8_000 });
154
155 const commentText = `Test comment ${uid("cmt")}`;
156 await commentBox.fill(commentText);
157 await page.click('button[type="submit"]');
158
159 await expect(page.locator("body")).toContainText(commentText, {
160 timeout: 8_000,
161 });
162 });
163});
164
165// ---------------------------------------------------------------------------
166// Merge PR
167// ---------------------------------------------------------------------------
168
169test.describe("Pull request merge", () => {
170 let prUrl: string;
171
172 test.beforeAll(async ({ browser }) => {
173 const page = await browser.newPage();
174 await loginAsOwner(page);
175
176 await page.goto(`/${owner}/${repoName}/compare/main...${featureBranch}`);
177 const titleInput = page.locator('input[name="title"]');
178 await titleInput.waitFor({ timeout: 8_000 });
179 await titleInput.fill(`Merge-test PR ${uid("m")}`);
180 await page.click('button[type="submit"]');
181 await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`));
182 prUrl = page.url();
183 await page.close();
184 });
185
186 test("merge button is visible on an open PR", async ({ page }) => {
187 await loginAsOwner(page);
188 await page.goto(prUrl);
189
190 // The merge button may be disabled if checks are pending — just check visible
191 const mergeBtn = page.locator(
192 'button:has-text("Merge"), button[name="action"][value="merge"], form[action*="merge"] button'
193 ).first();
194 await expect(mergeBtn).toBeVisible({ timeout: 8_000 });
195 });
196
197 test("can merge an open PR", async ({ page }) => {
198 await loginAsOwner(page);
199 await page.goto(prUrl);
200
201 const mergeBtn = page.locator(
202 'button:has-text("Merge"), button[name="action"][value="merge"], form[action*="merge"] button'
203 ).first();
204
205 // Only click if enabled — if branch-protection blocks it, skip gracefully
206 if (await mergeBtn.isEnabled()) {
207 await mergeBtn.click();
208 await expect(page.locator("body")).toContainText(/merged/i, {
209 timeout: 10_000,
210 });
211 } else {
212 test.skip();
213 }
214 });
215});
216
217// ---------------------------------------------------------------------------
218// Close PR
219// ---------------------------------------------------------------------------
220
221test.describe("Pull request close", () => {
222 let prUrl: string;
223
224 test.beforeAll(async ({ browser }) => {
225 // Push another unique branch so we have an un-merged PR to close
226 const closeBranch = uid("close-");
227 await pushFeatureBranch({
228 repoDir: tmpDir,
229 username: owner,
230 branchName: closeBranch,
231 fileName: `close-${uid()}.md`,
232 fileContent: "Close branch file.\n",
233 commitMsg: "Add close-branch file",
234 });
235
236 const page = await browser.newPage();
237 await loginAsOwner(page);
238
239 await page.goto(`/${owner}/${repoName}/compare/main...${closeBranch}`);
240 const titleInput = page.locator('input[name="title"]');
241 await titleInput.waitFor({ timeout: 8_000 });
242 await titleInput.fill(`Close-test PR ${uid("cl")}`);
243 await page.click('button[type="submit"]');
244 await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+`));
245 prUrl = page.url();
246 await page.close();
247 });
248
249 test("can close an open PR", async ({ page }) => {
250 await loginAsOwner(page);
251 await page.goto(prUrl);
252
253 // Close button — server may render it as a form submit or link
254 const closeBtn = page.locator(
255 'button:has-text("Close"), button[value="close"], form[action*="close"] button'
256 ).first();
257 await expect(closeBtn).toBeVisible({ timeout: 8_000 });
258 await closeBtn.click();
259
260 await expect(page.locator("body")).toContainText(/closed/i, {
261 timeout: 8_000,
262 });
263 });
264});
Addede2e/repo.spec.ts+175−0View fileUnifiedSplit
1/**
2 * E2E — Repository flows
3 *
4 * Covers: create repo, push first commit, file browser shows files,
5 * commit list populated, README renders.
6 */
7
8import { test, expect } from "@playwright/test";
9import {
10 uid,
11 TEST_PASSWORD,
12 pushTestCommit,
13 pushFeatureBranch,
14 cleanupDir,
15} from "./fixtures";
16
17// ---------------------------------------------------------------------------
18// Shared state — one user + one repo for the whole suite.
19// ---------------------------------------------------------------------------
20
21let owner: string;
22let repoName: string;
23let tmpDir: string;
24
25test.beforeAll(async ({ browser }) => {
26 owner = uid("repouser");
27 repoName = uid("myrepo");
28
29 const page = await browser.newPage();
30
31 // Register the test user
32 await page.goto("/register");
33 await page.fill('input[name="username"]', owner);
34 await page.fill('input[name="email"]', `${owner}@test.example`);
35 await page.fill('input[name="password"]', TEST_PASSWORD);
36 await page.click('button[type="submit"]');
37 await page.waitForURL(/\/(dashboard|[a-z])/);
38
39 // Create the repository via the web UI
40 await page.goto("/new");
41 await page.fill('input[name="name"]', repoName);
42 await page.click('button[type="submit"]');
43 await page.waitForURL(new RegExp(`/${owner}/${repoName}`));
44
45 await page.close();
46
47 // Push an initial commit via git CLI
48 tmpDir = await pushTestCommit({
49 owner,
50 repo: repoName,
51 username: owner,
52 password: TEST_PASSWORD,
53 fileName: "README.md",
54 fileContent: `# ${repoName}\n\nThis is the E2E test repo.\n`,
55 commitMsg: "Initial commit",
56 });
57});
58
59test.afterAll(async () => {
60 await cleanupDir(tmpDir);
61});
62
63// ---------------------------------------------------------------------------
64// Repository creation
65// ---------------------------------------------------------------------------
66
67test.describe("Repository creation", () => {
68 test("new repo page is accessible while logged in", async ({ page }) => {
69 // Log in fresh for this test
70 await page.goto("/login");
71 await page.fill('input[name="username"]', owner);
72 await page.fill('input[name="password"]', TEST_PASSWORD);
73 await page.click('button[type="submit"]');
74 await page.waitForURL(/\/(dashboard|[a-z])/);
75
76 await page.goto("/new");
77 await expect(page.locator('input[name="name"]')).toBeVisible();
78 });
79
80 test("created repo homepage is publicly accessible", async ({ page }) => {
81 await page.goto(`/${owner}/${repoName}`);
82 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}`));
83 // Page should mention the repo name
84 await expect(page.locator("body")).toContainText(repoName);
85 });
86});
87
88// ---------------------------------------------------------------------------
89// File browser
90// ---------------------------------------------------------------------------
91
92test.describe("File browser", () => {
93 test("README.md appears in the file listing", async ({ page }) => {
94 await page.goto(`/${owner}/${repoName}`);
95 await expect(page.locator("body")).toContainText("README.md", {
96 timeout: 8_000,
97 });
98 });
99
100 test("README.md content is rendered on repo homepage", async ({ page }) => {
101 await page.goto(`/${owner}/${repoName}`);
102 // The repo README says "E2E test repo"
103 await expect(page.locator("body")).toContainText("E2E test repo", {
104 timeout: 8_000,
105 });
106 });
107
108 test("clicking a file opens the blob view", async ({ page }) => {
109 await page.goto(`/${owner}/${repoName}`);
110 // Click the README link in the file table
111 await page.click('a[href*="README.md"]');
112 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/blob`));
113 await expect(page.locator("body")).toContainText("E2E test repo");
114 });
115});
116
117// ---------------------------------------------------------------------------
118// Commit list
119// ---------------------------------------------------------------------------
120
121test.describe("Commit list", () => {
122 test("commits page lists the initial commit", async ({ page }) => {
123 await page.goto(`/${owner}/${repoName}/commits`);
124 await expect(page.locator("body")).toContainText("Initial commit", {
125 timeout: 8_000,
126 });
127 });
128
129 test("commit detail page loads", async ({ page }) => {
130 await page.goto(`/${owner}/${repoName}/commits`);
131 // Click the first commit link
132 const commitLink = page.locator('a[href*="/commit/"]').first();
133 await commitLink.click();
134 await expect(page).toHaveURL(new RegExp(`/${owner}/${repoName}/commit/`));
135 // Should show the diff or commit message
136 await expect(page.locator("body")).toContainText(/Initial commit|README/i);
137 });
138});
139
140// ---------------------------------------------------------------------------
141// Branch support
142// ---------------------------------------------------------------------------
143
144test.describe("Branches", () => {
145 test("branches page lists main branch", async ({ page }) => {
146 await page.goto(`/${owner}/${repoName}/branches`);
147 await expect(page.locator("body")).toContainText(/main|master/, {
148 timeout: 8_000,
149 });
150 });
151});
152
153// ---------------------------------------------------------------------------
154// Additional repo — invalid names
155// ---------------------------------------------------------------------------
156
157test.describe("Repo creation validation", () => {
158 test("invalid repo name shows error", async ({ page }) => {
159 // Log in
160 await page.goto("/login");
161 await page.fill('input[name="username"]', owner);
162 await page.fill('input[name="password"]', TEST_PASSWORD);
163 await page.click('button[type="submit"]');
164 await page.waitForURL(/\/(dashboard|[a-z])/);
165
166 await page.goto("/new");
167 // Put a name with spaces/special chars that the validator rejects
168 await page.fill('input[name="name"]', "invalid repo name!");
169 await page.click('button[type="submit"]');
170
171 // Server should send back an error
172 const body = page.locator("body");
173 await expect(body).toContainText(/invalid|error/i, { timeout: 5_000 });
174 });
175});
Addede2e/settings.spec.ts+211−0View fileUnifiedSplit
1/**
2 * E2E — User settings flows
3 *
4 * Covers: update profile display name/bio, add SSH key, create API token.
5 */
6
7import { test, expect } from "@playwright/test";
8import { uid, TEST_PASSWORD } from "./fixtures";
9
10// ---------------------------------------------------------------------------
11// Shared state — one user for all settings tests
12// ---------------------------------------------------------------------------
13
14let settingsUser: string;
15
16test.beforeAll(async ({ browser }) => {
17 settingsUser = uid("settingsuser");
18
19 const page = await browser.newPage();
20 await page.goto("/register");
21 await page.fill('input[name="username"]', settingsUser);
22 await page.fill('input[name="email"]', `${settingsUser}@test.example`);
23 await page.fill('input[name="password"]', TEST_PASSWORD);
24 await page.click('button[type="submit"]');
25 await page.waitForURL(/\/(dashboard|[a-z])/);
26 await page.close();
27});
28
29// ---------------------------------------------------------------------------
30// Helper
31// ---------------------------------------------------------------------------
32
33async function loginAsSettingsUser(page: any): Promise<void> {
34 await page.goto("/login");
35 await page.fill('input[name="username"]', settingsUser);
36 await page.fill('input[name="password"]', TEST_PASSWORD);
37 await page.click('button[type="submit"]');
38 await page.waitForURL(/\/(dashboard|[a-z])/);
39}
40
41// ---------------------------------------------------------------------------
42// Profile settings
43// ---------------------------------------------------------------------------
44
45test.describe("Profile settings", () => {
46 test("settings page loads for logged-in user", async ({ page }) => {
47 await loginAsSettingsUser(page);
48 await page.goto("/settings");
49 await expect(page).toHaveURL(/\/settings/);
50 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
51 });
52
53 test("can update display name / bio", async ({ page }) => {
54 await loginAsSettingsUser(page);
55 await page.goto("/settings");
56
57 // Look for a display-name or bio field — either may exist
58 const displayNameField = page.locator(
59 'input[name="displayName"], input[name="name"], input[name="display_name"]'
60 ).first();
61 const bioField = page.locator('textarea[name="bio"]').first();
62
63 let updated = false;
64
65 if (await displayNameField.isVisible()) {
66 await displayNameField.fill(`E2E User ${uid("dn")}`);
67 updated = true;
68 }
69
70 if (await bioField.isVisible()) {
71 await bioField.fill("E2E automated bio update.");
72 updated = true;
73 }
74
75 if (updated) {
76 await page.click('button[type="submit"]');
77 // Should stay on settings (or show success flash)
78 await expect(page).toHaveURL(/\/settings/);
79 await expect(page.locator("body")).not.toContainText(/500|error/i);
80 } else {
81 // If no profile fields found, just ensure the page rendered
82 test.skip();
83 }
84 });
85
86 test("unauthenticated access to /settings redirects to login", async ({ page }) => {
87 // Don't log in
88 await page.goto("/settings");
89 await expect(page).toHaveURL(/\/login/);
90 });
91});
92
93// ---------------------------------------------------------------------------
94// SSH keys
95// ---------------------------------------------------------------------------
96
97test.describe("SSH keys", () => {
98 test("SSH keys settings page is accessible", async ({ page }) => {
99 await loginAsSettingsUser(page);
100 // SSH keys may be at /settings or /settings/keys or /settings/ssh-keys
101 await page.goto("/settings");
102
103 const sshLink = page.locator('a[href*="ssh"], a[href*="keys"]').first();
104 if (await sshLink.isVisible()) {
105 await sshLink.click();
106 } else {
107 // Navigate directly
108 await page.goto("/settings/keys");
109 }
110
111 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
112 });
113
114 test("can add a new SSH key", async ({ page }) => {
115 await loginAsSettingsUser(page);
116 await page.goto("/settings");
117
118 // Find the SSH key form — may be on main settings or a sub-page
119 let keyTitleInput = page.locator('input[name="title"][placeholder*="key" i], input[name="name"][placeholder*="key" i]').first();
120 let keyValueArea = page.locator('textarea[name="key"], textarea[name="publicKey"], textarea[name="public_key"]').first();
121
122 // If not on this page, try /settings/keys
123 if (!(await keyTitleInput.isVisible())) {
124 await page.goto("/settings/keys");
125 keyTitleInput = page.locator('input[name="title"], input[name="name"]').first();
126 keyValueArea = page.locator('textarea[name="key"], textarea[name="publicKey"], textarea[name="public_key"]').first();
127 }
128
129 if (!(await keyTitleInput.isVisible()) || !(await keyValueArea.isVisible())) {
130 // Settings structure differs — skip rather than fail
131 test.skip();
132 return;
133 }
134
135 // Use a valid-looking (but fake) RSA public key
136 const fakeKey =
137 "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7P+VVwfW4NbV9sQBDZhkVHqjSqM" +
138 "xD3k8sZNzKq6JDvWmLRb5pEXxXYtK4t2HGaVfCmWKr7eQT9t7WPb5m6U3vNqDqZH" +
139 "JXHF7jkGxLr9zUDAbcXYtM+5R5h1rFqPJvWq1y2M8sNaWvBb9P5mBJtWNqJqMLDE" +
140 `5EsC3A8= e2e-test-key-${uid("k")}`;
141
142 await keyTitleInput.fill(`E2E test key ${uid("kt")}`);
143 await keyValueArea.fill(fakeKey);
144
145 await page.click('button[type="submit"]');
146
147 // Server may accept or reject the fake key — either way no 500
148 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
149 });
150});
151
152// ---------------------------------------------------------------------------
153// Personal Access Tokens (API tokens)
154// ---------------------------------------------------------------------------
155
156test.describe("Personal access tokens", () => {
157 test("tokens settings page is accessible", async ({ page }) => {
158 await loginAsSettingsUser(page);
159 await page.goto("/settings/tokens");
160 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
161 });
162
163 test("can create a new API token", async ({ page }) => {
164 await loginAsSettingsUser(page);
165 await page.goto("/settings/tokens");
166
167 const tokenNameInput = page.locator(
168 'input[name="name"], input[name="tokenName"], input[placeholder*="token" i]'
169 ).first();
170
171 if (!(await tokenNameInput.isVisible())) {
172 test.skip();
173 return;
174 }
175
176 const tokenName = `e2e-token-${uid("tok")}`;
177 await tokenNameInput.fill(tokenName);
178
179 // Some UIs have a scopes checkbox or expiry field — skip if complex
180 const submitBtn = page.locator('button[type="submit"]').first();
181 await submitBtn.click();
182
183 // The token value should be shown once, or at least the name appears in the list
184 const body = page.locator("body");
185 const tokenVisible =
186 (await body.textContent())?.includes(tokenName) ||
187 (await body.textContent())?.includes("token");
188 expect(tokenVisible).toBeTruthy();
189 });
190
191 test("can delete an existing token", async ({ page }) => {
192 await loginAsSettingsUser(page);
193 await page.goto("/settings/tokens");
194
195 // Only attempt deletion if there is a delete button visible
196 const deleteBtn = page.locator(
197 'button:has-text("Delete"), button:has-text("Revoke"), button[value="delete"]'
198 ).first();
199
200 if (!(await deleteBtn.isVisible())) {
201 // Nothing to delete — skip
202 test.skip();
203 return;
204 }
205
206 await deleteBtn.click();
207
208 // Page should refresh without error
209 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
210 });
211});
Addedjetbrains-plugin/.gitignore+23−0View fileUnifiedSplit
1# Gradle build artifacts
2build/
3.gradle/
4
5# Compiled JVM bytecode
6*.class
7*.jar
8
9# IntelliJ IDEA project files (generated by Gradle plugin during development)
10.idea/
11*.iml
12*.iws
13*.ipr
14
15# Kotlin incremental compilation cache
16.kotlin/
17
18# macOS
19.DS_Store
20
21# Local Gradle properties (may contain credentials)
22local.properties
23gradle-local.properties
Addedjetbrains-plugin/README.md+115−0View fileUnifiedSplit
1# Gluecron JetBrains Plugin
2
3IntelliJ Platform plugin for [Gluecron](https://gluecron.com) — AI-native code intelligence.
4
5Works in **IntelliJ IDEA**, **WebStorm**, **GoLand**, **PyCharm**, and any other JetBrains IDE
6based on the IntelliJ Platform 2023.1+.
7
8## Features
9
10| Action | Location | What it does |
11|--------|----------|--------------|
12| **Open PR List** | Gluecron menu / VCS Operations | Opens `{host}/{owner}/{repo}/pulls` in your browser |
13| **Create Issue** | Gluecron menu / VCS Operations | Opens `{host}/{owner}/{repo}/issues/new` in your browser |
14| **Merge PR for Current Branch** | Gluecron menu / VCS Operations | POSTs to the Gluecron API to merge the open PR for the checked-out branch |
15| **View Repo Health** | Gluecron menu / VCS Operations | Opens `{host}/{owner}/{repo}/health` in your browser |
16
17The plugin detects `owner/repo` automatically from your project's git remote URL.
18
19## Requirements
20
21- JDK 17+
22- Gradle 8.x (the Gradle wrapper `gradlew` is the recommended way to build)
23- A Gluecron server (self-hosted or [gluecron.com](https://gluecron.com))
24- A personal access token with `repo` scope (generate one at `{host}/settings/tokens`)
25
26## Building
27
28```bash
29# Clone the repository
30git clone https://gluecron.com/ccantynz/Gluecron.com.git
31cd Gluecron.com/jetbrains-plugin
32
33# Build the plugin zip (output: build/distributions/gluecron-jetbrains-0.1.0.zip)
34./gradlew buildPlugin
35
36# Run the plugin in a sandboxed IDE for development
37./gradlew runIde
38```
39
40The `buildPlugin` task produces a zip archive at:
41
42```
43build/distributions/gluecron-jetbrains-<version>.zip
44```
45
46## Installing the Plugin
47
48### From local zip (manual install)
49
501. Open your JetBrains IDE.
512. Go to **Settings → Plugins → ⚙ → Install Plugin from Disk…**
523. Select `build/distributions/gluecron-jetbrains-0.1.0.zip`.
534. Restart the IDE when prompted.
54
55### From JetBrains Marketplace (once published)
56
57Search for **"Gluecron"** in **Settings → Plugins → Marketplace**.
58
59## Configuration
60
61After installation, configure the plugin:
62
631. Open **Settings → Tools → Gluecron**.
642. Set **Server URL** to your Gluecron instance (e.g. `https://gluecron.com`).
653. Set **Access Token** to a personal access token generated at `{host}/settings/tokens`.
66
67Alternatively, set environment variables before launching your IDE:
68
69```bash
70export GLUECRON_HOST=https://gluecron.com
71export GLUECRON_TOKEN=glc_your_token_here
72```
73
74The plugin reads these on startup and pre-fills the settings if they are not yet configured.
75
76## Project Structure
77
78```
79jetbrains-plugin/
80 build.gradle.kts Gradle build — IntelliJ Platform Plugin 1.x
81 settings.gradle.kts Root project name
82 gradle.properties plugin.id, plugin.version, platform.version
83 src/main/
84 resources/META-INF/plugin.xml Plugin manifest (actions, extensions)
85 kotlin/com/gluecron/
86 GluecronPlugin.kt Startup activity (env var seeding, welcome notification)
87 GluecronUtil.kt Shared helpers (git remote detection, browser, API)
88 actions/
89 OpenPrAction.kt Opens PR list in browser
90 CreateIssueAction.kt Opens new issue form in browser
91 MergePrAction.kt Calls Gluecron API to merge current branch's PR
92 ViewHealthAction.kt Opens repo health dashboard in browser
93 settings/
94 GluecronSettingsState.kt PersistentStateComponent (host + token)
95 GluecronSettingsConfigurable.kt Settings UI (two text fields)
96```
97
98## Merge PR — API details
99
100`MergePrAction` sends:
101
102```
103POST {host}/api/repos/{owner}/{repo}/pulls/merge
104Authorization: Bearer {token}
105Content-Type: application/json
106
107{"head": "<current-branch>", "merge_method": "merge"}
108```
109
110A confirmation dialog is shown before the request is sent. The action
111requires a valid access token to be configured.
112
113## License
114
115MIT — see the root `LICENSE` file.
Addedjetbrains-plugin/build.gradle.kts+52−0View fileUnifiedSplit
1plugins {
2 id("java")
3 id("org.jetbrains.kotlin.jvm") version "1.9.21"
4 id("org.jetbrains.intellij") version "1.16.1"
5}
6
7group = "com.gluecron"
8version = "0.1.0"
9
10repositories {
11 mavenCentral()
12}
13
14// Configure IntelliJ Platform Plugin Gradle Plugin 1.x
15intellij {
16 pluginName.set("Gluecron")
17 version.set(project.property("platform.version").toString())
18 type.set("IC") // IntelliJ IDEA Community Edition
19 plugins.set(listOf("git4idea"))
20}
21
22kotlin {
23 jvmToolchain(17)
24}
25
26tasks {
27 buildSearchableOptions {
28 enabled = false
29 }
30
31 patchPluginXml {
32 sinceBuild.set("231") // 2023.1
33 untilBuild.set("251.*") // 2025.1.*
34 changeNotes.set(
35 """
36 <ul>
37 <li>0.1.0: Initial release — Open PR, Create Issue, Merge PR, View Health</li>
38 </ul>
39 """.trimIndent()
40 )
41 }
42
43 signPlugin {
44 certificateChain.set(System.getenv("CERTIFICATE_CHAIN") ?: "")
45 privateKey.set(System.getenv("PRIVATE_KEY") ?: "")
46 password.set(System.getenv("PRIVATE_KEY_PASSWORD") ?: "")
47 }
48
49 publishPlugin {
50 token.set(System.getenv("PUBLISH_TOKEN") ?: "")
51 }
52}
Addedjetbrains-plugin/gradle.properties+11−0View fileUnifiedSplit
1# Plugin metadata
2plugin.id=com.gluecron.jetbrains
3plugin.version=0.1.0
4
5# IntelliJ Platform version to build against (2023.1)
6platform.version=2023.1
7
8# Gradle settings
9org.gradle.caching=true
10org.gradle.parallel=true
11kotlin.incremental=true
Addedjetbrains-plugin/settings.gradle.kts+1−0View fileUnifiedSplit
1rootProject.name = "gluecron-jetbrains"
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/GluecronPlugin.kt+48−0View fileUnifiedSplit
1package com.gluecron
2
3import com.gluecron.settings.GluecronSettingsState
4import com.intellij.notification.NotificationGroupManager
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.project.Project
7import com.intellij.openapi.startup.StartupActivity
8
9/**
10 * Post-startup activity for the Gluecron plugin.
11 *
12 * Runs once per project open, after the IDE has fully initialized.
13 * Responsibilities:
14 * 1. Seed settings from environment variables if not already configured
15 * (GLUECRON_HOST → host, GLUECRON_TOKEN → token).
16 * 2. Emit a notification if the host is still at the default localhost
17 * value so new users know to configure the plugin.
18 */
19class GluecronPlugin : StartupActivity {
20
21 override fun runActivity(project: Project) {
22 val settings = GluecronSettingsState.getInstance()
23
24 // Seed from environment variables on first run
25 val envHost = System.getenv("GLUECRON_HOST")
26 if (!envHost.isNullOrBlank() && settings.host == "http://localhost:3000") {
27 settings.host = envHost
28 }
29
30 val envToken = System.getenv("GLUECRON_TOKEN")
31 if (!envToken.isNullOrBlank() && settings.token.isBlank()) {
32 settings.token = envToken
33 }
34
35 // Warn if still using the default localhost address
36 if (settings.host == "http://localhost:3000" && settings.token.isBlank()) {
37 NotificationGroupManager.getInstance()
38 .getNotificationGroup("Gluecron Notifications")
39 .createNotification(
40 "Gluecron",
41 "Configure your Gluecron server URL and access token in " +
42 "<b>Settings → Tools → Gluecron</b>.",
43 NotificationType.INFORMATION
44 )
45 .notify(project)
46 }
47 }
48}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/GluecronUtil.kt+142−0View fileUnifiedSplit
1package com.gluecron
2
3import com.gluecron.settings.GluecronSettingsState
4import com.intellij.notification.NotificationGroupManager
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.ide.CopyPasteManager
7import com.intellij.openapi.project.Project
8import git4idea.repo.GitRepositoryManager
9import java.awt.Desktop
10import java.net.URI
11import java.net.http.HttpClient
12import java.net.http.HttpRequest
13import java.net.http.HttpResponse
14import java.time.Duration
15
16/**
17 * Shared utilities for Gluecron actions.
18 *
19 * - [detectOwnerRepo] — infers `owner/repo` from the project's git remote URL
20 * - [openBrowser] — opens a URL in the system's default browser
21 * - [apiPost] — synchronous POST helper used by MergePrAction
22 * - [notify] — show a balloon notification
23 */
24object GluecronUtil {
25
26 /**
27 * Detects the owner and repository name from the first git remote URL
28 * in the project that contains the configured Gluecron host, or falls
29 * back to any remote named "origin".
30 *
31 * Handles both HTTPS and SSH remote formats:
32 * https://gluecron.com/owner/repo.git
33 * git@gluecron.com:owner/repo.git
34 *
35 * Returns null if no usable git remote is found.
36 */
37 fun detectOwnerRepo(project: Project): Pair<String, String>? {
38 val settings = GluecronSettingsState.getInstance()
39 val manager = GitRepositoryManager.getInstance(project)
40 val repos = manager.repositories
41 if (repos.isEmpty()) return null
42
43 // Prefer remotes whose URL contains the configured host
44 val hostDomain = settings.host
45 .removePrefix("https://")
46 .removePrefix("http://")
47 .trimEnd('/')
48
49 fun parseRemoteUrl(url: String): Pair<String, String>? {
50 // Normalise: strip protocol + host prefix or git@ prefix
51 val cleaned = url
52 .replace(Regex("^https?://[^/]+/"), "")
53 .replace(Regex("^git@[^:]+:"), "")
54 .removeSuffix(".git")
55 val parts = cleaned.split("/").filter { it.isNotBlank() }
56 if (parts.size < 2) return null
57 return Pair(parts[0], parts[1])
58 }
59
60 for (repo in repos) {
61 val remotes = repo.remotes
62 // Try host-matching remote first
63 for (remote in remotes) {
64 val url = remote.firstUrl ?: continue
65 if (url.contains(hostDomain)) {
66 return parseRemoteUrl(url)
67 }
68 }
69 // Fall back to any remote named "origin"
70 val origin = remotes.firstOrNull { it.name == "origin" }
71 if (origin != null) {
72 val url = origin.firstUrl ?: continue
73 return parseRemoteUrl(url)
74 }
75 }
76 return null
77 }
78
79 /**
80 * Returns the name of the currently checked-out branch in the first
81 * git repository found in the project, or null if none.
82 */
83 fun currentBranch(project: Project): String? {
84 val manager = GitRepositoryManager.getInstance(project)
85 return manager.repositories.firstOrNull()
86 ?.currentBranchName
87 }
88
89 /**
90 * Opens [url] in the system's default browser.
91 * Falls back gracefully if Desktop is not supported.
92 */
93 fun openBrowser(url: String) {
94 if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
95 Desktop.getDesktop().browse(URI(url))
96 }
97 }
98
99 /**
100 * Performs a synchronous HTTP POST to [path] (relative to the configured host)
101 * with a JSON [body]. Returns the response body as a string, or throws on error.
102 *
103 * This is intentionally minimal — used only by MergePrAction which needs a
104 * blocking call so we can surface the result in the same action invocation.
105 */
106 fun apiPost(path: String, body: String): String {
107 val settings = GluecronSettingsState.getInstance()
108 val url = "${settings.host}$path"
109 val token = settings.token
110
111 val client = HttpClient.newBuilder()
112 .connectTimeout(Duration.ofSeconds(10))
113 .build()
114
115 val requestBuilder = HttpRequest.newBuilder()
116 .uri(URI(url))
117 .timeout(Duration.ofSeconds(30))
118 .header("Content-Type", "application/json")
119 .header("Accept", "application/json")
120 .POST(HttpRequest.BodyPublishers.ofString(body))
121
122 if (token.isNotBlank()) {
123 requestBuilder.header("Authorization", "Bearer $token")
124 }
125
126 val response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
127 if (response.statusCode() !in 200..299) {
128 throw RuntimeException("HTTP ${response.statusCode()}: ${response.body().take(200)}")
129 }
130 return response.body()
131 }
132
133 /**
134 * Shows a balloon notification attached to [project].
135 */
136 fun notify(project: Project, message: String, type: NotificationType = NotificationType.INFORMATION) {
137 NotificationGroupManager.getInstance()
138 .getNotificationGroup("Gluecron Notifications")
139 .createNotification("Gluecron", message, type)
140 .notify(project)
141 }
142}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/actions/CreateIssueAction.kt+44−0View fileUnifiedSplit
1package com.gluecron.actions
2
3import com.gluecron.GluecronUtil
4import com.gluecron.settings.GluecronSettingsState
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.project.DumbAware
9
10/**
11 * Opens a browser tab showing the new-issue form for the current repository.
12 *
13 * URL pattern: {host}/{owner}/{repo}/issues/new
14 *
15 * This is the JetBrains equivalent of the VS Code "gluecron.createIssue" flow.
16 * Rather than embedding a form in the IDE (which would require additional UI
17 * scaffolding), we open the Gluecron web UI so users can fill in labels,
18 * assignees, and description with full markdown preview.
19 */
20class CreateIssueAction : AnAction(), DumbAware {
21
22 override fun actionPerformed(e: AnActionEvent) {
23 val project = e.project ?: return
24 val settings = GluecronSettingsState.getInstance()
25
26 val (owner, repo) = GluecronUtil.detectOwnerRepo(project)
27 ?: run {
28 GluecronUtil.notify(
29 project,
30 "No Gluecron remote detected. Ensure the project has a git remote " +
31 "pointing to ${settings.host}.",
32 NotificationType.WARNING
33 )
34 return
35 }
36
37 val url = "${settings.host}/$owner/$repo/issues/new"
38 GluecronUtil.openBrowser(url)
39 }
40
41 override fun update(e: AnActionEvent) {
42 e.presentation.isEnabledAndVisible = e.project != null
43 }
44}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/actions/MergePrAction.kt+116−0View fileUnifiedSplit
1package com.gluecron.actions
2
3import com.gluecron.GluecronUtil
4import com.gluecron.settings.GluecronSettingsState
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.application.ApplicationManager
9import com.intellij.openapi.progress.ProgressIndicator
10import com.intellij.openapi.progress.ProgressManager
11import com.intellij.openapi.progress.Task
12import com.intellij.openapi.project.DumbAware
13import com.intellij.openapi.ui.Messages
14
15/**
16 * Merges the open pull request for the currently checked-out branch.
17 *
18 * Flow:
19 * 1. Detect owner/repo from the git remote URL.
20 * 2. Detect the current branch name.
21 * 3. Ask the user to confirm (the merge is irreversible).
22 * 4. POST to {host}/api/repos/{owner}/{repo}/pulls/merge
23 * with body: { "head": "<branch>", "merge_method": "merge" }
24 * 5. Show a success or error balloon notification.
25 *
26 * The API call runs on a background thread via ProgressManager so it
27 * doesn't block the EDT.
28 *
29 * This is the JetBrains equivalent of the VS Code "gluecron.mergePr" command.
30 */
31class MergePrAction : AnAction(), DumbAware {
32
33 override fun actionPerformed(e: AnActionEvent) {
34 val project = e.project ?: return
35 val settings = GluecronSettingsState.getInstance()
36
37 if (settings.token.isBlank()) {
38 GluecronUtil.notify(
39 project,
40 "Gluecron access token is not configured. " +
41 "Go to Settings → Tools → Gluecron to add your token.",
42 NotificationType.ERROR
43 )
44 return
45 }
46
47 val (owner, repo) = GluecronUtil.detectOwnerRepo(project)
48 ?: run {
49 GluecronUtil.notify(
50 project,
51 "No Gluecron remote detected. Ensure the project has a git remote " +
52 "pointing to ${settings.host}.",
53 NotificationType.WARNING
54 )
55 return
56 }
57
58 val branch = GluecronUtil.currentBranch(project)
59 ?: run {
60 GluecronUtil.notify(
61 project,
62 "Could not determine the current branch.",
63 NotificationType.WARNING
64 )
65 return
66 }
67
68 // Confirm before merging — this is a destructive action
69 val confirmed = Messages.showYesNoDialog(
70 project,
71 "Merge the open pull request for branch \"$branch\" into $owner/$repo?\n\n" +
72 "This action cannot be undone.",
73 "Merge PR — Gluecron",
74 "Merge",
75 "Cancel",
76 Messages.getQuestionIcon()
77 )
78 if (confirmed != Messages.YES) return
79
80 // Run the API call on a background thread
81 ProgressManager.getInstance().run(object : Task.Backgroundable(
82 project,
83 "Gluecron: Merging PR for branch \"$branch\"…",
84 false
85 ) {
86 override fun run(indicator: ProgressIndicator) {
87 indicator.isIndeterminate = true
88 try {
89 val path = "/api/repos/$owner/$repo/pulls/merge"
90 val body = """{"head":"$branch","merge_method":"merge"}"""
91 GluecronUtil.apiPost(path, body)
92
93 ApplicationManager.getApplication().invokeLater {
94 GluecronUtil.notify(
95 project,
96 "PR for branch \"$branch\" merged successfully into $owner/$repo.",
97 NotificationType.INFORMATION
98 )
99 }
100 } catch (ex: Exception) {
101 ApplicationManager.getApplication().invokeLater {
102 GluecronUtil.notify(
103 project,
104 "Failed to merge PR: ${ex.message}",
105 NotificationType.ERROR
106 )
107 }
108 }
109 }
110 })
111 }
112
113 override fun update(e: AnActionEvent) {
114 e.presentation.isEnabledAndVisible = e.project != null
115 }
116}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/actions/OpenPrAction.kt+44−0View fileUnifiedSplit
1package com.gluecron.actions
2
3import com.gluecron.GluecronUtil
4import com.gluecron.settings.GluecronSettingsState
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.project.DumbAware
9
10/**
11 * Opens a browser tab showing the pull request list for the current repository.
12 *
13 * URL pattern: {host}/{owner}/{repo}/pulls
14 *
15 * This is the JetBrains equivalent of the VS Code "gluecron.openOnWeb" command
16 * adapted for PR-centric workflow — surfacing the PR list rather than an
17 * individual file, since JetBrains users primarily navigate via the IDE tree.
18 */
19class OpenPrAction : AnAction(), DumbAware {
20
21 override fun actionPerformed(e: AnActionEvent) {
22 val project = e.project ?: return
23 val settings = GluecronSettingsState.getInstance()
24
25 val (owner, repo) = GluecronUtil.detectOwnerRepo(project)
26 ?: run {
27 GluecronUtil.notify(
28 project,
29 "No Gluecron remote detected. Ensure the project has a git remote " +
30 "pointing to ${settings.host}.",
31 NotificationType.WARNING
32 )
33 return
34 }
35
36 val url = "${settings.host}/$owner/$repo/pulls"
37 GluecronUtil.openBrowser(url)
38 }
39
40 override fun update(e: AnActionEvent) {
41 // Only enable when a project is open
42 e.presentation.isEnabledAndVisible = e.project != null
43 }
44}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/actions/ViewHealthAction.kt+48−0View fileUnifiedSplit
1package com.gluecron.actions
2
3import com.gluecron.GluecronUtil
4import com.gluecron.settings.GluecronSettingsState
5import com.intellij.notification.NotificationType
6import com.intellij.openapi.actionSystem.AnAction
7import com.intellij.openapi.actionSystem.AnActionEvent
8import com.intellij.openapi.project.DumbAware
9
10/**
11 * Opens a browser tab showing the repository health / CI dashboard.
12 *
13 * URL pattern: {host}/{owner}/{repo}/health
14 *
15 * The health page aggregates:
16 * - GateTest scan results from the most recent push
17 * - CI/CD pipeline status
18 * - Branch protection rule compliance
19 * - Open PR count and review coverage
20 *
21 * This is the JetBrains equivalent of the VS Code "gluecron.viewHealth" command,
22 * and mirrors the /admin/deploys live step stream mentioned in the project docs.
23 */
24class ViewHealthAction : AnAction(), DumbAware {
25
26 override fun actionPerformed(e: AnActionEvent) {
27 val project = e.project ?: return
28 val settings = GluecronSettingsState.getInstance()
29
30 val (owner, repo) = GluecronUtil.detectOwnerRepo(project)
31 ?: run {
32 GluecronUtil.notify(
33 project,
34 "No Gluecron remote detected. Ensure the project has a git remote " +
35 "pointing to ${settings.host}.",
36 NotificationType.WARNING
37 )
38 return
39 }
40
41 val url = "${settings.host}/$owner/$repo/health"
42 GluecronUtil.openBrowser(url)
43 }
44
45 override fun update(e: AnActionEvent) {
46 e.presentation.isEnabledAndVisible = e.project != null
47 }
48}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/settings/GluecronSettingsConfigurable.kt+71−0View fileUnifiedSplit
1package com.gluecron.settings
2
3import com.intellij.openapi.options.Configurable
4import com.intellij.ui.components.JBLabel
5import com.intellij.ui.components.JBTextField
6import com.intellij.util.ui.FormBuilder
7import javax.swing.JComponent
8import javax.swing.JPanel
9
10/**
11 * Settings UI for the Gluecron plugin.
12 *
13 * Accessible via: Settings → Tools → Gluecron
14 *
15 * Provides two fields:
16 * - Server URL — the Gluecron host (e.g. "https://gluecron.com")
17 * - Access Token — personal access token (glc_...)
18 */
19class GluecronSettingsConfigurable : Configurable {
20
21 private var hostField: JBTextField? = null
22 private var tokenField: JBTextField? = null
23
24 override fun getDisplayName(): String = "Gluecron"
25
26 override fun createComponent(): JComponent {
27 val settings = GluecronSettingsState.getInstance()
28
29 hostField = JBTextField(settings.host, 40)
30 tokenField = JBTextField(settings.token, 40)
31
32 return FormBuilder.createFormBuilder()
33 .addLabeledComponent(
34 JBLabel("Server URL:"),
35 hostField!!,
36 1,
37 false
38 )
39 .addLabeledComponent(
40 JBLabel("Access Token:"),
41 tokenField!!,
42 1,
43 false
44 )
45 .addComponentFillVertically(JPanel(), 0)
46 .panel
47 }
48
49 override fun isModified(): Boolean {
50 val settings = GluecronSettingsState.getInstance()
51 return hostField?.text?.trimEnd('/') != settings.host ||
52 tokenField?.text != settings.token
53 }
54
55 override fun apply() {
56 val settings = GluecronSettingsState.getInstance()
57 hostField?.text?.let { settings.host = it }
58 tokenField?.text?.let { settings.token = it }
59 }
60
61 override fun reset() {
62 val settings = GluecronSettingsState.getInstance()
63 hostField?.text = settings.host
64 tokenField?.text = settings.token
65 }
66
67 override fun disposeUIResources() {
68 hostField = null
69 tokenField = null
70 }
71}
Addedjetbrains-plugin/src/main/kotlin/com/gluecron/settings/GluecronSettingsState.kt+56−0View fileUnifiedSplit
1package com.gluecron.settings
2
3import com.intellij.openapi.application.ApplicationManager
4import com.intellij.openapi.components.PersistentStateComponent
5import com.intellij.openapi.components.State
6import com.intellij.openapi.components.Storage
7
8/**
9 * Persistent state for Gluecron plugin settings.
10 *
11 * Stored in: <config>/options/GluecronSettings.xml
12 *
13 * Fields:
14 * host — the Gluecron server base URL (e.g. "https://gluecron.com")
15 * token — a personal access token (glc_...) with read+write repo scope
16 *
17 * Both fields can also be seeded from environment variables at first
18 * launch (see [GluecronPlugin]):
19 * GLUECRON_HOST → host
20 * GLUECRON_TOKEN → token
21 */
22@State(
23 name = "GluecronSettingsState",
24 storages = [Storage("GluecronSettings.xml")]
25)
26class GluecronSettingsState : PersistentStateComponent<GluecronSettingsState.State> {
27
28 data class State(
29 var host: String = "http://localhost:3000",
30 var token: String = ""
31 )
32
33 private var myState = State()
34
35 override fun getState(): State = myState
36
37 override fun loadState(state: State) {
38 myState = state
39 }
40
41 /** Gluecron server base URL, e.g. "https://gluecron.com" (no trailing slash). */
42 var host: String
43 get() = myState.host.trimEnd('/')
44 set(value) { myState.host = value.trimEnd('/') }
45
46 /** Personal access token (glc_...). May be empty if the server is public. */
47 var token: String
48 get() = myState.token
49 set(value) { myState.token = value }
50
51 companion object {
52 fun getInstance(): GluecronSettingsState =
53 ApplicationManager.getApplication()
54 .getService(GluecronSettingsState::class.java)
55 }
56}
Addedjetbrains-plugin/src/main/resources/META-INF/plugin.xml+110−0View fileUnifiedSplit
1<idea-plugin>
2 <id>com.gluecron.jetbrains</id>
3 <name>Gluecron</name>
4 <version>0.1.0</version>
5
6 <vendor email="support@gluecron.com" url="https://gluecron.com">Gluecron</vendor>
7
8 <description><![CDATA[
9 <p>Gluecron integration for JetBrains IDEs (IntelliJ IDEA, WebStorm, GoLand, PyCharm, and more).</p>
10 <p>AI-native code intelligence platform — git hosting, automated CI, and push-time gate enforcement.</p>
11 <br/>
12 <p><b>Features:</b></p>
13 <ul>
14 <li><b>Open PR List</b> — browse open pull requests for the current repository in your browser</li>
15 <li><b>Create Issue</b> — open the new issue form for the current repository in your browser</li>
16 <li><b>Merge PR</b> — merge the open pull request for the current branch via the Gluecron API</li>
17 <li><b>View Health</b> — open the repository health/CI dashboard in your browser</li>
18 </ul>
19 <br/>
20 <p>Configure your Gluecron host URL and personal access token via
21 <b>Settings → Tools → Gluecron</b>.</p>
22 ]]></description>
23
24 <change-notes><![CDATA[
25 <ul>
26 <li>0.1.0: Initial release — Open PR, Create Issue, Merge PR, View Health</li>
27 </ul>
28 ]]></change-notes>
29
30 <!-- Minimum/maximum IDE versions (2023.1+) -->
31 <idea-version since-build="231" until-build="251.*"/>
32
33 <!-- Depends on the bundled Git4Idea plugin for VCS root detection -->
34 <depends>com.intellij.modules.platform</depends>
35 <depends>Git4Idea</depends>
36
37 <extensions defaultExtensionNs="com.intellij">
38 <!-- Startup activity: loads settings and validates connectivity -->
39 <postStartupActivity
40 implementation="com.gluecron.GluecronPlugin"/>
41
42 <!-- Persistent settings storage -->
43 <applicationService
44 serviceImplementation="com.gluecron.settings.GluecronSettingsState"/>
45
46 <!-- Settings UI page under Tools -->
47 <applicationConfigurable
48 parentId="tools"
49 instance="com.gluecron.settings.GluecronSettingsConfigurable"
50 id="com.gluecron.settings"
51 displayName="Gluecron"/>
52 </extensions>
53
54 <actions>
55 <!-- Top-level Gluecron menu in the main menu bar -->
56 <group id="GluecronMenuGroup" text="Gluecron" description="Gluecron actions" popup="true">
57 <add-to-group group-id="MainMenu" anchor="last"/>
58
59 <action
60 id="com.gluecron.actions.OpenPrAction"
61 class="com.gluecron.actions.OpenPrAction"
62 text="Open PR List"
63 description="Open the pull request list for this repository in a browser tab"
64 icon="AllIcons.Vcs.Branch">
65 </action>
66
67 <action
68 id="com.gluecron.actions.CreateIssueAction"
69 class="com.gluecron.actions.CreateIssueAction"
70 text="Create Issue"
71 description="Open the new issue form for this repository in a browser tab"
72 icon="AllIcons.General.Add">
73 </action>
74
75 <action
76 id="com.gluecron.actions.MergePrAction"
77 class="com.gluecron.actions.MergePrAction"
78 text="Merge PR for Current Branch"
79 description="Merge the open pull request for the current branch via the Gluecron API"
80 icon="AllIcons.Vcs.Merge">
81 </action>
82
83 <action
84 id="com.gluecron.actions.ViewHealthAction"
85 class="com.gluecron.actions.ViewHealthAction"
86 text="View Repo Health"
87 description="Open the repository health and CI dashboard in a browser tab"
88 icon="AllIcons.Debugger.ThreadStates.Idle">
89 </action>
90
91 <separator/>
92
93 <action
94 id="com.gluecron.actions.OpenSettingsAction"
95 class="com.intellij.openapi.options.ShowSettingsUtil"
96 text="Gluecron Settings..."
97 description="Open Gluecron plugin settings">
98 </action>
99 </group>
100
101 <!-- Also surface actions in the VCS Operations popup -->
102 <group id="GluecronVcsGroup" text="Gluecron" description="Gluecron actions" popup="true">
103 <add-to-group group-id="VcsGroups" anchor="last"/>
104 <reference ref="com.gluecron.actions.OpenPrAction"/>
105 <reference ref="com.gluecron.actions.CreateIssueAction"/>
106 <reference ref="com.gluecron.actions.MergePrAction"/>
107 <reference ref="com.gluecron.actions.ViewHealthAction"/>
108 </group>
109 </actions>
110</idea-plugin>
Modifiedpackage.json+4−1View fileUnifiedSplit
1818 "db:migrate": "bun run src/db/migrate.ts",
1919 "db:studio": "drizzle-kit studio",
2020 "test": "bun test",
21 "preflight": "bun scripts/preflight.ts"
21 "preflight": "bun scripts/preflight.ts",
22 "e2e": "playwright test --config=e2e/playwright.config.ts"
2223 },
2324 "dependencies": {
2425 "@anthropic-ai/sdk": "^0.96.0",
3637 "sshcrypto": "*"
3738 },
3839 "devDependencies": {
40 "@playwright/test": "^1.49.0",
3941 "@types/bun": "^1.3.14",
42 "@types/k6": "^2.0.0",
4043 "drizzle-kit": "^0.31.10",
4144 "typescript": "^5.7.0"
4245 }
Addedscripts/load-test-git.js+223−0View fileUnifiedSplit
1/**
2 * Gluecron git Smart HTTP load test
3 *
4 * Tests the git Smart HTTP endpoints under concurrent load, simulating
5 * the discovery and pack-negotiation phases of git clone and git fetch.
6 *
7 * THIS FILE RUNS UNDER k6 — do not run with node or bun directly.
8 * k6 provides its own built-in modules (k6/http, k6/metrics, etc.) at runtime.
9 *
10 * Covered endpoints:
11 * GET /:owner/:repo.git/info/refs?service=git-upload-pack
12 * GET /:owner/:repo.git/info/refs?service=git-receive-pack
13 * POST /:owner/:repo.git/git-upload-pack (upload-pack capability advertisement)
14 *
15 * Usage:
16 * k6 run scripts/load-test-git.js
17 *
18 * Required env vars:
19 * BASE_URL — server base URL (default: http://localhost:3000)
20 * GIT_OWNER — git repo owner username (default: testowner)
21 * GIT_REPO — git repo name (default: testrepo)
22 * GIT_PAT — personal access token for authenticated push simulation
23 * (optional; unauthenticated read tests still run without it)
24 *
25 * Example with a real public repo:
26 * BASE_URL=https://gluecron.com GIT_OWNER=ccantynz GIT_REPO=Gluecron.com \
27 * k6 run scripts/load-test-git.js
28 *
29 * Requirements: k6 >= 0.46 (https://k6.io/docs/get-started/installation/)
30 */
31
32/* global __ENV */
33
34// k6 built-in modules — loaded via k6's module system at runtime
35var http = require('k6/http');
36var k6 = require('k6');
37var metrics = require('k6/metrics');
38
39var check = k6.check;
40var sleep = k6.sleep;
41var group = k6.group;
42var Rate = metrics.Rate;
43var Trend = metrics.Trend;
44var Counter = metrics.Counter;
45
46// ---------------------------------------------------------------------------
47// Custom metrics
48// ---------------------------------------------------------------------------
49
50var errorRate = new Rate('git_errors');
51var uploadPackRefs = new Trend('upload_pack_refs_duration');
52var receivePackRefs = new Trend('receive_pack_refs_duration');
53var uploadPackPost = new Trend('upload_pack_post_duration');
54var gitRequests = new Counter('git_requests_total');
55
56// ---------------------------------------------------------------------------
57// Test options — exported so k6 reads them before starting VUs
58// ---------------------------------------------------------------------------
59
60module.exports.options = {
61 stages: [
62 { duration: '20s', target: 50 }, // ramp: simulate developers starting their day
63 { duration: '1m', target: 50 }, // steady state: 50 concurrent git clients
64 { duration: '20s', target: 150 }, // spike: CI farm wakes up and clones in bulk
65 { duration: '30s', target: 150 }, // sustained spike
66 { duration: '30s', target: 0 }, // ramp down
67 ],
68 thresholds: {
69 // git-upload-pack/info/refs should be fast — capability advertisement only
70 upload_pack_refs_duration: ['p(95)<300', 'p(99)<800'],
71 // receive-pack refs can be a touch slower (auth check + lock)
72 receive_pack_refs_duration: ['p(95)<400', 'p(99)<1000'],
73 // POST upload-pack: streaming body, allow more headroom
74 upload_pack_post_duration: ['p(95)<1000', 'p(99)<3000'],
75 // Overall HTTP failure rate
76 http_req_failed: ['rate<0.01'],
77 // Our own error tracking
78 git_errors: ['rate<0.02'],
79 },
80};
81
82// ---------------------------------------------------------------------------
83// Configuration from env
84// ---------------------------------------------------------------------------
85
86var BASE_URL = (typeof __ENV !== 'undefined' && __ENV.BASE_URL) || 'http://localhost:3000';
87var GIT_OWNER = (typeof __ENV !== 'undefined' && __ENV.GIT_OWNER) || 'testowner';
88var GIT_REPO = (typeof __ENV !== 'undefined' && __ENV.GIT_REPO) || 'testrepo';
89var GIT_PAT = (typeof __ENV !== 'undefined' && __ENV.GIT_PAT) || '';
90
91// Derived
92var REPO_PATH = '/' + GIT_OWNER + '/' + GIT_REPO + '.git';
93
94// Auth header — only included when a PAT is available.
95// The value is constructed at runtime from the GIT_PAT env var — no hardcoded secret. // secrets-ok
96var authHeaders = GIT_PAT
97 ? { Authorization: 'Basic ' + btoa('token:' + GIT_PAT) } // secrets-ok
98 : {};
99
100// git-upload-pack POST capability advertisement body.
101// pkt-line framing for a minimal ls-refs request — what `git fetch` sends
102// after reading /info/refs to negotiate a pack. Kept minimal so the server
103// returns quickly without sending a full pack.
104var GIT_UPLOAD_PACK_BODY = [
105 '0014command=ls-refs\n', // pkt-line: ls-refs command
106 '0000', // flush pkt
107 '0000', // end of capability list
108].join('');
109
110// ---------------------------------------------------------------------------
111// Setup — printed once before the test starts
112// ---------------------------------------------------------------------------
113
114module.exports.setup = function () {
115 console.log('[load-test-git] Target: ' + BASE_URL + REPO_PATH);
116 console.log('[load-test-git] Auth: ' + (GIT_PAT ? 'PAT set (POST tests enabled)' : 'no PAT (read-only tests)'));
117 return { baseUrl: BASE_URL, repo: REPO_PATH };
118};
119
120// ---------------------------------------------------------------------------
121// Default export — executed once per VU per iteration
122// ---------------------------------------------------------------------------
123
124module.exports.default = function () {
125 // ---- 1. git-upload-pack info/refs (git clone / git fetch discovery) ----
126 group('upload-pack info/refs', function () {
127 var url = BASE_URL + REPO_PATH + '/info/refs?service=git-upload-pack';
128 var headers = {};
129 Object.assign(headers, authHeaders, {
130 'Git-Protocol': 'version=2',
131 'User-Agent': 'git/2.44.0',
132 });
133 var r = http.get(url, { headers: headers });
134 gitRequests.add(1);
135
136 var ok = check(r, {
137 'upload-pack refs: 200 or 401': function (res) {
138 return res.status === 200 || res.status === 401;
139 },
140 'upload-pack refs: correct content-type': function (res) {
141 return res.status === 401 ||
142 (res.headers['Content-Type'] || '').indexOf(
143 'application/x-git-upload-pack-advertisement'
144 ) !== -1;
145 },
146 });
147 errorRate.add(!ok);
148 uploadPackRefs.add(r.timings.duration);
149 });
150
151 sleep(0.2);
152
153 // ---- 2. git-receive-pack info/refs (git push discovery) ----
154 group('receive-pack info/refs', function () {
155 var url = BASE_URL + REPO_PATH + '/info/refs?service=git-receive-pack';
156 var headers = {};
157 Object.assign(headers, authHeaders, {
158 'Git-Protocol': 'version=2',
159 'User-Agent': 'git/2.44.0',
160 });
161 var r = http.get(url, { headers: headers });
162 gitRequests.add(1);
163
164 var ok = check(r, {
165 // receive-pack always requires auth; 401 is expected for unauthed requests
166 'receive-pack refs: 200 or 401': function (res) {
167 return res.status === 200 || res.status === 401;
168 },
169 'receive-pack refs: not 500': function (res) { return res.status !== 500; },
170 });
171 errorRate.add(!ok);
172 receivePackRefs.add(r.timings.duration);
173 });
174
175 sleep(0.3);
176
177 // ---- 3. git-upload-pack POST (minimal ls-refs — no actual pack download) ----
178 //
179 // Only run when a PAT is set so we don't measure auth-rejection latency.
180 if (GIT_PAT) {
181 group('upload-pack POST (ls-refs)', function () {
182 var url = BASE_URL + REPO_PATH + '/git-upload-pack';
183 var headers = {};
184 Object.assign(headers, authHeaders, {
185 'Content-Type': 'application/x-git-upload-pack-request',
186 'Accept': 'application/x-git-upload-pack-result',
187 'Git-Protocol': 'version=2',
188 'User-Agent': 'git/2.44.0',
189 });
190 var r = http.post(url, GIT_UPLOAD_PACK_BODY, { headers: headers });
191 gitRequests.add(1);
192
193 var ok = check(r, {
194 'upload-pack POST: 200': function (res) { return res.status === 200; },
195 'upload-pack POST: git content-type': function (res) {
196 return (res.headers['Content-Type'] || '').indexOf(
197 'application/x-git-upload-pack-result'
198 ) !== -1;
199 },
200 'upload-pack POST: not empty': function (res) {
201 return res.body !== null && res.body.length > 0;
202 },
203 });
204 errorRate.add(!ok);
205 uploadPackPost.add(r.timings.duration);
206 });
207 }
208
209 // ---- 4. Raw blob fetch (simulates web raw file downloads) ----
210 group('raw file fetch (HEAD README)', function () {
211 var url = BASE_URL + '/' + GIT_OWNER + '/' + GIT_REPO + '/raw/HEAD/README.md';
212 var r = http.get(url, { headers: authHeaders });
213 gitRequests.add(1);
214
215 var ok = check(r, {
216 'raw: 200 or 404': function (res) { return res.status === 200 || res.status === 404; },
217 'raw: not 500': function (res) { return res.status !== 500; },
218 });
219 errorRate.add(!ok);
220 });
221
222 sleep(1);
223};
Addedscripts/load-test.js+140−0View fileUnifiedSplit
1/**
2 * Gluecron core flow load test
3 *
4 * Tests the main public-facing pages under sustained load.
5 *
6 * THIS FILE RUNS UNDER k6 — do not run with node or bun directly.
7 * k6 provides its own built-in modules (k6/http, k6/metrics, etc.) at runtime.
8 *
9 * Usage:
10 * k6 run scripts/load-test.js
11 *
12 * Override base URL:
13 * BASE_URL=https://gluecron.com k6 run scripts/load-test.js
14 *
15 * Staged run against staging:
16 * BASE_URL=https://staging.gluecron.com k6 run scripts/load-test.js
17 *
18 * Requirements: k6 >= 0.46 (https://k6.io/docs/get-started/installation/)
19 */
20
21/* global __ENV */
22
23// k6 built-in modules — loaded via k6's module system at runtime
24var http = require('k6/http');
25var k6 = require('k6');
26var metrics = require('k6/metrics');
27
28var check = k6.check;
29var sleep = k6.sleep;
30var group = k6.group;
31var Rate = metrics.Rate;
32var Trend = metrics.Trend;
33
34// ---------------------------------------------------------------------------
35// Custom metrics
36// ---------------------------------------------------------------------------
37
38var errorRate = new Rate('errors');
39var landingDuration = new Trend('landing_duration');
40var exploreDuration = new Trend('explore_duration');
41var blogDuration = new Trend('blog_duration');
42var pricingDuration = new Trend('pricing_duration');
43
44// ---------------------------------------------------------------------------
45// Test options — exported so k6 reads them before starting VUs
46// ---------------------------------------------------------------------------
47
48module.exports.options = {
49 stages: [
50 { duration: '30s', target: 100 }, // ramp up to 100 VUs
51 { duration: '1m', target: 100 }, // steady state
52 { duration: '30s', target: 0 }, // ramp down
53 ],
54 thresholds: {
55 // 95th-percentile response time under 500ms across all requests
56 http_req_duration: ['p(95)<500'],
57 // Less than 1% of requests may fail
58 http_req_failed: ['rate<0.01'],
59 // Custom error rate mirrors http_req_failed but allows per-check tracking
60 errors: ['rate<0.01'],
61 },
62};
63
64// ---------------------------------------------------------------------------
65// Helpers
66// ---------------------------------------------------------------------------
67
68var BASE_URL = (typeof __ENV !== 'undefined' && __ENV.BASE_URL) || 'http://localhost:3000';
69
70function get(path, params) {
71 return http.get(BASE_URL + path, params || {});
72}
73
74// ---------------------------------------------------------------------------
75// Default export — executed once per VU per iteration
76// ---------------------------------------------------------------------------
77
78module.exports.default = function () {
79 group('landing page', function () {
80 var r = get('/');
81 var ok = check(r, {
82 'landing 200': function (res) { return res.status === 200; },
83 'landing has content': function (res) { return res.body && res.body.length > 500; },
84 });
85 errorRate.add(!ok);
86 landingDuration.add(r.timings.duration);
87 });
88
89 sleep(0.5);
90
91 group('explore page', function () {
92 var r = get('/explore');
93 var ok = check(r, {
94 'explore 200': function (res) { return res.status === 200; },
95 });
96 errorRate.add(!ok);
97 exploreDuration.add(r.timings.duration);
98 });
99
100 sleep(0.5);
101
102 group('blog index', function () {
103 var r = get('/blog');
104 var ok = check(r, {
105 'blog 200': function (res) { return res.status === 200; },
106 'blog has devlog header': function (res) {
107 return res.body && res.body.indexOf('Gluecron Devlog') !== -1;
108 },
109 });
110 errorRate.add(!ok);
111 blogDuration.add(r.timings.duration);
112 });
113
114 sleep(0.3);
115
116 group('blog post', function () {
117 var r = get('/blog/spec-to-pr-in-90-seconds');
118 var ok = check(r, {
119 'blog post 200': function (res) { return res.status === 200; },
120 'blog post has title': function (res) {
121 return res.body && res.body.indexOf('Spec to PR') !== -1;
122 },
123 });
124 errorRate.add(!ok);
125 blogDuration.add(r.timings.duration);
126 });
127
128 sleep(0.3);
129
130 group('pricing page', function () {
131 var r = get('/pricing');
132 var ok = check(r, {
133 'pricing 200': function (res) { return res.status === 200; },
134 });
135 errorRate.add(!ok);
136 pricingDuration.add(r.timings.duration);
137 });
138
139 sleep(1);
140};
Addedsrc/__tests__/dependency-scanner.test.ts+401−0View fileUnifiedSplit
1/**
2 * Dependency CVE Scanner — unit tests.
3 *
4 * Tests the pure parsing helpers, the OSV result mapper, and issue-body
5 * renderers without touching the DB or network.
6 */
7
8import { describe, it, expect } from "bun:test";
9import { __internal } from "../lib/dependency-scanner";
10import app from "../app";
11
12const {
13 parsePackageJson,
14 parseRequirementsTxt,
15 parseCargoToml,
16 parseGoMod,
17 parseGemfile,
18 mapSeverity,
19 extractFixVersion,
20 osvResultsToFindings,
21 renderVulnIssueBody,
22 renderDigestBody,
23 WEEKLY_DIGEST_MARKER,
24 FILE_ECOSYSTEM,
25 DEPENDENCY_FILES,
26} = __internal;
27
28// ---------------------------------------------------------------------------
29// parsePackageJson
30// ---------------------------------------------------------------------------
31
32describe("parsePackageJson", () => {
33 it("parses dependencies and devDependencies", () => {
34 const json = JSON.stringify({
35 dependencies: { lodash: "^4.17.11", express: "^4.18.0" },
36 devDependencies: { jest: "^29.0.0" },
37 });
38 const result = parsePackageJson(json, "npm");
39 expect(result.length).toBe(3);
40 expect(result.map((p) => p.name)).toContain("lodash");
41 expect(result.map((p) => p.name)).toContain("express");
42 expect(result.map((p) => p.name)).toContain("jest");
43 });
44
45 it("strips leading ^ ~ from version specs", () => {
46 const json = JSON.stringify({ dependencies: { react: "^18.2.0" } });
47 const result = parsePackageJson(json, "npm");
48 expect(result[0].version).toBe("18.2.0");
49 });
50
51 it("returns [] on invalid JSON", () => {
52 expect(parsePackageJson("not json", "npm")).toEqual([]);
53 });
54
55 it("sets correct ecosystem", () => {
56 const json = JSON.stringify({ dependencies: { foo: "1.0.0" } });
57 const result = parsePackageJson(json, "npm");
58 expect(result[0].ecosystem).toBe("npm");
59 });
60
61 it("handles empty dependency sections", () => {
62 const json = JSON.stringify({ name: "my-app" });
63 expect(parsePackageJson(json, "npm")).toEqual([]);
64 });
65});
66
67// ---------------------------------------------------------------------------
68// parseRequirementsTxt
69// ---------------------------------------------------------------------------
70
71describe("parseRequirementsTxt", () => {
72 it("parses pinned versions", () => {
73 const content = "requests==2.28.0\nDjango>=4.1\nflask\n";
74 const result = parseRequirementsTxt(content, "PyPI");
75 expect(result.map((p) => p.name)).toContain("requests");
76 expect(result.find((p) => p.name === "requests")?.version).toBe("2.28.0");
77 expect(result.map((p) => p.name)).toContain("Django");
78 expect(result.map((p) => p.name)).toContain("flask");
79 });
80
81 it("skips comments and -r flags", () => {
82 const content = "# comment\n-r base.txt\nnumpy==1.24.0\n";
83 const result = parseRequirementsTxt(content, "PyPI");
84 expect(result.length).toBe(1);
85 expect(result[0].name).toBe("numpy");
86 });
87
88 it("returns [] for empty content", () => {
89 expect(parseRequirementsTxt("", "PyPI")).toEqual([]);
90 });
91});
92
93// ---------------------------------------------------------------------------
94// parseCargoToml
95// ---------------------------------------------------------------------------
96
97describe("parseCargoToml", () => {
98 it("parses simple string versions", () => {
99 const content = `[dependencies]\nserde = "1.0.163"\ntokio = "1.28.0"\n`;
100 const result = parseCargoToml(content, "crates.io");
101 expect(result.map((p) => p.name)).toContain("serde");
102 expect(result.find((p) => p.name === "serde")?.version).toBe("1.0.163");
103 expect(result.find((p) => p.name === "tokio")?.version).toBe("1.28.0");
104 });
105
106 it("parses table-style versions", () => {
107 const content = `[dependencies]\nactix-web = { version = "4.3.0", features = ["macros"] }\n`;
108 const result = parseCargoToml(content, "crates.io");
109 expect(result.find((p) => p.name === "actix-web")?.version).toBe("4.3.0");
110 });
111
112 it("skips [package] and other sections", () => {
113 const content = `[package]\nname = "my-crate"\n\n[dependencies]\nhyper = "0.14.27"\n`;
114 const result = parseCargoToml(content, "crates.io");
115 expect(result.length).toBe(1);
116 expect(result[0].name).toBe("hyper");
117 });
118
119 it("returns [] for empty content", () => {
120 expect(parseCargoToml("", "crates.io")).toEqual([]);
121 });
122});
123
124// ---------------------------------------------------------------------------
125// parseGoMod
126// ---------------------------------------------------------------------------
127
128describe("parseGoMod", () => {
129 it("parses require lines", () => {
130 const content = `module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n\tgolang.org/x/net v0.10.0\n)\n`;
131 const result = parseGoMod(content, "Go");
132 expect(result.map((p) => p.name)).toContain("github.com/gin-gonic/gin");
133 expect(result.find((p) => p.name === "github.com/gin-gonic/gin")?.version).toBe("1.9.0");
134 });
135
136 it("strips leading v from version", () => {
137 const content = `require github.com/foo/bar v2.1.0\n`;
138 const result = parseGoMod(content, "Go");
139 expect(result[0].version).toBe("2.1.0");
140 });
141
142 it("returns [] for content with no require lines", () => {
143 expect(parseGoMod("module foo\n\ngo 1.21\n", "Go")).toEqual([]);
144 });
145});
146
147// ---------------------------------------------------------------------------
148// parseGemfile
149// ---------------------------------------------------------------------------
150
151describe("parseGemfile", () => {
152 it("parses gem declarations", () => {
153 const content = `source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\ngem 'pg'\n`;
154 const result = parseGemfile(content, "RubyGems");
155 expect(result.map((p) => p.name)).toContain("rails");
156 expect(result.map((p) => p.name)).toContain("pg");
157 });
158
159 it("returns [] for non-gem lines", () => {
160 expect(parseGemfile("source 'https://rubygems.org'\n", "RubyGems")).toEqual([]);
161 });
162});
163
164// ---------------------------------------------------------------------------
165// mapSeverity
166// ---------------------------------------------------------------------------
167
168describe("mapSeverity", () => {
169 it("returns 'critical' for CVSS score >= 9.0", () => {
170 expect(
171 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "9.5" }] })
172 ).toBe("critical");
173 });
174
175 it("returns 'high' for CVSS score >= 7.0", () => {
176 expect(
177 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "8.1" }] })
178 ).toBe("high");
179 });
180
181 it("returns 'medium' for CVSS score >= 4.0", () => {
182 expect(
183 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "5.3" }] })
184 ).toBe("medium");
185 });
186
187 it("returns 'low' for CVSS score < 4.0", () => {
188 expect(
189 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "2.1" }] })
190 ).toBe("low");
191 });
192
193 it("handles string CRITICAL severity", () => {
194 expect(
195 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "CRITICAL" }] })
196 ).toBe("critical");
197 });
198
199 it("handles string HIGH severity", () => {
200 expect(
201 mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "HIGH" }] })
202 ).toBe("high");
203 });
204
205 it("returns 'medium' when no severity data", () => {
206 expect(mapSeverity({ id: "x" })).toBe("medium");
207 expect(mapSeverity({ id: "x", severity: [] })).toBe("medium");
208 });
209});
210
211// ---------------------------------------------------------------------------
212// extractFixVersion
213// ---------------------------------------------------------------------------
214
215describe("extractFixVersion", () => {
216 it("extracts fix version from affected ranges", () => {
217 const vuln = {
218 id: "CVE-2021-44228",
219 affected: [
220 {
221 ranges: [
222 {
223 type: "SEMVER",
224 events: [{ introduced: "2.0.0" }, { fixed: "2.15.0" }],
225 },
226 ],
227 },
228 ],
229 };
230 expect(extractFixVersion(vuln)).toBe("2.15.0");
231 });
232
233 it("returns undefined when no fix is available", () => {
234 const vuln = {
235 id: "CVE-XXXX",
236 affected: [{ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }] }] }],
237 };
238 expect(extractFixVersion(vuln)).toBeUndefined();
239 });
240
241 it("returns undefined for vulns with no affected data", () => {
242 expect(extractFixVersion({ id: "x" })).toBeUndefined();
243 });
244});
245
246// ---------------------------------------------------------------------------
247// osvResultsToFindings
248// ---------------------------------------------------------------------------
249
250describe("osvResultsToFindings", () => {
251 it("converts OSV results to VulnFindings", () => {
252 const packages = [{ name: "lodash", version: "4.17.11", ecosystem: "npm" }];
253 const results = [
254 {
255 vulns: [
256 {
257 id: "GHSA-p6mc-m468-83gw",
258 summary: "Prototype pollution in lodash",
259 severity: [{ type: "CVSS_V3", score: "9.8" }],
260 affected: [
261 {
262 ranges: [
263 { type: "SEMVER", events: [{ introduced: "0" }, { fixed: "4.17.12" }] },
264 ],
265 },
266 ],
267 },
268 ],
269 },
270 ];
271 const findings = osvResultsToFindings(packages, results);
272 expect(findings).toHaveLength(1);
273 expect(findings[0].packageName).toBe("lodash");
274 expect(findings[0].installedVersion).toBe("4.17.11");
275 expect(findings[0].severity).toBe("critical");
276 expect(findings[0].cveId).toBe("GHSA-p6mc-m468-83gw");
277 expect(findings[0].fixVersion).toBe("4.17.12");
278 });
279
280 it("returns [] when no vulns in results", () => {
281 const packages = [{ name: "safe-pkg", version: "1.0.0", ecosystem: "npm" }];
282 const results = [{ vulns: [] }];
283 expect(osvResultsToFindings(packages, results)).toEqual([]);
284 });
285
286 it("handles mismatched results length gracefully", () => {
287 const packages = [{ name: "pkg-a", version: "1.0.0", ecosystem: "npm" }];
288 const findings = osvResultsToFindings(packages, []);
289 expect(findings).toEqual([]);
290 });
291});
292
293// ---------------------------------------------------------------------------
294// renderVulnIssueBody
295// ---------------------------------------------------------------------------
296
297describe("renderVulnIssueBody", () => {
298 const finding = {
299 packageName: "lodash",
300 installedVersion: "4.17.11",
301 severity: "critical" as const,
302 cveId: "GHSA-p6mc-m468-83gw",
303 description: "Prototype pollution.",
304 fixVersion: "4.17.12",
305 };
306
307 it("includes package name and version", () => {
308 const body = renderVulnIssueBody(finding);
309 expect(body).toContain("lodash");
310 expect(body).toContain("4.17.11");
311 });
312
313 it("includes CVE ID with OSV link", () => {
314 const body = renderVulnIssueBody(finding);
315 expect(body).toContain("GHSA-p6mc-m468-83gw");
316 expect(body).toContain("https://osv.dev/vulnerability/GHSA-p6mc-m468-83gw");
317 });
318
319 it("includes fix version in remediation", () => {
320 const body = renderVulnIssueBody(finding);
321 expect(body).toContain("4.17.12");
322 });
323
324 it("mentions 'No fix' when fixVersion is absent", () => {
325 const body = renderVulnIssueBody({ ...finding, fixVersion: undefined });
326 expect(body).toContain("No fix version");
327 });
328
329 it("includes CRITICAL urgency text for critical severity", () => {
330 const body = renderVulnIssueBody(finding);
331 expect(body).toContain("CRITICAL");
332 });
333});
334
335// ---------------------------------------------------------------------------
336// renderDigestBody
337// ---------------------------------------------------------------------------
338
339describe("renderDigestBody", () => {
340 const findings = [
341 {
342 packageName: "minimist",
343 installedVersion: "1.2.0",
344 severity: "medium" as const,
345 cveId: "CVE-2020-7598",
346 description: "Prototype pollution.",
347 fixVersion: "1.2.2",
348 },
349 ];
350
351 it("includes the weekly digest marker", () => {
352 expect(renderDigestBody(findings)).toContain(WEEKLY_DIGEST_MARKER);
353 });
354
355 it("includes package name and CVE in the table", () => {
356 const body = renderDigestBody(findings);
357 expect(body).toContain("minimist");
358 expect(body).toContain("CVE-2020-7598");
359 });
360
361 it("returns valid markdown table", () => {
362 const body = renderDigestBody(findings);
363 expect(body).toContain("| Package |");
364 expect(body).toContain("|---|");
365 });
366});
367
368// ---------------------------------------------------------------------------
369// Constants
370// ---------------------------------------------------------------------------
371
372describe("constants", () => {
373 it("DEPENDENCY_FILES contains the five expected files", () => {
374 expect(DEPENDENCY_FILES).toContain("package.json");
375 expect(DEPENDENCY_FILES).toContain("requirements.txt");
376 expect(DEPENDENCY_FILES).toContain("Cargo.toml");
377 expect(DEPENDENCY_FILES).toContain("go.mod");
378 expect(DEPENDENCY_FILES).toContain("Gemfile");
379 });
380
381 it("FILE_ECOSYSTEM maps each file to an ecosystem", () => {
382 expect(FILE_ECOSYSTEM["package.json"]).toBe("npm");
383 expect(FILE_ECOSYSTEM["requirements.txt"]).toBe("PyPI");
384 expect(FILE_ECOSYSTEM["Cargo.toml"]).toBe("crates.io");
385 expect(FILE_ECOSYSTEM["go.mod"]).toBe("Go");
386 expect(FILE_ECOSYSTEM["Gemfile"]).toBe("RubyGems");
387 });
388});
389
390// ---------------------------------------------------------------------------
391// Route auth smoke tests
392// ---------------------------------------------------------------------------
393
394describe("GET /:owner/:repo/security/vulnerabilities — route auth", () => {
395 it("returns 404 for non-existent owner", async () => {
396 const res = await app.request(
397 "/nonexistent-owner-99999/my-repo/security/vulnerabilities"
398 );
399 expect(res.status).toBe(404);
400 });
401});
Modifiedsrc/__tests__/landing-hero.test.ts+20−39View fileUnifiedSplit
2525 expect(ct.toLowerCase()).toContain("text/html");
2626 });
2727
28 it("renders the new hero headline", async () => {
28 it("renders the hero headline", async () => {
2929 const res = await app.request(HOME);
3030 const body = await res.text();
31 expect(body).toContain("The git host built around Claude.");
31 expect(body).toContain("The git host built for");
3232 });
3333
34 it("renders the install snippet", async () => {
34 it("renders speed-framing copy in hero lede", async () => {
3535 const res = await app.request(HOME);
3636 const body = await res.text();
37 // The host + path is what makes it unambiguous as the install snippet.
38 expect(body).toContain("gluecron.com/install");
39 expect(body).toContain("curl -sSL gluecron.com/install | bash");
37 expect(body).toContain("Spec to PR in 90 seconds");
4038 });
4139
42 it("renders all three primary CTAs in the hero row", async () => {
40 it("renders primary CTAs in the hero row", async () => {
4341 const res = await app.request(HOME);
4442 const body = await res.text();
4543 expect(body).toContain('href="/register"');
46 expect(body).toContain('href="/demo"');
47 expect(body).toContain('href="/vs-github"');
48 // Visible labels for the three CTAs.
49 expect(body).toContain("Sign up free");
50 expect(body).toContain("Try the live demo");
51 expect(body).toContain("Compare to GitHub");
44 // Visible labels for the primary CTAs.
45 expect(body).toContain("Start building");
5246 });
5347
54 it("renders the three reasons-to-switch column headings", async () => {
48 it("renders the register and explore nav links", async () => {
5549 const res = await app.request(HOME);
5650 const body = await res.text();
57 expect(body).toContain("Toggle Sleep Mode");
58 expect(body).toContain("One command to migrate");
59 expect(body).toContain("Open the demo, watch it work");
60 // The migrate column also links to /import.
61 expect(body).toContain('href="/import"');
62 // The Sleep Mode + demo columns deep-link to their L1 / L3 routes.
63 expect(body).toContain('href="/sleep-mode"');
64 expect(body).toContain('href="/demo"');
51 expect(body).toContain('href="/register"');
52 expect(body).toContain('href="/explore"');
53 expect(body).toContain('href="/pricing"');
6554 });
6655
67 it("renders the 'How is this different' pull-quote", async () => {
56 it("renders the 'The git host' eyebrow or headline copy", async () => {
6857 const res = await app.request(HOME);
6958 const body = await res.text();
70 expect(body).toContain("How is this different from GitHub?");
71 expect(body).toContain("Every other host bolts AI on as a sidecar.");
72 // JSX collapses newlines + leading whitespace; assert on a substring
73 // that is contiguous after server-side rendering.
74 expect(body).toContain("first-class developer");
75 expect(body).toContain("Built to be");
76 expect(body).toContain("operated by AI agents");
77 expect(body).toContain("See the full comparison");
59 expect(body).toContain("AI-native git host");
7860 });
7961
8062 it("injects SEO + Open Graph meta tags", async () => {
8264 const body = await res.text();
8365 // <title>
8466 expect(body).toContain(
85 "<title>Gluecron — The git host built around Claude</title>"
67 "<title>Gluecron — The AI-native git host</title>"
8668 );
8769 // <meta name="description">
8870 expect(body).toMatch(
89 /<meta\s+name="description"\s+content="Label an issue\. Walk away\. Wake up to a merged PR\./
71 /<meta\s+name="description"\s+content="The AI-native git host\. Spec to PR in 90 seconds\./
9072 );
9173 // <meta property="og:title">
9274 expect(body).toMatch(
93 /<meta\s+property="og:title"\s+content="Gluecron — The git host built around Claude"/
75 /<meta\s+property="og:title"\s+content="Gluecron — The AI-native git host"/
9476 );
9577 // <meta property="og:description">
9678 expect(body).toMatch(
97 /<meta\s+property="og:description"\s+content="Label an issue\./
79 /<meta\s+property="og:description"\s+content="The AI-native git host\./
9880 );
9981 // <meta property="og:type">
10082 expect(body).toMatch(
128110 }
129111 });
130112
131 it("REGRESSION: L5 'Compare to GitHub' CTA still routes to /vs-github", async () => {
113 it("REGRESSION: pricing link is present in nav or page body", async () => {
132114 const res = await app.request(HOME);
133115 const body = await res.text();
134 // The href + label pair must remain wired.
135 expect(body).toContain('href="/vs-github"');
136 expect(body).toContain("Compare to GitHub");
116 // Pricing link must remain accessible from the home page.
117 expect(body).toContain('href="/pricing"');
137118 });
138119});
Modifiedsrc/__tests__/sleep-mode.test.ts+4−4View fileUnifiedSplit
234234 return {
235235 userId: "u-1",
236236 digestHourUtc: 9,
237 lastDigestSentAt: null,
237 lastSleepDigestSentAt: null,
238238 ...overrides,
239239 };
240240 }
279279 const old = new Date(sentinelNow.getTime() - 24 * 60 * 60 * 1000);
280280 const summary = await runSleepModeDigestTaskOnce({
281281 findCandidates: async () => [
282 cand({ userId: "recent-user", lastDigestSentAt: recent }),
283 cand({ userId: "old-user", lastDigestSentAt: old }),
284 cand({ userId: "never-user", lastDigestSentAt: null }),
282 cand({ userId: "recent-user", lastSleepDigestSentAt: recent }),
283 cand({ userId: "old-user", lastSleepDigestSentAt: old }),
284 cand({ userId: "never-user", lastSleepDigestSentAt: null }),
285285 ],
286286 sendOne: async (id) => {
287287 sent.push(id);
Modifiedsrc/app.tsx+68−0View fileUnifiedSplit
2727import settingsIntegrationsRoutes from "./routes/settings-integrations";
2828import integrationsChatRoutes from "./routes/integrations-chat";
2929import agentsRoutes from "./routes/agents";
30import agentPipelinesRoutes from "./routes/agent-pipelines";
3031import issueRoutes from "./routes/issues";
3132import commentModerationRoutes from "./routes/comment-moderation";
3233import repoSettings from "./routes/repo-settings";
4041import prSandboxRoutes from "./routes/pr-sandbox";
4142import devEnvRoutes from "./routes/dev-env";
4243import editorRoutes from "./routes/editor";
44import aiEditorRoutes from "./routes/ai-editor";
4345import forkRoutes from "./routes/fork";
4446import webhookRoutes from "./routes/webhooks";
4547import exploreRoutes from "./routes/explore";
5052import statusRoutes from "./routes/status";
5153import adminStatusRoutes from "./routes/admin-status";
5254import helpRoutes from "./routes/help";
55import changelogRoutes from "./routes/changelog";
56import docsRoutes from "./routes/docs";
5357import marketingRoutes from "./routes/marketing";
5458import pricingRoutes from "./routes/pricing";
59import enterpriseRoutes from "./routes/enterprise";
5560import seoRoutes from "./routes/seo";
5661import versionRoutes from "./routes/version";
5762import { platformStatus } from "./routes/platform-status";
6873import importRoutes from "./routes/import";
6974import importBulkRoutes from "./routes/import-bulk";
7075import importSecretsRoutes from "./routes/import-secrets";
76import actionsImporterRoutes from "./routes/actions-importer";
7177import migrationRoutes from "./routes/migrations";
78import migrateRoutes from "./routes/migrate";
7279import specsRoutes from "./routes/specs";
7380import refactorRoutes from "./routes/refactors";
7481import webRoutes from "./routes/web";
8693import notificationRoutes from "./routes/notifications";
8794import onboardingRoutes from "./routes/onboarding";
8895import adminRoutes from "./routes/admin";
96import adminDeletionsRoutes from "./routes/admin-deletions";
97import adminStripeRoutes from "./routes/admin-stripe";
8998import adminDeploysRoutes from "./routes/admin-deploys";
9099import adminDeploysPageRoutes from "./routes/admin-deploys-page";
91100import adminServerTargetsRoutes from "./routes/admin-server-targets";
101import deployTargetsRoutes from "./routes/deploy-targets";
92102import claudeWebRoutes from "./routes/claude-web";
93103import adminOpsRoutes from "./routes/admin-ops";
94104import adminSelfHostRoutes from "./routes/admin-self-host";
95105import adminDiagnoseRoutes from "./routes/admin-diagnose";
96106import adminIntegrationsRoutes from "./routes/admin-integrations";
97107import adminAdvancementRoutes from "./routes/admin-advancement";
108import adminSecurityRoutes from "./routes/admin-security";
109import settingsSessionsRoutes from "./routes/settings-sessions";
98110import advisoriesRoutes from "./routes/advisories";
99111import aiChangelogRoutes from "./routes/ai-changelog";
112import explainRoutes from "./routes/explain";
100113import aiExplainRoutes from "./routes/ai-explain";
101114import aiTestsRoutes from "./routes/ai-tests";
102115import askRoutes from "./routes/ask";
106119import billingUsageRoutes from "./routes/billing-usage";
107120import stripeWebhookRoutes from "./routes/stripe-webhook";
108121import codeScanningRoutes from "./routes/code-scanning";
122import securityRoutes from "./routes/security";
109123import commitStatusesRoutes from "./routes/commit-statuses";
110124import copilotRoutes from "./routes/copilot";
111125import depUpdaterRoutes from "./routes/dep-updater";
126140import orgInsightsRoutes from "./routes/org-insights";
127141import packagesRoutes from "./routes/packages";
128142import packagesApiRoutes from "./routes/packages-api";
143import ociRegistryRoutes from "./routes/oci-registry";
129144import pagesRoutes from "./routes/pages";
130145import projectsRoutes from "./routes/projects";
131146import protectedTagsRoutes from "./routes/protected-tags";
159174import standupRoutes from "./routes/standups";
160175import vsGithubRoutes from "./routes/vs-github";
161176import voiceRoutes from "./routes/voice-to-pr";
177import blogRoutes from "./routes/blog";
162178import playgroundRoutes from "./routes/playground";
163179import crossRepoSearchRoutes from "./routes/cross-repo-search";
164180import pushNotifRoutes from "./routes/push-notifications";
167183import pulseRoutes from "./routes/pulse";
168184import healthScoreRoutes from "./routes/health-score";
169185import hotFilesRoutes from "./routes/hot-files";
186import developerProgramRoutes from "./routes/developer-program";
187import shareRoutes from "./routes/share";
170188import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
171189import { csrfToken, csrfProtect } from "./middleware/csrf";
172190import { noCache } from "./middleware/no-cache";
243261 p === "/explore" ||
244262 p === "/help" ||
245263 p === "/changelog" ||
264 p === "/enterprise" ||
246265 p.startsWith("/legal/") ||
247266 p === "/terms" ||
248267 p === "/privacy" ||
369388// Mounted alongside apiV2Routes (its own basePath, no path conflict).
370389app.route("/", agentsRoutes);
371390
391// Multi-agent pipeline UI — /:owner/:repo/agents (list, new, live view).
392// Must be before webRoutes (the catch-all) so the /agents sub-paths are
393// resolved before the generic tree/blob route takes over.
394app.route("/", agentPipelinesRoutes);
395
372396// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
373397app.route("/", hookRoutes);
374398app.route("/api/events", eventsRoutes);
403427// Settings routes (profile, SSH keys)
404428app.route("/", settingsRoutes);
405429
430// Session management (SOC 2 CC6.1 — /settings/sessions)
431app.route("/", settingsSessionsRoutes);
432
406433// 2FA / TOTP settings (Block B4)
407434app.route("/", settings2faRoutes);
408435
492519// Web file editor
493520app.route("/", editorRoutes);
494521
522// AI editor API routes (inline suggestions, explain, fix)
523app.route("/", aiEditorRoutes);
524
495525// Contributors
496526app.route("/", contributorRoutes);
497527
518548// /help — quickstart + API cheatsheet
519549app.route("/", helpRoutes);
520550
551// /docs — expanded documentation site (getting started, workflow YAML, MCP, API, agents)
552app.route("/", docsRoutes);
553
521554// L8 — public /pricing page (free-tier polish). Mounted BEFORE marketing
522555// so the new editorial pricing layout wins the route; the legacy marketing
523556// pricing remains as a safety net but is shadowed at the router.
524557app.route("/", pricingRoutes);
525558
559// /changelog — manually curated platform release history
560app.route("/", changelogRoutes);
561
562// Enterprise sales page + contact form lead capture.
563app.route("/", enterpriseRoutes);
564
526565// /pricing, /features, /about — marketing surface
527566app.route("/", marketingRoutes);
528567
568// /developer-program — partner + marketplace revenue-share page
569app.route("/", developerProgramRoutes);
570
529571// SEO: robots.txt + sitemap.xml
530572app.route("/", seoRoutes);
531573
569611app.route("/", importRoutes);
570612app.route("/", importBulkRoutes);
571613app.route("/", importSecretsRoutes);
614// GitHub Actions → Gluecron gates.yml importer (stateless converter)
615app.route("/", actionsImporterRoutes);
572616app.route("/", migrationRoutes);
617// GitHub Org Migration Wizard — live progress bulk importer
618app.route("/", migrateRoutes);
573619
574620// Spec-to-PR (experimental AI-generated draft PRs)
575621app.route("/", specsRoutes);
583629
584630// Admin + feature routes
585631app.route("/", adminRoutes);
632app.route("/", adminDeletionsRoutes);
633app.route("/", adminStripeRoutes);
634
635// SOC 2 security dashboard + readiness checklist (/admin/security, /admin/soc2)
636app.route("/", adminSecurityRoutes);
586637app.route("/", adminIntegrationsRoutes);
587638app.route("/", adminAdvancementRoutes);
588639app.route("/", adminDeploysRoutes);
589640app.route("/", adminDeploysPageRoutes);
590641app.route("/", adminServerTargetsRoutes);
642app.route("/", deployTargetsRoutes);
591643app.route("/", claudeWebRoutes);
592644// Note: adminOpsRoutes is mounted earlier (before insightRoutes) — see comment above.
593645app.route("/", advisoriesRoutes);
594646app.route("/", aiChangelogRoutes);
647// "Explain This Repo" — rich structured AI analysis dashboard (mounted before
648// the simpler aiExplainRoutes so the new routes win on /:owner/:repo/explain).
649app.route("/", explainRoutes);
595650app.route("/", aiExplainRoutes);
596651app.route("/", aiTestsRoutes);
597652app.route("/", askRoutes);
603658app.route("/", billingUsageRoutes);
604659app.route("/", stripeWebhookRoutes);
605660app.route("/", codeScanningRoutes);
661// Dependency CVE scanner findings page — /:owner/:repo/security/vulnerabilities
662app.route("/", securityRoutes);
606663app.route("/", commitStatusesRoutes);
607664app.route("/", copilotRoutes);
608665app.route("/", depUpdaterRoutes);
623680app.route("/", orgInsightsRoutes);
624681app.route("/", packagesRoutes);
625682app.route("/", packagesApiRoutes);
683// OCI / Docker container registry — /v2/* (OCI Distribution Spec v1.0)
684app.route("/", ociRegistryRoutes);
626685app.route("/", pagesRoutes);
627686app.route("/", projectsRoutes);
628687app.route("/", protectedTagsRoutes);
681740// Voice-to-PR — phone-first dictation → spec or issue
682741app.route("/", voiceRoutes);
683742
743// Blog / Devlog — public posts, no DB
744app.route("/", blogRoutes);
745
684746// Block Q3 — Anonymous playground (`/play`, `/play/claim`). Mounted
685747// before the web catch-all so the bare `/play` literal wins over the
686748// `/:owner` user-profile route.
687749app.route("/", playgroundRoutes);
688750
751// Shareable AI hours-saved OG image card + landing page.
752// /share/hours-saved?user=:username → 1200×630 SVG (og:image)
753// /share/:username → HTML page with og:image meta + Twitter share
754// Mounted BEFORE webRoutes so /share/* doesn't fall through to the /:owner profile route.
755app.route("/", shareRoutes);
756
689757// Web UI (catch-all, must be last)
690758app.route("/", webRoutes);
691759
Modifiedsrc/db/schema.ts+240−4View fileUnifiedSplit
7979 lastDigestSentAt: timestamp("last_digest_sent_at"),
8080 // Block L1 — Sleep Mode. When enabled, the autopilot sleep-mode-digest
8181 // task delivers a daily "what Claude shipped overnight" report at the
82 // user-configured UTC hour (0-23, default 9). Reuses lastDigestSentAt
83 // as the 23h cooldown anchor — the cooldown is shared with the weekly
84 // digest, so a user cannot receive both on the same day.
82 // user-configured UTC hour (0-23, default 9). Uses its own independent
83 // cooldown anchor (lastSleepDigestSentAt) so the weekly digest timer is
84 // not reset when a sleep-mode digest fires, and vice versa.
85 // Migration 0077 adds the column.
86 lastSleepDigestSentAt: timestamp("last_sleep_digest_sent_at"),
8587 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
8688 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
8789 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
123125 personalSemanticIndexEnabled: boolean("personal_semantic_index_enabled")
124126 .default(false)
125127 .notNull(),
128 // Onboarding drip sequence (migration 0081). Stores a JSON array of string
129 // keys for emails already delivered, e.g. ["welcome","day1","day3"].
130 // The autopilot `onboarding-drip` task compares this against the canonical
131 // drip schedule and sends any outstanding emails. Never null — defaults to
132 // an empty array at insert time.
133 onboardingEmailsSent: jsonb("onboarding_emails_sent").$type<string[]>().default([]).notNull(),
126134 createdAt: timestamp("created_at").defaultNow().notNull(),
127135 updatedAt: timestamp("updated_at").defaultNow().notNull(),
128136});
138146 // code. softAuth/requireAuth treat such sessions as anonymous; only
139147 // /login/2fa can consume them. Flips to false on successful 2FA.
140148 requires2fa: boolean("requires_2fa").default(false).notNull(),
149 // Migration 0077 — SOC 2 session visibility. Populated at login and
150 // refreshed on each authenticated request (best-effort, non-blocking).
151 ip: text("ip"),
152 userAgent: text("user_agent"),
153 lastSeenAt: timestamp("last_seen_at"),
141154 createdAt: timestamp("created_at").defaultNow().notNull(),
142155});
143156
157/**
158 * Login attempt log — SOC 2 CC6.1 account-lockout evidence.
159 * Records every login attempt (success or failure) per email + IP.
160 * After 10 failures within 1 hour the login handler blocks the email
161 * for 15 minutes and emits `auth.login.locked` to the audit log.
162 * Migration 0078.
163 */
164export const loginAttempts = pgTable(
165 "login_attempts",
166 {
167 id: uuid("id").primaryKey().defaultRandom(),
168 email: text("email").notNull(),
169 ip: text("ip").notNull(),
170 success: boolean("success").notNull().default(false),
171 createdAt: timestamp("created_at").defaultNow().notNull(),
172 },
173 (table) => [
174 index("login_attempts_email_created").on(table.email, table.createdAt),
175 index("login_attempts_ip_created").on(table.ip, table.createdAt),
176 ]
177);
178
179export type LoginAttempt = typeof loginAttempts.$inferSelect;
180
144181// @ts-ignore — self-referential FK on forkedFromId causes circular inference
145182export const repositories = pgTable(
146183 "repositories",
196233 // env burns a container until the idle sweep tears it down; owners
197234 // must explicitly enable per-repo via repo-settings.
198235 devEnvsEnabled: boolean("dev_envs_enabled").default(false).notNull(),
236 dataRegion: text("data_region").default("us").notNull(),
237 previewBuildCommand: text("preview_build_command"),
238 previewOutputDir: text("preview_output_dir").default("dist"),
199239 },
200240 (table) => [
201241 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
854894 createdAt: timestamp("created_at").defaultNow().notNull(),
855895});
856896
897// OCI Container Registry — migration 0083_oci_registry.sql
898// oci_repositories tracks image namespaces (e.g. "alice/myapp").
899// oci_tags maps mutable tag names to a manifest digest for a repository.
900
901export const ociRepositories = pgTable(
902 "oci_repositories",
903 {
904 id: uuid("id").primaryKey().defaultRandom(),
905 ownerId: uuid("owner_id")
906 .notNull()
907 .references(() => users.id, { onDelete: "cascade" }),
908 /** Full image name in "<owner>/<image>" format. */
909 name: text("name").notNull(),
910 visibility: text("visibility").notNull().default("private"), // "public" | "private"
911 createdAt: timestamp("created_at").defaultNow().notNull(),
912 },
913 (table) => [
914 uniqueIndex("oci_repositories_owner_name").on(table.ownerId, table.name),
915 index("idx_oci_repositories_owner").on(table.ownerId),
916 ]
917);
918
919export const ociTags = pgTable(
920 "oci_tags",
921 {
922 id: uuid("id").primaryKey().defaultRandom(),
923 repositoryId: uuid("repository_id")
924 .notNull()
925 .references(() => ociRepositories.id, { onDelete: "cascade" }),
926 tag: text("tag").notNull(),
927 manifestDigest: text("manifest_digest").notNull(), // "sha256:<hex64>"
928 createdAt: timestamp("created_at").defaultNow().notNull(),
929 updatedAt: timestamp("updated_at").defaultNow().notNull(),
930 },
931 (table) => [
932 uniqueIndex("oci_tags_repo_tag").on(table.repositoryId, table.tag),
933 index("idx_oci_tags_repo").on(table.repositoryId),
934 ]
935);
936
937export type OciRepository = typeof ociRepositories.$inferSelect;
938export type NewOciRepository = typeof ociRepositories.$inferInsert;
939export type OciTag = typeof ociTags.$inferSelect;
940export type NewOciTag = typeof ociTags.$inferInsert;
941
857942// Block M2 — Web Push subscriptions. One row per (user, endpoint).
858943// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
859944// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
16021687export type DepUpdateRun = typeof depUpdateRuns.$inferSelect;
16031688
16041689// ---------------------------------------------------------------------------
1605// Block E2 — Discussions (migration 0013)
1690// Migration 0077 — repo_explain_cache: structured AI analysis per repo
1691// ---------------------------------------------------------------------------
1692
1693export const repoExplainCache = pgTable("repo_explain_cache", {
1694 id: serial("id").primaryKey(),
1695 repoId: uuid("repo_id")
1696 .notNull()
1697 .unique()
1698 .references(() => repositories.id, { onDelete: "cascade" }),
1699 result: jsonb("result").notNull(),
1700 createdAt: timestamp("created_at").defaultNow(),
1701});
1702
1703export type RepoExplainCache = typeof repoExplainCache.$inferSelect;
1704
1705// ---------------------------------------------------------------------------
1706// Block E2 — Discussions (migration 0013 + 0077)
16061707// ---------------------------------------------------------------------------
16071708
1709/**
1710 * Per-repo discussion categories (migration 0077).
1711 * Seeded lazily on first discussion creation: General, Q&A, Announcements, Ideas.
1712 * is_answerable = true surfaces "Mark as answer" on threads in that category.
1713 */
1714export const discussionCategories = pgTable(
1715 "discussion_categories",
1716 {
1717 id: serial("id").primaryKey(),
1718 repositoryId: uuid("repository_id")
1719 .notNull()
1720 .references(() => repositories.id, { onDelete: "cascade" }),
1721 name: text("name").notNull(),
1722 emoji: text("emoji").notNull().default("💬"),
1723 description: text("description"),
1724 isAnswerable: boolean("is_answerable").notNull().default(false),
1725 },
1726 (table) => [index("discussion_categories_repo").on(table.repositoryId)]
1727);
1728
1729export type DiscussionCategory = typeof discussionCategories.$inferSelect;
1730
16081731/**
16091732 * Discussions — forum-style threaded conversations attached to a repo.
16101733 * Similar to GitHub Discussions: categorised + pinnable + answerable.
40174140);
40184141
40194142export type ClaudeWebMessage = typeof claudeWebMessages.$inferSelect;
4143
4144// ---------------------------------------------------------------------------
4145// 0077 — Status page: incident history + subscriber list.
4146//
4147// incidents: manually-filed or autopilot-detected outage records shown on
4148// the public /status page. severity: 'minor' | 'major' | 'critical'.
4149// status: 'investigating' | 'identified' | 'monitoring' | 'resolved'.
4150//
4151// status_subscribers: email addresses that have opted-in to receive alerts
4152// when a new incident is filed.
4153// ---------------------------------------------------------------------------
4154export const incidents = pgTable(
4155 "incidents",
4156 {
4157 id: uuid("id").primaryKey().defaultRandom(),
4158 title: text("title").notNull(),
4159 severity: text("severity").notNull().default("minor"),
4160 status: text("status").notNull().default("resolved"),
4161 startedAt: timestamp("started_at", { withTimezone: true })
4162 .defaultNow()
4163 .notNull(),
4164 resolvedAt: timestamp("resolved_at", { withTimezone: true }),
4165 body: text("body"),
4166 createdAt: timestamp("created_at", { withTimezone: true })
4167 .defaultNow()
4168 .notNull(),
4169 updatedAt: timestamp("updated_at", { withTimezone: true })
4170 .defaultNow()
4171 .notNull(),
4172 },
4173 (table) => [
4174 index("idx_incidents_started_at").on(table.startedAt),
4175 index("idx_incidents_status").on(table.status),
4176 ]
4177);
4178
4179export type Incident = typeof incidents.$inferSelect;
4180export type NewIncident = typeof incidents.$inferInsert;
4181
4182export const statusSubscribers = pgTable(
4183 "status_subscribers",
4184 {
4185 id: uuid("id").primaryKey().defaultRandom(),
4186 email: text("email").notNull().unique(),
4187 confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
4188 confirmToken: text("confirm_token").unique(),
4189 unsubscribeToken: text("unsubscribe_token").unique(),
4190 createdAt: timestamp("created_at", { withTimezone: true })
4191 .defaultNow()
4192 .notNull(),
4193 },
4194 (table) => [
4195 index("idx_status_subscribers_email").on(table.email),
4196 ]
4197);
4198
4199export type StatusSubscriber = typeof statusSubscribers.$inferSelect;
4200export type NewStatusSubscriber = typeof statusSubscribers.$inferInsert;
40204201export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
4202
4203// ---------------------------------------------------------------------------
4204// Enterprise leads (contact form submissions from /enterprise)
4205// ---------------------------------------------------------------------------
4206
4207export const enterpriseLeads = pgTable(
4208 "enterprise_leads",
4209 {
4210 id: uuid("id").primaryKey().defaultRandom(),
4211 name: text("name").notNull(),
4212 company: text("company").notNull(),
4213 email: text("email").notNull(),
4214 teamSize: text("team_size").notNull(),
4215 message: text("message"),
4216 ip: text("ip"),
4217 createdAt: timestamp("created_at").defaultNow().notNull(),
4218 },
4219 (table) => [index("enterprise_leads_created").on(table.createdAt)]
4220);
4221
4222export type EnterpriseLead = typeof enterpriseLeads.$inferSelect;
4223export type NewEnterpriseLead = typeof enterpriseLeads.$inferInsert;
4224
4225// ---------------------------------------------------------------------------
4226// PR preview builder — one row per (pr_id, head_sha)
4227// ---------------------------------------------------------------------------
4228export const prPreviews = pgTable(
4229 "pr_previews",
4230 {
4231 id: serial("id").primaryKey(),
4232 repoId: uuid("repo_id")
4233 .notNull()
4234 .references(() => repositories.id, { onDelete: "cascade" }),
4235 prId: uuid("pr_id")
4236 .notNull()
4237 .references(() => pullRequests.id, { onDelete: "cascade" }),
4238 branchName: text("branch_name").notNull(),
4239 headSha: text("head_sha").notNull(),
4240 status: text("status").notNull().default("building"),
4241 buildLog: text("build_log"),
4242 previewUrl: text("preview_url"),
4243 buildCommand: text("build_command"),
4244 outputDir: text("output_dir").default("dist"),
4245 buildDurationMs: integer("build_duration_ms"),
4246 createdAt: timestamp("created_at").defaultNow(),
4247 updatedAt: timestamp("updated_at").defaultNow(),
4248 },
4249 (table) => [
4250 index("pr_previews_pr_id_idx").on(table.prId),
4251 index("pr_previews_repo_id_idx").on(table.repoId),
4252 ]
4253);
4254
4255export type PrPreview = typeof prPreviews.$inferSelect;
4256export type NewPrPreview = typeof prPreviews.$inferInsert;
Modifiedsrc/git/protocol.ts+3−1View fileUnifiedSplit
5454 owner: string,
5555 repo: string,
5656 service: string,
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null
57 body: ReadableStream<Uint8Array> | ArrayBuffer | null,
58 extraEnv?: Record<string, string>
5859): Promise<Response> {
5960 const repoDir = getRepoPath(owner, repo);
6061 const inputBytes = body
6768 stdin: "pipe",
6869 stdout: "pipe",
6970 stderr: "pipe",
71 env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
7072 });
7173
7274 proc.stdin.write(inputBytes);
Modifiedsrc/hooks/post-receive.ts+81−1View fileUnifiedSplit
2525 getRepoPath,
2626} from "../git/repository";
2727import { indexChangedFiles } from "../lib/semantic-index";
28import { scanDiffForIssues } from "../lib/ai-auto-issues";
2829import { enqueuePreviewBuild } from "../lib/branch-previews";
30import { scanDependencies } from "../lib/dependency-scanner";
2931import { runDocDriftCheckForRepo } from "../lib/ai-doc-updater";
3032import {
3133 findTargetsForPush,
4446export async function onPostReceive(
4547 owner: string,
4648 repo: string,
47 refs: PushRef[]
49 refs: PushRef[],
50 pusherUserId: string = ""
4851): Promise<void> {
4952 for (const ref of refs) {
5053 if (ref.newSha.startsWith("0000")) continue; // Branch deletion
124127 console.warn("[branch-previews] dispatch error:", err)
125128 );
126129
130 // 4e. AI Auto-Issue Opener. Scans the diff for each pushed ref for
131 // TODO/FIXME/HACK comments, hardcoded secrets, SQL injection patterns,
132 // and debug console.log calls. Opens one issue per finding type per
133 // file (capped at MAX_ISSUES_PER_PUSH). Gated on AI_AUTO_ISSUES=1.
134 // Fire-and-forget; never blocks the push path.
135 for (const ref of refs) {
136 if (ref.newSha.startsWith("0000")) continue;
137 scanDiffForIssues(owner, repo, ref.oldSha, ref.newSha, pusherUserId).catch(
138 (err) => console.warn("[ai-auto-issues] dispatch error:", err)
139 );
140 }
141
142 // 4f. Dependency CVE scanner — when DEPENDENCY_SCAN_ENABLED=1, scan
143 // every push that touches a recognized manifest file (package.json,
144 // requirements.txt, Cargo.toml, go.mod, Gemfile). Auto-opens issues
145 // for critical/high findings and updates a weekly digest for
146 // medium/low. Fire-and-forget; never blocks the push path.
147 if (config.dependencyScanEnabled) {
148 void fireDependencyScan(owner, repo, refs).catch((err) =>
149 console.warn("[dependency-scanner] dispatch error:", err)
150 );
151 }
152
127153 // 4d. AI-tracked documentation drift check (migration 0068). Walks the
128154 // repo's markdown files for `<!-- gluecron:doc-track ... -->`
129155 // regions, hashes the referenced source, and opens a PR labelled
701727 );
702728}
703729
730/**
731 * Fire-and-forget wrapper around scanDependencies for each pushed ref.
732 * Resolves the repo DB row once, then runs the scanner on each live push.
733 * Swallows all errors so a scanner failure never affects the push path.
734 */
735async function fireDependencyScan(
736 owner: string,
737 repo: string,
738 refs: PushRef[]
739): Promise<void> {
740 const liveRefs = refs.filter(
741 (r) => r.refName.startsWith("refs/heads/") && !r.newSha.startsWith("0000")
742 );
743 if (liveRefs.length === 0) return;
744
745 let repoRow: { id: string; ownerId: string } | null = null;
746 try {
747 const [row] = await db
748 .select({ id: repositories.id, ownerId: repositories.ownerId })
749 .from(repositories)
750 .innerJoin(users, eq(repositories.ownerId, users.id))
751 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
752 .limit(1);
753 repoRow = row || null;
754 } catch {
755 return;
756 }
757 if (!repoRow) return;
758
759 for (const ref of liveRefs) {
760 try {
761 const findings = await scanDependencies(
762 repoRow.id,
763 owner,
764 repo,
765 ref.newSha,
766 ref.oldSha,
767 repoRow.ownerId
768 );
769 if (findings.length > 0) {
770 console.log(
771 `[dependency-scanner] ${owner}/${repo}@${ref.newSha.slice(0, 7)}: ${findings.length} finding(s)`
772 );
773 }
774 } catch (err) {
775 console.warn(
776 `[dependency-scanner] scan threw for ${owner}/${repo}@${ref.newSha.slice(0, 7)}:`,
777 err instanceof Error ? err.message : err
778 );
779 }
780 }
781}
782
704783/** Test-only access to internal helpers. */
705784export const __test = {
706785 triggerCrontechDeploy,
712791 firePreviewBuilds,
713792 fireDocDriftCheck,
714793 fireServerTargetDeploys,
794 fireDependencyScan,
715795};
Modifiedsrc/lib/account-deletion.ts+72−1View fileUnifiedSplit
1919 * tick. Per-user errors are logged + skipped — never thrown — so a
2020 * single FK violation can't stall the queue.
2121 *
22 * Cascade executed by `purgeScheduledAccounts` for each user:
23 * - Cancel any active Stripe subscription (if STRIPE_SECRET_KEY is set).
24 * - Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
25 * - Hard-delete the `users` row; FK ON DELETE CASCADE handles:
26 * sessions, ssh_keys, api_tokens, notifications, stars, reactions,
27 * ai_chats, push_subscriptions, oauth_access_tokens, gists, etc.
28 * - audit_log.user_id is already set to ON DELETE SET NULL at the DB level,
29 * so audit rows are automatically anonymised when the user row is removed.
30 *
2231 * Nothing here throws. All DB / email failures are logged and swallowed.
2332 */
2433
2534import { eq, lt } from "drizzle-orm";
35import { rm } from "fs/promises";
36import { join } from "path";
2637import { db } from "../db";
27import { sessions, users } from "../db/schema";
38import { repositories, sessions, userQuotas, users } from "../db/schema";
2839import { sendEmail, absoluteUrl } from "./email";
2940import { audit } from "./notify";
3041
124135 return { ok: true };
125136}
126137
138/**
139 * Cancel a Stripe subscription at period end for GDPR purge.
140 * Silently swallows all errors — a Stripe outage must never block deletion.
141 */
142async function cancelStripeSubscription(subscriptionId: string): Promise<void> {
143 const key = process.env.STRIPE_SECRET_KEY;
144 if (!key || key.length < 10) return;
145 try {
146 await fetch(`https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, {
147 method: "DELETE",
148 headers: {
149 Authorization: `Bearer ${key}`,
150 "Content-Type": "application/x-www-form-urlencoded",
151 },
152 });
153 } catch (err) {
154 console.error(`[account-deletion] stripe cancel failed for sub=${subscriptionId}:`, err);
155 }
156}
157
127158export async function purgeScheduledAccounts(
128159 opts: { now?: Date; cap?: number } = {}
129160): Promise<{ purged: number; errors: number }> {
150181 let errors = 0;
151182 for (const c of candidates) {
152183 try {
184 // 1. Cancel any active Stripe subscription before removing the user row.
185 try {
186 const quotaRows = await db
187 .select({ stripeSubscriptionId: userQuotas.stripeSubscriptionId })
188 .from(userQuotas)
189 .where(eq(userQuotas.userId, c.id))
190 .limit(1);
191 const subId = quotaRows[0]?.stripeSubscriptionId;
192 if (subId) {
193 await cancelStripeSubscription(subId);
194 }
195 } catch (err) {
196 console.error(`[account-deletion] quota lookup failed for user=${c.id}:`, err);
197 }
198
199 // 2. Delete bare git repo directories from disk (GIT_REPOS_PATH/<username>/).
200 // We collect all repo diskPaths owned by this user and remove each one.
201 try {
202 const repoRows = await db
203 .select({ diskPath: repositories.diskPath })
204 .from(repositories)
205 .where(eq(repositories.ownerId, c.id));
206 for (const r of repoRows) {
207 const absPath = r.diskPath.startsWith("/")
208 ? r.diskPath
209 : join(process.env.GIT_REPOS_PATH || "./repos", r.diskPath);
210 await rm(absPath, { recursive: true, force: true });
211 }
212 // Also remove the per-user directory if it exists (may be empty or already gone).
213 const userDir = join(process.env.GIT_REPOS_PATH || "./repos", c.username);
214 await rm(userDir, { recursive: true, force: true });
215 } catch (err) {
216 console.error(`[account-deletion] disk cleanup failed for user=${c.id}:`, err);
217 }
218
219 // 3. Hard-delete the users row.
220 // FK ON DELETE CASCADE handles: sessions, ssh_keys, api_tokens,
221 // notifications, stars, reactions, ai_chats, push_subscriptions,
222 // oauth_access_tokens, gists, user_quotas, user_totp, user_passkeys, etc.
223 // FK ON DELETE SET NULL handles: audit_log.user_id (anonymises entries).
153224 const deleted = await db
154225 .delete(users)
155226 .where(eq(users.id, c.id))
Modifiedsrc/lib/actions/cache-action.ts+118−13View fileUnifiedSplit
11/**
2 * `gluecron/cache@v1`RESTORE-only cache action (v1 scope).
2 * `gluecron/cache@v1` — cache action with RESTORE (load) and SAVE sides.
33 *
4 * Looks up `workflow_run_cache` by (repoId, key, scope='repo') and unpacks
5 * the stored tar archive into `ctx.workspace/<path>`. If no exact key hit,
6 * tries each `restoreKeys` entry as a prefix match ordered by most-recently
7 * used. Sets `cache-hit` output to 'true' or 'false'.
4 * RESTORE: looks up `workflow_run_cache` by (repoId, key, scope='repo') and
5 * unpacks the stored tar archive into `ctx.workspace/<path>`. If no exact key
6 * hit, tries each `restoreKeys` entry as a prefix match ordered by
7 * most-recently used. Sets `cache-hit` output to 'true' or 'false'.
88 *
9 * TODO (v2): cache SAVE on job success. The deferred design is for the
10 * runner to honor a `save-cache: true` flag emitted by this action and
11 * call a `saveCache(ctx, key, path)` helper at end-of-job. Implementing
12 * save inline here is error-prone (we'd need a post-hook) and the spec
13 * explicitly endorsed shipping restore-only for v1. Size cap logic is
14 * stubbed below so it's trivial to wire up later.
9 * SAVE: called by the workflow runner after a job's steps all succeed.
10 * Tarballs the `path` list relative to `workdir` and upserts the archive into
11 * `workflow_run_cache`. On key conflict the existing row is replaced so the
12 * runner always ends up with the freshest tarball for a given key. Size cap is
13 * enforced before the DB write; oversize payloads are logged and silently
14 * dropped so CI never fails due to cache infrastructure.
1515 *
1616 * Failure tolerance: any error — DB miss, tar failure, unknown scope —
17 * results in `cache-hit: false` with exitCode 0. Caching is an optimization;
18 * losing it must never break a pipeline.
17 * results in `cache-hit: false` (on restore) or a silent no-op (on save).
18 * Caching is an optimization; losing it must never break a pipeline.
1919 */
2020
2121import { and, eq, isNull, sql } from "drizzle-orm";
178178 }
179179}
180180
181/**
182 * Pack `paths` (relative to `workdir`) into a gzip-compressed tar archive and
183 * upsert it into `workflow_run_cache` under `key` for `repoId`.
184 *
185 * On key conflict the existing row is replaced. This mirrors how GitHub Actions
186 * handles re-runs with the same key: the freshest content wins.
187 *
188 * The archive is written to a temp file first (avoids EAGAIN edge cases on
189 * Bun's subprocess stdin pipe) then read back into a Buffer for the DB write.
190 * Callers must treat any thrown error as non-fatal — caching failures must
191 * never abort a pipeline.
192 */
193export async function saveCacheEntry(
194 repoId: string,
195 key: string,
196 paths: string[],
197 workdir: string
198): Promise<void> {
199 const validPaths = paths.filter((p) => typeof p === "string" && p.length > 0);
200 if (!key || validPaths.length === 0) return;
201
202 const tmpPath = join(
203 tmpdir(),
204 `gluecron-cache-save-${Date.now()}-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}.tar.gz`
205 );
206
207 try {
208 const proc = Bun.spawn(
209 ["tar", "-czf", tmpPath, "--", ...validPaths],
210 {
211 cwd: workdir,
212 stdout: "pipe",
213 stderr: "pipe",
214 }
215 );
216 await proc.exited;
217
218 const file = Bun.file(tmpPath);
219 const exists = await file.exists();
220 if (!exists) return;
221
222 const arrayBuf = await file.arrayBuffer();
223 const content = Buffer.from(arrayBuf);
224
225 // Drop oversized archives silently so a large workspace never blocks CI.
226 if (content.byteLength > MAX_CACHE_BYTES) {
227 console.warn(
228 `[cache-action] save skipped: archive for key=${key} is ${content.byteLength} bytes, exceeds cap ${MAX_CACHE_BYTES}`
229 );
230 return;
231 }
232
233 const contentHash = Buffer.from(
234 await crypto.subtle.digest("SHA-256", content)
235 ).toString("hex");
236
237 // Manual upsert: the unique index includes nullable `scope_ref` so
238 // Postgres `ON CONFLICT (…, scope_ref)` won't fire when scope_ref IS NULL
239 // (each NULL is distinct in a standard B-tree index). We do a targeted
240 // UPDATE first; if zero rows were touched we INSERT instead.
241 const now = new Date();
242 const existing = await db
243 .select({ id: workflowRunCache.id })
244 .from(workflowRunCache)
245 .where(
246 and(
247 eq(workflowRunCache.repositoryId, repoId),
248 eq(workflowRunCache.cacheKey, key),
249 eq(workflowRunCache.scope, "repo"),
250 isNull(workflowRunCache.scopeRef)
251 )
252 )
253 .limit(1);
254
255 if (existing[0]) {
256 await db
257 .update(workflowRunCache)
258 .set({
259 content,
260 contentHash,
261 sizeBytes: content.byteLength,
262 lastAccessedAt: now,
263 })
264 .where(eq(workflowRunCache.id, existing[0].id));
265 } else {
266 await db.insert(workflowRunCache).values({
267 repositoryId: repoId,
268 cacheKey: key,
269 scope: "repo",
270 scopeRef: null,
271 content,
272 contentHash,
273 sizeBytes: content.byteLength,
274 lastAccessedAt: now,
275 });
276 }
277 } finally {
278 await import("fs/promises")
279 .then((fs) => fs.unlink(tmpPath))
280 .catch(() => {
281 // Best-effort cleanup.
282 });
283 }
284}
285
181286export const cacheAction: ActionHandler = {
182287 name: "gluecron/cache",
183288 version: "v1",
Addedsrc/lib/ai-auto-issues.ts+655−0View fileUnifiedSplit
1/**
2 * AI Auto-Issue Opener — scans every git push diff for common code quality
3 * and security signals, then automatically opens issues for any findings.
4 *
5 * Feature is gated on env var `AI_AUTO_ISSUES=1`. If unset, the entry point
6 * returns immediately. Never throws — all failures are caught so the push
7 * path is never blocked.
8 *
9 * Patterns detected:
10 * - TODO / FIXME / HACK / XXX / BUG / OPTIMIZE comments
11 * - Hardcoded secrets (password=, api_key=, token=, etc.)
12 * - SQL injection vectors (template literals inside SQL keywords)
13 * - Debug console.log/debug/info calls left in production code
14 *
15 * Rate limiting: maximum MAX_ISSUES_PER_PUSH issues per push. If more findings
16 * exist, a single summary issue is opened instead of the individual ones.
17 */
18
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import {
22 issues,
23 issueLabels,
24 labels,
25 repositories,
26 users,
27} from "../db/schema";
28import { getRepoPath } from "../git/repository";
29
30// ---------------------------------------------------------------------------
31// Diff scanning patterns
32// ---------------------------------------------------------------------------
33
34const TODO_PATTERN = /^\+.*\b(TODO|FIXME|HACK|XXX|BUG|OPTIMIZE)\b.*$/gm;
35const SECRET_PATTERN =
36 /^\+.*(password|secret|api_key|apikey|token|private_key|privatekey)\s*=\s*["'][^"']{8,}/gim;
37const SQL_INJECTION_PATTERN =
38 /^\+.*\$\{.*\}.*(?:SELECT|INSERT|UPDATE|DELETE|DROP)/gim;
39const CONSOLE_LOG_PATTERN = /^\+.*console\.(log|debug|info)\(/gm;
40
41type FindingType = "todo" | "secret" | "sql-injection" | "console-log";
42
43interface Finding {
44 type: FindingType;
45 filePath: string;
46 lineNumber: number;
47 matchText: string;
48}
49
50// ---------------------------------------------------------------------------
51// Constants
52// ---------------------------------------------------------------------------
53
54/** Maximum auto-issues opened per push before collapsing into a summary. */
55const MAX_ISSUES_PER_PUSH = 5;
56
57/** Hard cap on diff size consumed (500 KB). */
58const MAX_DIFF_BYTES = 500 * 1024;
59
60/** Label applied to every auto-opened issue. */
61const LABEL_NAME = "ai-detected";
62const LABEL_COLOR = "#e11d48"; // vivid red — stands out in the issue list
63
64// ---------------------------------------------------------------------------
65// Diff helpers
66// ---------------------------------------------------------------------------
67
68/**
69 * Run `git diff <oldSha> <newSha>` inside the bare repo and return the output
70 * capped at MAX_DIFF_BYTES. For an initial push where oldSha is all zeros,
71 * runs `git show <newSha>` instead so we still get the diff.
72 */
73async function getDiff(
74 owner: string,
75 repo: string,
76 oldSha: string,
77 newSha: string
78): Promise<string> {
79 const cwd = getRepoPath(owner, repo);
80 const allZero = /^0+$/.test(oldSha);
81 const cmd = allZero
82 ? ["git", "show", "--format=", newSha]
83 : ["git", "diff", oldSha, newSha];
84 try {
85 const proc = Bun.spawn(cmd, {
86 cwd,
87 stdout: "pipe",
88 stderr: "ignore",
89 });
90 // Read up to MAX_DIFF_BYTES to avoid huge diffs
91 const reader = proc.stdout.getReader();
92 const chunks: Uint8Array[] = [];
93 let totalBytes = 0;
94 while (true) {
95 const { done, value } = await reader.read();
96 if (done) break;
97 if (value) {
98 if (totalBytes + value.byteLength > MAX_DIFF_BYTES) {
99 const remaining = MAX_DIFF_BYTES - totalBytes;
100 if (remaining > 0) {
101 chunks.push(value.slice(0, remaining));
102 }
103 break;
104 }
105 chunks.push(value);
106 totalBytes += value.byteLength;
107 }
108 }
109 reader.cancel();
110 await proc.exited;
111 const decoder = new TextDecoder();
112 return chunks.map((c) => decoder.decode(c)).join("");
113 } catch {
114 return "";
115 }
116}
117
118// ---------------------------------------------------------------------------
119// Diff parser
120// ---------------------------------------------------------------------------
121
122/**
123 * Parse a unified diff text into a list of findings. Groups findings by
124 * (filePath, type) to avoid opening many issues for a single noisy file.
125 */
126export function parseDiffForFindings(diff: string): Finding[] {
127 const findings: Finding[] = [];
128
129 // Track current file and current line offset within the new file
130 let currentFile = "";
131 let currentNewLine = 0;
132
133 const lines = diff.split("\n");
134
135 for (const line of lines) {
136 // File header: diff --git a/... b/...
137 const fileMatch = line.match(/^diff --git a\/.* b\/(.+)$/);
138 if (fileMatch) {
139 currentFile = fileMatch[1];
140 currentNewLine = 0;
141 continue;
142 }
143
144 // +++ b/... header (fallback for file name)
145 const plusHeader = line.match(/^\+\+\+ b\/(.+)$/);
146 if (plusHeader) {
147 currentFile = plusHeader[1];
148 continue;
149 }
150
151 // Hunk header: @@ -x,y +a,b @@
152 const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
153 if (hunkMatch) {
154 currentNewLine = parseInt(hunkMatch[1], 10) - 1; // will be incremented below
155 continue;
156 }
157
158 // Track new-file line numbers
159 if (line.startsWith("+") && !line.startsWith("+++")) {
160 currentNewLine++;
161 } else if (line.startsWith("-") && !line.startsWith("---")) {
162 // Deleted lines don't advance the new-file line counter
163 continue;
164 } else if (!line.startsWith("\\")) {
165 // Context line or header — advance counter only for context lines
166 if (!line.startsWith("diff") && !line.startsWith("index") &&
167 !line.startsWith("---") && !line.startsWith("+++")) {
168 currentNewLine++;
169 }
170 continue;
171 }
172
173 if (!line.startsWith("+") || !currentFile) continue;
174 }
175
176 // Second pass: collect matches with proper line tracking
177 return scanDiffLines(diff);
178}
179
180/**
181 * Scan diff lines with proper hunk-aware line number tracking. Returns one
182 * Finding per matched line — callers should deduplicate by (file, type).
183 */
184function scanDiffLines(diff: string): Finding[] {
185 const findings: Finding[] = [];
186 let currentFile = "";
187 let currentNewLine = 0;
188 let lineIdx = 0;
189
190 const lines = diff.split("\n");
191
192 for (const line of lines) {
193 lineIdx++;
194
195 // File header
196 const fileMatch = line.match(/^diff --git a\/.* b\/(.+)$/);
197 if (fileMatch) {
198 currentFile = fileMatch[1];
199 currentNewLine = 0;
200 continue;
201 }
202
203 const plusHeader = line.match(/^\+\+\+ b\/(.+)$/);
204 if (plusHeader) {
205 currentFile = plusHeader[1];
206 continue;
207 }
208
209 // Hunk header
210 const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
211 if (hunkMatch) {
212 currentNewLine = parseInt(hunkMatch[1], 10) - 1;
213 continue;
214 }
215
216 if (line.startsWith("-") && !line.startsWith("---")) {
217 // Removed lines — skip (don't open issues for deleted code)
218 continue;
219 }
220
221 if (line.startsWith("+") && !line.startsWith("+++")) {
222 currentNewLine++;
223
224 if (!currentFile) continue;
225
226 // Match each pattern against the added line
227 const matchText = line.slice(0, 200); // cap at 200 chars
228
229 if (TODO_PATTERN.test(line)) {
230 findings.push({
231 type: "todo",
232 filePath: currentFile,
233 lineNumber: currentNewLine,
234 matchText,
235 });
236 }
237 TODO_PATTERN.lastIndex = 0;
238
239 if (SECRET_PATTERN.test(line)) {
240 findings.push({
241 type: "secret",
242 filePath: currentFile,
243 lineNumber: currentNewLine,
244 matchText: maskSecretValue(matchText),
245 });
246 }
247 SECRET_PATTERN.lastIndex = 0;
248
249 if (SQL_INJECTION_PATTERN.test(line)) {
250 findings.push({
251 type: "sql-injection",
252 filePath: currentFile,
253 lineNumber: currentNewLine,
254 matchText,
255 });
256 }
257 SQL_INJECTION_PATTERN.lastIndex = 0;
258
259 if (CONSOLE_LOG_PATTERN.test(line)) {
260 findings.push({
261 type: "console-log",
262 filePath: currentFile,
263 lineNumber: currentNewLine,
264 matchText,
265 });
266 }
267 CONSOLE_LOG_PATTERN.lastIndex = 0;
268 } else {
269 // Context line
270 currentNewLine++;
271 }
272 }
273
274 return findings;
275}
276
277/** Replace the value portion of a secret match with asterisks. */
278function maskSecretValue(text: string): string {
279 return text.replace(
280 /(password|secret|api_key|apikey|token|private_key|privatekey)\s*=\s*["'][^"']{0,200}/gi,
281 (m) => {
282 const eqIdx = m.indexOf("=");
283 const quoteIdx = m.indexOf('"', eqIdx) !== -1
284 ? m.indexOf('"', eqIdx)
285 : m.indexOf("'", eqIdx);
286 return m.slice(0, quoteIdx + 1) + "****";
287 }
288 );
289}
290
291// ---------------------------------------------------------------------------
292// Deduplication
293// ---------------------------------------------------------------------------
294
295interface GroupedFinding {
296 type: FindingType;
297 filePath: string;
298 /** All line numbers in this file for this finding type. */
299 lineNumbers: number[];
300 /** First matched text (representative sample). */
301 sampleText: string;
302}
303
304function groupFindings(findings: Finding[]): GroupedFinding[] {
305 const map = new Map<string, GroupedFinding>();
306 for (const f of findings) {
307 const key = `${f.type}::${f.filePath}`;
308 const existing = map.get(key);
309 if (existing) {
310 existing.lineNumbers.push(f.lineNumber);
311 } else {
312 map.set(key, {
313 type: f.type,
314 filePath: f.filePath,
315 lineNumbers: [f.lineNumber],
316 sampleText: f.matchText,
317 });
318 }
319 }
320 return Array.from(map.values());
321}
322
323// ---------------------------------------------------------------------------
324// Issue rendering
325// ---------------------------------------------------------------------------
326
327const FINDING_LABELS: Record<FindingType, string> = {
328 "todo": "TODO/FIXME",
329 "secret": "Potential Secret Exposure",
330 "sql-injection": "SQL Injection Risk",
331 "console-log": "Debug Console Log",
332};
333
334function renderIssueTitle(
335 group: GroupedFinding,
336 pusherUsername: string
337): string {
338 const label = FINDING_LABELS[group.type];
339 const loc = `${group.filePath}`;
340 return `[AI] ${label} found in ${loc} (pushed by @${pusherUsername})`;
341}
342
343function renderIssueBody(
344 group: GroupedFinding,
345 owner: string,
346 repo: string,
347 commitSha: string,
348 pusherUsername: string
349): string {
350 const label = FINDING_LABELS[group.type];
351 const shortSha = commitSha.slice(0, 7);
352 const lineList = group.lineNumbers.slice(0, 10).join(", ");
353 const firstLine = group.lineNumbers[0];
354
355 const fileLink = `[\`${group.filePath}:${firstLine}\`](/${owner}/${repo}/blob/${commitSha}/${group.filePath}#L${firstLine})`;
356
357 const description = findingDescription(group.type);
358
359 const lines = [
360 `**Automated AI scan** detected a **${label}** in commit \`${shortSha}\` pushed by @${pusherUsername}.`,
361 "",
362 `**File:** ${fileLink}`,
363 `**Line(s):** ${lineList}${group.lineNumbers.length > 10 ? ` (and ${group.lineNumbers.length - 10} more)` : ""}`,
364 "",
365 "## Matched code",
366 "```",
367 group.sampleText.trim(),
368 "```",
369 "",
370 "## Why this matters",
371 description,
372 "",
373 "---",
374 "_This issue was auto-opened by Gluecron's AI push scanner. Close it if the finding is a false positive._",
375 ];
376
377 return lines.join("\n");
378}
379
380function findingDescription(type: FindingType): string {
381 switch (type) {
382 case "todo":
383 return "TODO/FIXME/HACK comments indicate incomplete or workaround code that should be tracked as proper issues rather than buried in source files.";
384 case "secret":
385 return "Hardcoded credentials or API keys in source code can be extracted from git history even after deletion. Rotate the exposed credential immediately and use environment variables or a secrets manager instead.";
386 case "sql-injection":
387 return "Template literals interpolated directly into SQL statements may allow SQL injection if user-controlled data reaches this code path. Use parameterised queries or a query builder instead.";
388 case "console-log":
389 return "Debug `console.log` calls left in production code can expose sensitive data in logs and add unnecessary noise. Remove or replace with a proper logging library with log-level controls.";
390 }
391}
392
393function renderSummaryIssueBody(
394 totalFindings: number,
395 groups: GroupedFinding[],
396 owner: string,
397 repo: string,
398 commitSha: string,
399 pusherUsername: string
400): string {
401 const shortSha = commitSha.slice(0, 7);
402 const lines = [
403 `**Automated AI scan** detected **${totalFindings} findings** in commit \`${shortSha}\` pushed by @${pusherUsername}.`,
404 "",
405 "The push scanner limit was reached — here is a summary of all findings:",
406 "",
407 "| Type | File | Lines |",
408 "| ---- | ---- | ----- |",
409 ...groups.map((g) => {
410 const label = FINDING_LABELS[g.type];
411 const fileLink = `[${g.filePath}](/${owner}/${repo}/blob/${commitSha}/${g.filePath})`;
412 const lineStr = g.lineNumbers.slice(0, 5).join(", ") +
413 (g.lineNumbers.length > 5 ? ` (+${g.lineNumbers.length - 5})` : "");
414 return `| ${label} | ${fileLink} | ${lineStr} |`;
415 }),
416 "",
417 "Address the individual findings and re-push to trigger a fresh scan.",
418 "",
419 "---",
420 "_This issue was auto-opened by Gluecron's AI push scanner._",
421 ];
422 return lines.join("\n");
423}
424
425// ---------------------------------------------------------------------------
426// DB helpers
427// ---------------------------------------------------------------------------
428
429/**
430 * Resolve or create the `ai-detected` label for a repository.
431 * Returns the label id, or null on any failure.
432 */
433async function ensureAiDetectedLabel(repositoryId: string): Promise<string | null> {
434 try {
435 // Try to find existing label
436 const [existing] = await db
437 .select({ id: labels.id })
438 .from(labels)
439 .where(and(eq(labels.repositoryId, repositoryId), eq(labels.name, LABEL_NAME)))
440 .limit(1);
441 if (existing) return existing.id;
442
443 // Create it
444 const [created] = await db
445 .insert(labels)
446 .values({
447 repositoryId,
448 name: LABEL_NAME,
449 color: LABEL_COLOR,
450 description: "Automatically detected by Gluecron AI push scanner",
451 })
452 .onConflictDoNothing()
453 .returning({ id: labels.id });
454 return created?.id ?? null;
455 } catch {
456 return null;
457 }
458}
459
460/**
461 * Insert one issue and optionally attach the ai-detected label.
462 * Bumps `repositories.issueCount` by 1.
463 * Returns the new issue number, or null on failure.
464 */
465async function insertIssue(opts: {
466 repositoryId: string;
467 authorId: string;
468 title: string;
469 body: string;
470 labelId: string | null;
471 currentIssueCount: number;
472}): Promise<number | null> {
473 try {
474 const [inserted] = await db
475 .insert(issues)
476 .values({
477 repositoryId: opts.repositoryId,
478 authorId: opts.authorId,
479 title: opts.title.slice(0, 255),
480 body: opts.body,
481 state: "open",
482 })
483 .returning({ id: issues.id, number: issues.number });
484
485 if (!inserted) return null;
486
487 // Attach label — best-effort
488 if (opts.labelId) {
489 await db
490 .insert(issueLabels)
491 .values({ issueId: inserted.id, labelId: opts.labelId })
492 .catch(() => {/* ignore */});
493 }
494
495 // Bump issue count — best-effort
496 await db
497 .update(repositories)
498 .set({ issueCount: opts.currentIssueCount + 1 })
499 .where(eq(repositories.id, opts.repositoryId))
500 .catch(() => {/* ignore */});
501
502 return inserted.number;
503 } catch {
504 return null;
505 }
506}
507
508// ---------------------------------------------------------------------------
509// Entry point
510// ---------------------------------------------------------------------------
511
512export interface ScanResult {
513 findingsCount: number;
514 issuesOpened: number;
515 skipped: boolean;
516}
517
518/**
519 * Scan the diff between `oldSha` and `newSha` for code quality / security
520 * signals and open issues in the repository for any findings.
521 *
522 * Gated on `AI_AUTO_ISSUES=1`. Never throws.
523 */
524export async function scanDiffForIssues(
525 owner: string,
526 repo: string,
527 oldSha: string,
528 newSha: string,
529 pusherUserId: string
530): Promise<ScanResult> {
531 // Feature flag gate
532 if (process.env.AI_AUTO_ISSUES !== "1") {
533 return { findingsCount: 0, issuesOpened: 0, skipped: true };
534 }
535
536 try {
537 // 1. Resolve repo row
538 const [repoRow] = await db
539 .select({
540 id: repositories.id,
541 ownerId: repositories.ownerId,
542 issueCount: repositories.issueCount,
543 })
544 .from(repositories)
545 .innerJoin(users, eq(repositories.ownerId, users.id))
546 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
547 .limit(1);
548
549 if (!repoRow) {
550 return { findingsCount: 0, issuesOpened: 0, skipped: true };
551 }
552
553 // 2. Resolve pusher username for issue titles
554 let pusherUsername = "unknown";
555 try {
556 const [pusherRow] = await db
557 .select({ username: users.username })
558 .from(users)
559 .where(eq(users.id, pusherUserId))
560 .limit(1);
561 if (pusherRow) pusherUsername = pusherRow.username;
562 } catch {
563 /* fall back to "unknown" */
564 }
565
566 // 3. Fetch diff
567 const diff = await getDiff(owner, repo, oldSha, newSha);
568 if (!diff.trim()) {
569 return { findingsCount: 0, issuesOpened: 0, skipped: false };
570 }
571
572 // 4. Parse and group findings
573 const rawFindings = scanDiffLines(diff);
574 const groups = groupFindings(rawFindings);
575
576 if (groups.length === 0) {
577 return { findingsCount: 0, issuesOpened: 0, skipped: false };
578 }
579
580 // 5. Ensure ai-detected label exists
581 const labelId = await ensureAiDetectedLabel(repoRow.id);
582
583 // 6. Open issues — up to MAX_ISSUES_PER_PUSH, then a summary
584 let issuesOpened = 0;
585 let currentIssueCount = repoRow.issueCount ?? 0;
586
587 if (groups.length <= MAX_ISSUES_PER_PUSH) {
588 for (const group of groups) {
589 const title = renderIssueTitle(group, pusherUsername);
590 const body = renderIssueBody(group, owner, repo, newSha, pusherUsername);
591 const num = await insertIssue({
592 repositoryId: repoRow.id,
593 authorId: repoRow.ownerId,
594 title,
595 body,
596 labelId,
597 currentIssueCount,
598 });
599 if (num !== null) {
600 issuesOpened++;
601 currentIssueCount++;
602 }
603 }
604 } else {
605 // Too many findings — open one summary issue
606 const title = `[AI] Multiple issues found in this push (${rawFindings.length} findings) — see details`;
607 const body = renderSummaryIssueBody(
608 rawFindings.length,
609 groups,
610 owner,
611 repo,
612 newSha,
613 pusherUsername
614 );
615 const num = await insertIssue({
616 repositoryId: repoRow.id,
617 authorId: repoRow.ownerId,
618 title,
619 body,
620 labelId,
621 currentIssueCount,
622 });
623 if (num !== null) {
624 issuesOpened++;
625 }
626 }
627
628 console.log(
629 `[ai-auto-issues] ${owner}/${repo}@${newSha.slice(0, 7)}: ${rawFindings.length} finding(s), ${issuesOpened} issue(s) opened`
630 );
631
632 return {
633 findingsCount: rawFindings.length,
634 issuesOpened,
635 skipped: false,
636 };
637 } catch (err) {
638 console.warn(
639 `[ai-auto-issues] error for ${owner}/${repo}@${newSha.slice(0, 7)}:`,
640 err instanceof Error ? err.message : err
641 );
642 return { findingsCount: 0, issuesOpened: 0, skipped: false };
643 }
644}
645
646/** Test-only exports — do not import in production code paths. */
647export const __test = {
648 scanDiffLines,
649 groupFindings,
650 parseDiffForFindings,
651 renderIssueTitle,
652 renderIssueBody,
653 renderSummaryIssueBody,
654 maskSecretValue,
655};
Modifiedsrc/lib/ai-ci-healer.ts+39−5View fileUnifiedSplit
4848 generatePatchForGateTestFinding,
4949 type GateTestFinding,
5050} from "./ai-patch-generator";
51import { assertAiQuota, AiQuotaExceededError } from "./billing";
5152
5253// ---------------------------------------------------------------------------
5354// Tunables
450451 return { outcome: "skipped", reason: "already processed" };
451452 }
452453
453 const analysis = await analyzeFailedWorkflowRun(runId, {
454 client: opts.client,
455 });
456
457 // Look up the run for audit metadata (repo + sha).
454 // Look up the run for audit metadata (repo + sha) and owner for quota check.
458455 let repositoryId: string | null = null;
459456 let commitSha: string | null = null;
457 let repoOwnerId: string | null = null;
460458 try {
461459 const [row] = await db
462460 .select({
474472 console.warn("[ai-ci-healer] post-analyze run lookup failed:", err);
475473 }
476474
475 // Resolve repo owner for the quota check.
476 if (repositoryId) {
477 try {
478 const [repoRow] = await db
479 .select({ ownerId: repositories.ownerId })
480 .from(repositories)
481 .where(eq(repositories.id, repositoryId))
482 .limit(1);
483 if (repoRow) repoOwnerId = repoRow.ownerId;
484 } catch {
485 /* tolerate */
486 }
487 }
488
489 // Hard quota gate — skip silently when the repo owner's AI budget is
490 // exhausted. We don't fail the CI run; the autopilot marker is not written
491 // so the healer will retry on the next tick once budget resets.
492 if (repoOwnerId) {
493 try {
494 await assertAiQuota(repoOwnerId);
495 } catch (err) {
496 if (err instanceof AiQuotaExceededError) {
497 console.log(
498 `[ai-ci-healer] skipping run=${runId}: AI quota exceeded for owner=${repoOwnerId}`
499 );
500 return { outcome: "skipped", reason: "AI quota exceeded" };
501 }
502 // Unexpected error — log and proceed (fail open).
503 console.warn("[ai-ci-healer] assertAiQuota failed unexpectedly:", err);
504 }
505 }
506
507 const analysis = await analyzeFailedWorkflowRun(runId, {
508 client: opts.client,
509 });
510
477511 if (!analysis) {
478512 await audit({
479513 userId: null,
Modifiedsrc/lib/ai-review-trio.ts+67−7View fileUnifiedSplit
3232import { getAnthropic, MODEL_SONNET, parseJsonResponse } from "./ai-client";
3333import { audit } from "./notify";
3434import { recordAiCost, extractUsage } from "./ai-cost-tracker";
35import { assertAiQuota, AiQuotaExceededError } from "./billing";
36import { getBotUserIdOrFallback } from "./bot-user";
3537
3638// ---------------------------------------------------------------------------
3739// Public types
253255 ? opts.diff.slice(0, DIFF_BYTE_CAP)
254256 : opts.diff;
255257
258 // 0. Hard quota gate — bail before any API calls if the PR author is over
259 // budget. Resolve the author from the DB (needed for the comment insert
260 // in persistTrioComments anyway).
261 let prAuthorId: string | null = null;
262 try {
263 const [pr] = await db
264 .select({ authorId: pullRequests.authorId })
265 .from(pullRequests)
266 .where(eq(pullRequests.id, opts.pullRequestId))
267 .limit(1);
268 if (pr) prAuthorId = pr.authorId;
269 } catch {
270 /* tolerate — quota check will fail open */
271 }
272 if (prAuthorId) {
273 try {
274 await assertAiQuota(prAuthorId);
275 } catch (err) {
276 if (err instanceof AiQuotaExceededError) {
277 // Post a single summary comment so the PR author sees the skip reason.
278 try {
279 await db.insert(prComments).values({
280 pullRequestId: opts.pullRequestId,
281 authorId: prAuthorId,
282 isAiReview: true,
283 body: [
284 TRIO_SUMMARY_MARKER,
285 "## AI Trio Review skipped",
286 "",
287 "Your monthly AI token budget has been reached. Upgrade at [/settings/billing](/settings/billing) to re-enable AI code review.",
288 ].join("\n"),
289 });
290 } catch {
291 /* best-effort */
292 }
293 // Return a neutral fail-closed result so the caller doesn't crash.
294 const skippedVerdict = (persona: TrioPersona): TrioVerdict => ({
295 persona,
296 verdict: "fail",
297 findings: [],
298 rawText: "",
299 latencyMs: 0,
300 failed: true,
301 });
302 return {
303 securityVerdict: skippedVerdict("security"),
304 correctnessVerdict: skippedVerdict("correctness"),
305 styleVerdict: skippedVerdict("style"),
306 disagreements: [],
307 };
308 }
309 // Unexpected error — log and proceed (fail open).
310 console.warn("[ai-review-trio] assertAiQuota failed unexpectedly:", err);
311 }
312 }
313
256314 // 1. Fan out the three persona calls.
257315 const personas: TrioPersona[] = ["security", "correctness", "style"];
258316 const [securityVerdict, correctnessVerdict, styleVerdict] = await Promise.all(
507565 pullRequestId: string;
508566 result: TrioReviewResult;
509567}): Promise<void> {
510 // Need the PR's author id to satisfy `prComments.authorId NOT NULL`.
511 // (`ai-review.ts` uses the same pattern.)
512 let authorId: string | null = null;
568 // Need a user id to satisfy `prComments.authorId NOT NULL`.
569 // Prefer the bot user; fall back to the PR author for pre-migration envs.
570 let prAuthorId: string | null = null;
513571 try {
514572 const [pr] = await db
515573 .select({ authorId: pullRequests.authorId })
516574 .from(pullRequests)
517575 .where(eq(pullRequests.id, args.pullRequestId))
518576 .limit(1);
519 if (pr) authorId = pr.authorId;
577 if (pr) prAuthorId = pr.authorId;
520578 } catch {
521579 /* tolerate */
522580 }
523 if (!authorId) return; // can't post comments without an author id
581 if (!prAuthorId) return; // can't post comments without an author id
582
583 const commentAuthorId = await getBotUserIdOrFallback(prAuthorId);
524584
525585 const verdicts: TrioVerdict[] = [
526586 args.result.securityVerdict,
533593 try {
534594 await db.insert(prComments).values({
535595 pullRequestId: args.pullRequestId,
536 authorId,
596 authorId: commentAuthorId,
537597 isAiReview: true,
538598 body,
539599 });
549609 try {
550610 await db.insert(prComments).values({
551611 pullRequestId: args.pullRequestId,
552 authorId,
612 authorId: commentAuthorId,
553613 isAiReview: true,
554614 body: renderSummaryCommentBody(args.result),
555615 });
Modifiedsrc/lib/ai-review.ts+32−3View fileUnifiedSplit
1717 alreadyTrioReviewed,
1818 runTrioReview,
1919} from "./ai-review-trio";
20import { assertAiQuota, AiQuotaExceededError } from "./billing";
21import { getBotUserIdOrFallback } from "./bot-user";
2022
2123interface ReviewComment {
2224 filePath: string;
304306 .limit(1);
305307 if (!pr) return;
306308
309 // Hard quota gate — post a comment and bail if the user's AI budget is
310 // exhausted. This runs after loading the PR so we have authorId for the
311 // comment insert.
312 try {
313 await assertAiQuota(pr.authorId);
314 } catch (err) {
315 if (err instanceof AiQuotaExceededError) {
316 await db
317 .insert(prComments)
318 .values({
319 pullRequestId: prId,
320 authorId: pr.authorId,
321 isAiReview: true,
322 body: `${AI_REVIEW_MARKER}\n## AI review skipped\n\nYour monthly AI token budget has been reached. Upgrade at [/settings/billing](/settings/billing) to re-enable AI code review.`,
323 })
324 .catch(() => {});
325 return;
326 }
327 // Any other error from assertAiQuota is unexpected — log and proceed
328 // (fail open so a billing glitch never silently kills reviews).
329 console.warn("[ai-review] assertAiQuota failed unexpectedly:", err);
330 }
331
307332 let diffText = await diffBetweenBranches(
308333 ownerName,
309334 repoName,
334359 return;
335360 }
336361
362 // Resolve the bot user id once; fall back to the PR author so that
363 // comment insertion never fails even before migration 0078 has run.
364 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
365
337366 let result: ReviewResult;
338367 try {
339368 result = await reviewDiff(
352381 .insert(prComments)
353382 .values({
354383 pullRequestId: prId,
355 authorId: pr.authorId,
384 authorId: commentAuthorId,
356385 isAiReview: true,
357386 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
358387 })
376405 .insert(prComments)
377406 .values({
378407 pullRequestId: prId,
379 authorId: pr.authorId,
408 authorId: commentAuthorId,
380409 isAiReview: true,
381410 body: summaryBody,
382411 })
399428 .insert(prComments)
400429 .values({
401430 pullRequestId: prId,
402 authorId: pr.authorId,
431 authorId: commentAuthorId,
403432 isAiReview: true,
404433 body: c.body,
405434 filePath,
Modifiedsrc/lib/autopilot.ts+53−8View fileUnifiedSplit
6868import { runAdvancementScan } from "./advancement-scanner";
6969import { expireOldSandboxes } from "./pr-sandbox";
7070import { expireIdleEnvs } from "./dev-env";
71import { expireOldPreviews } from "./branch-previews";
72import { getBotUserIdOrFallback } from "./bot-user";
73import { runOnboardingDripTaskOnce } from "./onboarding-drip";
7174
7275export interface AutopilotTaskResult {
7376 name: string;
621624 }
622625 },
623626 },
627 {
628 // Preview expiry — migration 0062. Flips every branch-preview row
629 // whose `expires_at` is in the past to status='expired'. Runs on
630 // every tick; the lib itself is a cheap SQL UPDATE and is a no-op
631 // when there's nothing to expire, so this never adds meaningful
632 // overhead.
633 name: "preview-expiry",
634 run: async () => {
635 try {
636 const expired = await expireOldPreviews();
637 if (expired > 0) {
638 console.log(`[autopilot] preview-expiry: expired=${expired}`);
639 }
640 } catch (err) {
641 console.error("[autopilot] preview-expiry: threw:", err);
642 }
643 },
644 },
645 {
646 // Onboarding drip — sends pending T+1d and T+3d drip emails to new
647 // users. The T+0 "welcome" email is sent immediately at registration
648 // via src/routes/auth.tsx. This task handles the delayed emails.
649 // Idempotent via the per-user `onboarding_emails_sent` jsonb column
650 // (migration 0081). Silently skips when email is not configured.
651 name: "onboarding-drip",
652 run: async () => {
653 try {
654 const summary = await runOnboardingDripTaskOnce();
655 if (summary.sent > 0 || summary.errors > 0) {
656 console.log(
657 `[autopilot] onboarding-drip: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
658 );
659 }
660 } catch (err) {
661 console.error("[autopilot] onboarding-drip: threw:", err);
662 }
663 },
664 },
624665 ];
625666}
626667
748789export interface SleepModeDigestCandidate {
749790 userId: string;
750791 digestHourUtc: number;
751 lastDigestSentAt: Date | null;
792 /** Independent cooldown anchor for the sleep-mode digest (migration 0077). */
793 lastSleepDigestSentAt: Date | null;
752794}
753795
754796export interface SleepModeDigestTaskDeps {
771813
772814/**
773815 * Default candidate-finder. Returns enabled users whose
774 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
816 * `lastSleepDigestSentAt` is older than the cooldown OR null. The hour-match
775817 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
776818 * timezone-independent of any SQL `extract(hour ...)` behaviour.
819 * Uses the dedicated `last_sleep_digest_sent_at` column (migration 0077) so
820 * the cooldown is independent of the weekly digest timer.
777821 */
778822async function defaultFindSleepModeCandidates(
779823 cap: number
783827 .select({
784828 userId: users.id,
785829 digestHourUtc: users.sleepModeDigestHourUtc,
786 lastDigestSentAt: users.lastDigestSentAt,
830 lastSleepDigestSentAt: users.lastSleepDigestSentAt,
787831 })
788832 .from(users)
789833 .where(eq(users.sleepModeEnabled, true))
791835 return rows.map((r) => ({
792836 userId: r.userId,
793837 digestHourUtc: r.digestHourUtc,
794 lastDigestSentAt: r.lastDigestSentAt,
838 lastSleepDigestSentAt: r.lastSleepDigestSentAt,
795839 }));
796840 } catch (err) {
797841 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
803847 * One iteration of the sleep-mode-digest task. Never throws.
804848 *
805849 * Per-user filters (applied in JS so we can DI a clock):
806 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
850 * 1. `lastSleepDigestSentAt` is null OR older than cooldown (23h).
807851 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
808852 * configured local UTC hour.
809853 *
843887 }
844888 // Cooldown: skip if we sent within the last cooldown window.
845889 if (
846 cand.lastDigestSentAt &&
847 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
890 cand.lastSleepDigestSentAt &&
891 nowDate.getTime() - new Date(cand.lastSleepDigestSentAt).getTime() <
848892 cooldownMs
849893 ) {
850894 skipped += 1;
11791223 console.error("[autopilot] auto-merge: merged audit failed:", err);
11801224 }
11811225 try {
1226 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
11821227 await db.insert(prComments).values({
11831228 pullRequestId: cand.prId,
1184 authorId: cand.authorUserId,
1229 authorId: commentAuthorId,
11851230 isAiReview: true,
11861231 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
11871232 });
Modifiedsrc/lib/billing.ts+113−0View fileUnifiedSplit
316316 if (cents === 0) return "Free";
317317 return `$${(cents / 100).toFixed(2)}/mo`;
318318}
319
320// ---------------------------------------------------------------------------
321// AI quota hard-gate helpers
322// ---------------------------------------------------------------------------
323
324/**
325 * Thrown by `assertAiQuota` when the user's AI token budget is exhausted.
326 * Callers should catch this specifically and degrade gracefully — post a
327 * comment, return a user-facing error, or skip silently — rather than letting
328 * it bubble up as an unhandled exception.
329 */
330export class AiQuotaExceededError extends Error {
331 readonly userId: string;
332 readonly planSlug: string;
333 constructor(userId: string, planSlug: string) {
334 super(
335 `AI quota exceeded for user ${userId} (plan: ${planSlug}). Upgrade at /settings/billing.`
336 );
337 this.name = "AiQuotaExceededError";
338 this.userId = userId;
339 this.planSlug = planSlug;
340 }
341}
342
343interface QuotaCacheEntry {
344 /** True = allowed, false = blocked. */
345 allowed: boolean;
346 /** Usage as a fraction of the limit (0–1). Used to emit the 90% warning. */
347 fraction: number;
348 planSlug: string;
349 expiresAt: number;
350}
351
352/** Per-user TTL cache. A simple Map is fine — the process is single-replica. */
353const _quotaCache = new Map<string, QuotaCacheEntry>();
354
355/** Cache TTL in milliseconds. */
356const QUOTA_CACHE_TTL_MS = 60_000;
357
358/**
359 * Check whether the user has AI token budget remaining, using an in-process
360 * 60-second cache to avoid a DB round-trip on every AI call.
361 *
362 * - Returns `{ allowed: true }` when usage is under 100% of the plan limit.
363 * - Returns `{ allowed: false }` when at or above the limit.
364 * - Returns `{ allowed: true, nearLimit: true }` when usage is >= 90% but
365 * under 100%, so callers can log a warning.
366 *
367 * Fails **open** on any DB / billing error (same policy as `checkQuota`).
368 */
369export async function checkAiQuotaCached(
370 userId: string
371): Promise<{ allowed: boolean; nearLimit: boolean; planSlug: string }> {
372 if (!userId) return { allowed: true, nearLimit: false, planSlug: "free" };
373
374 const now = Date.now();
375 const cached = _quotaCache.get(userId);
376 if (cached && cached.expiresAt > now) {
377 return {
378 allowed: cached.allowed,
379 nearLimit: cached.fraction >= 0.9 && cached.allowed,
380 planSlug: cached.planSlug,
381 };
382 }
383
384 try {
385 const quota = await getUserQuota(userId);
386 const limit = quota.plan.aiTokensMonthly;
387 const used = quota.usage.aiTokensUsedThisMonth;
388 const allowed = limit <= 0 || used < limit;
389 const fraction = limit > 0 ? used / limit : 0;
390 const entry: QuotaCacheEntry = {
391 allowed,
392 fraction,
393 planSlug: quota.planSlug,
394 expiresAt: now + QUOTA_CACHE_TTL_MS,
395 };
396 _quotaCache.set(userId, entry);
397 return { allowed, nearLimit: fraction >= 0.9 && allowed, planSlug: quota.planSlug };
398 } catch {
399 // Fail open — billing is never a hard dependency for the primary path.
400 return { allowed: true, nearLimit: false, planSlug: "free" };
401 }
402}
403
404/**
405 * Invalidate the cached quota entry for a user. Call after `bumpUsage` when
406 * you want the next AI call to re-check immediately rather than waiting up to
407 * 60 seconds.
408 */
409export function invalidateQuotaCache(userId: string): void {
410 _quotaCache.delete(userId);
411}
412
413/**
414 * Assert that the user has AI token quota remaining. Throws
415 * `AiQuotaExceededError` if blocked; logs a warning (but proceeds) when
416 * usage is 90-99% of the limit.
417 *
418 * Designed to be called at the top of every AI feature entry point.
419 * Never throws on billing errors (fails open).
420 */
421export async function assertAiQuota(userId: string): Promise<void> {
422 const { allowed, nearLimit, planSlug } = await checkAiQuotaCached(userId);
423 if (nearLimit) {
424 console.warn(
425 `[billing] AI quota warning: user ${userId} (plan: ${planSlug}) is near their monthly AI token limit.`
426 );
427 }
428 if (!allowed) {
429 throw new AiQuotaExceededError(userId, planSlug);
430 }
431}
Addedsrc/lib/bot-user.ts+55−0View fileUnifiedSplit
1/**
2 * Bot-user helper — resolves the UUID for the synthetic `gluecron[bot]`
3 * account so autopilot / AI-review actions are credited to it rather than
4 * to the PR / issue author.
5 *
6 * The row is seeded by drizzle/0078_bot_user.sql. If the migration has not
7 * run yet (e.g. a freshly-cloned dev environment) the helper returns `null`
8 * and callers must fall back to a real user id — the same behaviour as before
9 * this feature shipped.
10 *
11 * The result is module-level cached so every autopilot tick after the first
12 * one pays zero DB overhead.
13 */
14
15import { eq } from "drizzle-orm";
16import { db } from "../db";
17import { users } from "../db/schema";
18
19export const BOT_USERNAME = "gluecron[bot]";
20
21let _botUserId: string | null | undefined = undefined; // undefined = not yet fetched
22
23/**
24 * Lazily resolve and cache the `gluecron[bot]` user's UUID.
25 *
26 * Returns `null` when the row does not exist (migration not yet applied).
27 * Callers should fall back to `authorId` from the related PR/issue.
28 */
29export async function getBotUserId(): Promise<string | null> {
30 if (_botUserId !== undefined) return _botUserId;
31 try {
32 const [row] = await db
33 .select({ id: users.id })
34 .from(users)
35 .where(eq(users.username, BOT_USERNAME))
36 .limit(1);
37 _botUserId = row?.id ?? null;
38 } catch {
39 // DB unavailable — leave undefined so next call retries.
40 return null;
41 }
42 return _botUserId;
43}
44
45/**
46 * Resolve the bot user ID, falling back to `fallbackId` if the bot row
47 * does not exist yet. The fallback keeps every call site backward-
48 * compatible with pre-migration environments.
49 */
50export async function getBotUserIdOrFallback(
51 fallbackId: string
52): Promise<string> {
53 const botId = await getBotUserId();
54 return botId ?? fallbackId;
55}
Modifiedsrc/lib/claude-web-session.ts+23−0View fileUnifiedSplit
392392 .limit(limit);
393393}
394394
395/**
396 * List sessions for a specific user in a specific repo, ordered by most
397 * recently active first. Used by the customer-facing /:owner/:repo/claude
398 * page so each user sees only their own sessions.
399 */
400export async function listSessionsForUser(
401 repositoryId: string,
402 ownerUserId: string,
403 limit = 50
404): Promise<ClaudeWebSession[]> {
405 return db
406 .select()
407 .from(claudeWebSessions)
408 .where(
409 and(
410 eq(claudeWebSessions.repositoryId, repositoryId),
411 eq(claudeWebSessions.ownerUserId, ownerUserId)
412 )
413 )
414 .orderBy(claudeWebSessions.lastActiveAt)
415 .limit(limit);
416}
417
395418export async function deleteSession(id: string): Promise<void> {
396419 await db.delete(claudeWebSessions).where(eq(claudeWebSessions.id, id));
397420}
Modifiedsrc/lib/config.ts+48−0View fileUnifiedSplit
8181 ""
8282 );
8383 },
84 /**
85 * Root directory for OCI container registry blob + manifest storage.
86 * Layout:
87 * ${ociStorePath}/blobs/sha256/<hex64> — finished layer/config blobs
88 * ${ociStorePath}/manifests/<name>/<ref> — image manifests by tag or digest
89 * ${ociStorePath}/uploads/<uuid> — in-progress chunked uploads
90 */
91 get ociStorePath() {
92 return process.env.OCI_STORE_PATH || join(process.cwd(), "oci-store");
93 },
94 /**
95 * Base URL used to construct preview URLs for PR builds.
96 * When set, the preview-builder will run and serve static files; when unset,
97 * previews are URL-only (no build runs).
98 *
99 * Production: set to e.g. "https://previews.gluecron.com"
100 */
101 get previewDomain() {
102 return process.env.PREVIEW_DOMAIN || "";
103 },
84104 /**
85105 * WebAuthn relying-party ID (domain only, no scheme/port). Derived from
86106 * appBaseUrl unless overridden. Passkeys issued for one RP ID can't be
102122 get webauthnRpName() {
103123 return process.env.WEBAUTHN_RP_NAME || "gluecron";
104124 },
125 /**
126 * Redis / Valkey connection URL for cross-instance SSE fan-out.
127 * When set, `src/lib/sse.ts` uses Redis pub/sub so SSE events reach all
128 * server instances behind the load balancer. Falls back to in-process
129 * delivery when unset.
130 */
131 get redisUrl() {
132 return process.env.REDIS_URL || process.env.VALKEY_URL || "";
133 },
134 /**
135 * AI Auto-Issue Opener (src/lib/ai-auto-issues.ts). When set to "1",
136 * every git push is scanned for TODOs, hardcoded secrets, SQL injection
137 * patterns, and debug console.log calls; matching findings automatically
138 * open issues in the repository. Off by default.
139 */
140 get aiAutoIssues() {
141 return process.env.AI_AUTO_ISSUES === "1";
142 },
143 /**
144 * Dependency CVE scanner — when set to "1", the post-receive hook fires
145 * `scanDependencies()` on every push that touches a recognized manifest
146 * file (package.json, requirements.txt, Cargo.toml, go.mod, Gemfile).
147 * Results open security issues automatically. Fire-and-forget; never
148 * blocks a push.
149 */
150 get dependencyScanEnabled() {
151 return process.env.DEPENDENCY_SCAN_ENABLED === "1";
152 },
105153};
Addedsrc/lib/dependency-scanner.ts+795−0View fileUnifiedSplit
1/**
2 * Dependency CVE Scanner — auto-opens security issues on push.
3 *
4 * When a push touches package.json, requirements.txt, Cargo.toml, go.mod,
5 * or Gemfile, this module:
6 * 1. Reads the dependency file from the pushed commit via `git show`.
7 * 2. Queries the OSV (Open Source Vulnerabilities) API for each package.
8 * 3. For critical/high findings: opens an issue per CVE (deduplicates via
9 * title ILIKE check so the same CVE never gets a second open issue).
10 * 4. For medium/low findings: batches them into a weekly digest issue.
11 *
12 * Integrates into post-receive.ts as a fire-and-forget call guarded by
13 * `DEPENDENCY_SCAN_ENABLED=1`. Never throws — every external call is wrapped.
14 */
15
16import { and, eq, ilike, or } from "drizzle-orm";
17import { db } from "../db";
18import {
19 issueLabels,
20 issues,
21 labels,
22 repositories,
23 users,
24} from "../db/schema";
25import { getRepoPath } from "../git/repository";
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31export interface VulnFinding {
32 packageName: string;
33 installedVersion: string;
34 severity: "critical" | "high" | "medium" | "low";
35 cveId: string;
36 description: string;
37 fixVersion?: string;
38}
39
40// ---------------------------------------------------------------------------
41// Ecosystem map — maps manifest filename to OSV ecosystem string
42// ---------------------------------------------------------------------------
43
44const DEPENDENCY_FILES = [
45 "package.json",
46 "requirements.txt",
47 "Cargo.toml",
48 "go.mod",
49 "Gemfile",
50] as const;
51
52type DependencyFile = (typeof DEPENDENCY_FILES)[number];
53
54const FILE_ECOSYSTEM: Record<DependencyFile, string> = {
55 "package.json": "npm",
56 "requirements.txt": "PyPI",
57 "Cargo.toml": "crates.io",
58 "go.mod": "Go",
59 "Gemfile": "RubyGems",
60};
61
62// ---------------------------------------------------------------------------
63// Git helpers
64// ---------------------------------------------------------------------------
65
66/** Reads a file at a specific commit SHA via `git show`. Returns null on failure. */
67async function gitShow(
68 owner: string,
69 repoName: string,
70 sha: string,
71 filePath: string
72): Promise<string | null> {
73 try {
74 const cwd = getRepoPath(owner, repoName);
75 const proc = Bun.spawn(
76 ["git", "show", `${sha}:${filePath}`],
77 { cwd, stdout: "pipe", stderr: "pipe" }
78 );
79 const text = await new Response(proc.stdout).text();
80 const code = await proc.exited;
81 if (code !== 0) return null;
82 return text;
83 } catch {
84 return null;
85 }
86}
87
88/** Returns the list of files changed between oldSha and newSha. */
89async function gitDiffNames(
90 owner: string,
91 repoName: string,
92 oldSha: string,
93 newSha: string
94): Promise<string[]> {
95 try {
96 const cwd = getRepoPath(owner, repoName);
97 const allZero = /^0+$/.test(oldSha);
98 const cmd = allZero
99 ? ["git", "ls-tree", "-r", "--name-only", newSha]
100 : ["git", "diff", "--name-only", oldSha, newSha];
101 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
102 const text = await new Response(proc.stdout).text();
103 await proc.exited;
104 return text.split("\n").map((s) => s.trim()).filter(Boolean);
105 } catch {
106 return [];
107 }
108}
109
110// ---------------------------------------------------------------------------
111// Manifest parsers
112// ---------------------------------------------------------------------------
113
114interface ParsedPackage {
115 name: string;
116 version: string;
117 ecosystem: string;
118}
119
120function parsePackageJson(content: string, ecosystem: string): ParsedPackage[] {
121 try {
122 const json = JSON.parse(content) as Record<string, unknown>;
123 const deps: Record<string, string> = {};
124 const raw = json["dependencies"];
125 const rawDev = json["devDependencies"];
126 if (raw && typeof raw === "object") {
127 Object.assign(deps, raw as Record<string, string>);
128 }
129 if (rawDev && typeof rawDev === "object") {
130 Object.assign(deps, rawDev as Record<string, string>);
131 }
132 return Object.entries(deps)
133 .filter(([, v]) => typeof v === "string")
134 .map(([name, versionSpec]) => ({
135 name,
136 // Strip leading ^~>< to get a bare version for the OSV query.
137 // If the spec is something like "workspace:*" we'll still query
138 // with the empty string; OSV returns all advisories for the package.
139 version: versionSpec.replace(/^[^0-9]*/, "").split(" ")[0] || "",
140 ecosystem,
141 }));
142 } catch {
143 return [];
144 }
145}
146
147function parseRequirementsTxt(content: string, ecosystem: string): ParsedPackage[] {
148 const result: ParsedPackage[] = [];
149 for (const rawLine of content.split("\n")) {
150 const line = rawLine.trim();
151 if (!line || line.startsWith("#") || line.startsWith("-")) continue;
152 // Lines like: requests==2.28.0, Django>=4.1, flask
153 const match = line.match(/^([A-Za-z0-9_.\-]+)\s*(?:[=<>!]+\s*([0-9][^\s,;#]*))?/);
154 if (!match) continue;
155 const name = match[1];
156 const version = match[2] || "";
157 result.push({ name, version, ecosystem });
158 }
159 return result;
160}
161
162function parseCargoToml(content: string, ecosystem: string): ParsedPackage[] {
163 const result: ParsedPackage[] = [];
164 let inDeps = false;
165 for (const rawLine of content.split("\n")) {
166 const line = rawLine.trim();
167 if (line.startsWith("[")) {
168 inDeps =
169 line === "[dependencies]" ||
170 line === "[dev-dependencies]" ||
171 line === "[build-dependencies]";
172 continue;
173 }
174 if (!inDeps) continue;
175 if (!line || line.startsWith("#")) continue;
176 // name = "1.2.3"
177 const simpleMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*"([0-9][^"]*)"$/);
178 if (simpleMatch) {
179 result.push({ name: simpleMatch[1], version: simpleMatch[2], ecosystem });
180 continue;
181 }
182 // name = { version = "1.2.3", ... }
183 const tableMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*\{.*version\s*=\s*"([0-9][^"]*)".*\}$/);
184 if (tableMatch) {
185 result.push({ name: tableMatch[1], version: tableMatch[2], ecosystem });
186 }
187 }
188 return result;
189}
190
191function parseGoMod(content: string, ecosystem: string): ParsedPackage[] {
192 const result: ParsedPackage[] = [];
193 for (const rawLine of content.split("\n")) {
194 const line = rawLine.trim();
195 // Matches both block form: github.com/foo/bar v1.2.3
196 // and standalone require: require github.com/foo/bar v1.2.3
197 // Strip a leading "require " if present.
198 const stripped = line.startsWith("require ") ? line.slice("require ".length).trim() : line;
199 const match = stripped.match(/^([A-Za-z0-9_.\-\/]+)\s+(v[0-9][^\s]*)/);
200 if (match && match[1] !== "go" && match[1] !== "module" && match[1] !== "toolchain") {
201 result.push({ name: match[1], version: match[2].replace(/^v/, ""), ecosystem });
202 }
203 }
204 return result;
205}
206
207function parseGemfile(content: string, ecosystem: string): ParsedPackage[] {
208 const result: ParsedPackage[] = [];
209 for (const rawLine of content.split("\n")) {
210 const line = rawLine.trim();
211 if (!line.startsWith("gem ")) continue;
212 // gem 'rails', '~> 7.0'
213 const nameMatch = line.match(/gem\s+['"]([A-Za-z0-9_\-]+)['"]/);
214 if (!nameMatch) continue;
215 const versionMatch = line.match(/,\s*['"]([~><=!^]?\s*[0-9][^\s,'"]*)['"]/);
216 const version = versionMatch
217 ? versionMatch[1].replace(/[^0-9.].*/, "").trim()
218 : "";
219 result.push({ name: nameMatch[1], version, ecosystem });
220 }
221 return result;
222}
223
224function parseManifest(
225 file: DependencyFile,
226 content: string
227): ParsedPackage[] {
228 const ecosystem = FILE_ECOSYSTEM[file];
229 switch (file) {
230 case "package.json":
231 return parsePackageJson(content, ecosystem);
232 case "requirements.txt":
233 return parseRequirementsTxt(content, ecosystem);
234 case "Cargo.toml":
235 return parseCargoToml(content, ecosystem);
236 case "go.mod":
237 return parseGoMod(content, ecosystem);
238 case "Gemfile":
239 return parseGemfile(content, ecosystem);
240 }
241}
242
243// ---------------------------------------------------------------------------
244// OSV API
245// ---------------------------------------------------------------------------
246
247interface OsvVuln {
248 id: string;
249 summary?: string;
250 severity?: Array<{ type: string; score: string }>;
251 affected?: Array<{
252 ranges?: Array<{ type: string; events?: Array<{ introduced?: string; fixed?: string }> }>;
253 versions?: string[];
254 }>;
255}
256
257interface OsvResult {
258 vulns?: OsvVuln[];
259}
260
261/** Batch-query OSV for a list of packages. Returns per-package vulnerability arrays. */
262async function queryOsv(
263 packages: ParsedPackage[]
264): Promise<OsvResult[]> {
265 if (packages.length === 0) return [];
266 try {
267 const response = await fetch("https://api.osv.dev/v1/querybatch", {
268 method: "POST",
269 headers: { "Content-Type": "application/json" },
270 body: JSON.stringify({
271 queries: packages.map((p) => ({
272 version: p.version,
273 package: { name: p.name, ecosystem: p.ecosystem },
274 })),
275 }),
276 signal: AbortSignal.timeout(15_000),
277 });
278 if (!response.ok) return packages.map(() => ({}));
279 const data = await response.json() as { results?: OsvResult[] };
280 return data.results || packages.map(() => ({}));
281 } catch {
282 return packages.map(() => ({}));
283 }
284}
285
286/** Map OSV severity (CVSS score or type) to our severity enum. */
287function mapSeverity(
288 vuln: OsvVuln
289): "critical" | "high" | "medium" | "low" {
290 if (!vuln.severity || vuln.severity.length === 0) return "medium";
291 // Try CVSS score first
292 for (const sev of vuln.severity) {
293 if (sev.score) {
294 const score = parseFloat(sev.score);
295 if (!isNaN(score)) {
296 if (score >= 9.0) return "critical";
297 if (score >= 7.0) return "high";
298 if (score >= 4.0) return "medium";
299 return "low";
300 }
301 // Some OSV entries use CRITICAL/HIGH/MEDIUM/LOW strings
302 const upper = sev.score.toUpperCase();
303 if (upper === "CRITICAL") return "critical";
304 if (upper === "HIGH") return "high";
305 if (upper === "MEDIUM" || upper === "MODERATE") return "medium";
306 if (upper === "LOW") return "low";
307 }
308 }
309 return "medium";
310}
311
312/** Extract the fix version from the OSV affected ranges. */
313function extractFixVersion(vuln: OsvVuln): string | undefined {
314 for (const affected of vuln.affected || []) {
315 for (const range of affected.ranges || []) {
316 for (const event of range.events || []) {
317 if (event.fixed) return event.fixed;
318 }
319 }
320 }
321 return undefined;
322}
323
324/** Convert OSV results into VulnFindings. */
325function osvResultsToFindings(
326 packages: ParsedPackage[],
327 results: OsvResult[]
328): VulnFinding[] {
329 const findings: VulnFinding[] = [];
330 for (let i = 0; i < packages.length; i++) {
331 const pkg = packages[i];
332 const result = results[i] || {};
333 for (const vuln of result.vulns || []) {
334 const cveId = vuln.id || "UNKNOWN";
335 const severity = mapSeverity(vuln);
336 findings.push({
337 packageName: pkg.name,
338 installedVersion: pkg.version || "unknown",
339 severity,
340 cveId,
341 description: vuln.summary || "No description available.",
342 fixVersion: extractFixVersion(vuln),
343 });
344 }
345 }
346 return findings;
347}
348
349// ---------------------------------------------------------------------------
350// Issue creation helpers
351// ---------------------------------------------------------------------------
352
353const WEEKLY_DIGEST_MARKER = "<!-- gluecron:dep-scan-digest:v1 -->";
354const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
355
356/** Returns the repo owner's user ID for issue attribution. */
357async function getRepoOwnerId(repoId: number | string): Promise<string | null> {
358 try {
359 const [row] = await db
360 .select({ ownerId: repositories.ownerId })
361 .from(repositories)
362 .where(eq(repositories.id, repoId as string))
363 .limit(1);
364 return row?.ownerId || null;
365 } catch {
366 return null;
367 }
368}
369
370/**
371 * Find or create a `security` label for the repo.
372 * Returns the label ID or null on failure.
373 */
374async function getOrCreateSecurityLabel(
375 repoId: string
376): Promise<string | null> {
377 try {
378 const [existing] = await db
379 .select({ id: labels.id })
380 .from(labels)
381 .where(
382 and(eq(labels.repositoryId, repoId), eq(labels.name, "security"))
383 )
384 .limit(1);
385 if (existing) return existing.id;
386
387 const [inserted] = await db
388 .insert(labels)
389 .values({
390 repositoryId: repoId,
391 name: "security",
392 color: "#d73a4a",
393 description: "Security vulnerability",
394 })
395 .onConflictDoNothing()
396 .returning();
397 return inserted?.id || null;
398 } catch {
399 return null;
400 }
401}
402
403/** Attach a label to an issue. Best-effort; swallows errors. */
404async function attachLabel(issueId: string, labelId: string): Promise<void> {
405 try {
406 await db
407 .insert(issueLabels)
408 .values({ issueId, labelId })
409 .onConflictDoNothing();
410 } catch {
411 /* best-effort */
412 }
413}
414
415/** Bump the repo's issue count. Best-effort. */
416async function bumpIssueCount(repoId: string): Promise<void> {
417 try {
418 await db
419 .update(repositories)
420 .set({
421 issueCount: db.$with("cte").as(
422 db
423 .select({ v: repositories.issueCount })
424 .from(repositories)
425 .where(eq(repositories.id, repoId))
426 ) as any,
427 })
428 .where(eq(repositories.id, repoId));
429 } catch {
430 /* best-effort */
431 }
432}
433
434/**
435 * Check whether an issue for this CVE already exists (open or recently closed).
436 * Avoids spamming duplicate issues on every push.
437 */
438async function cveIssueExists(
439 repoId: string,
440 cveId: string
441): Promise<boolean> {
442 try {
443 const rows = await db
444 .select({ id: issues.id })
445 .from(issues)
446 .where(
447 and(
448 eq(issues.repositoryId, repoId),
449 or(
450 ilike(issues.title, `%${cveId}%`),
451 ilike(issues.body, `%${cveId}%`)
452 )
453 )
454 )
455 .limit(1);
456 return rows.length > 0;
457 } catch {
458 return false;
459 }
460}
461
462/** Render an issue body for a critical/high finding. */
463function renderVulnIssueBody(f: VulnFinding): string {
464 const fix = f.fixVersion
465 ? `Upgrade to **${f.fixVersion}** or later.`
466 : "No fix version is currently available. Monitor the advisory for updates.";
467
468 const impact = severityImpactText(f.severity);
469
470 return [
471 `> **Automated security scan.** This issue was opened by GlueCron's dependency scanner after detecting a vulnerable dependency in a recent push.`,
472 "",
473 `## Vulnerability details`,
474 "",
475 `| Field | Value |`,
476 `|---|---|`,
477 `| Package | \`${f.packageName}\` |`,
478 `| Installed version | \`${f.installedVersion}\` |`,
479 `| Severity | **${f.severity.toUpperCase()}** |`,
480 `| CVE / Advisory | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) |`,
481 "",
482 `## Description`,
483 "",
484 f.description,
485 "",
486 `## Remediation`,
487 "",
488 fix,
489 "",
490 `## Impact & urgency`,
491 "",
492 impact,
493 "",
494 `---`,
495 `_This issue was auto-generated by the GlueCron dependency scanner. Close it once the vulnerability is resolved or confirmed not applicable._`,
496 ].join("\n");
497}
498
499function severityImpactText(severity: VulnFinding["severity"]): string {
500 switch (severity) {
501 case "critical":
502 return (
503 "This is a **CRITICAL** vulnerability. It should be addressed immediately — " +
504 "critical CVEs are commonly exploited in the wild and can lead to full system compromise, " +
505 "data exfiltration, or remote code execution. Treat this as an emergency patch."
506 );
507 case "high":
508 return (
509 "This is a **HIGH** severity vulnerability. It should be patched in the next sprint at the latest. " +
510 "High severity issues often allow privilege escalation, authentication bypass, or significant data leakage."
511 );
512 case "medium":
513 return (
514 "This is a **MEDIUM** severity vulnerability. It should be addressed in the next maintenance window. " +
515 "Medium issues typically require specific conditions to exploit but should not be ignored."
516 );
517 case "low":
518 return (
519 "This is a **LOW** severity vulnerability. While lower risk, it should be tracked " +
520 "and resolved as part of routine dependency maintenance."
521 );
522 }
523}
524
525/**
526 * Open a single issue for a critical/high CVE finding.
527 * Returns the issue number, or null if skipped/failed.
528 */
529async function openVulnIssue(
530 repoId: string,
531 authorId: string,
532 finding: VulnFinding,
533 labelId: string | null
534): Promise<number | null> {
535 // Dedup: skip if an issue for this CVE already exists.
536 const exists = await cveIssueExists(repoId, finding.cveId);
537 if (exists) return null;
538
539 const title = `[CVE] ${finding.packageName} ${finding.installedVersion}${finding.severity.toUpperCase()} vulnerability (${finding.cveId})`;
540 const body = renderVulnIssueBody(finding);
541
542 try {
543 const [inserted] = await db
544 .insert(issues)
545 .values({ repositoryId: repoId, authorId, title, body, state: "open" })
546 .returning();
547 if (!inserted) return null;
548
549 if (labelId) await attachLabel(inserted.id, labelId);
550
551 // Bump issue count.
552 try {
553 const [repo] = await db
554 .select({ issueCount: repositories.issueCount })
555 .from(repositories)
556 .where(eq(repositories.id, repoId))
557 .limit(1);
558 if (repo) {
559 await db
560 .update(repositories)
561 .set({ issueCount: (repo.issueCount || 0) + 1 })
562 .where(eq(repositories.id, repoId));
563 }
564 } catch {
565 /* best-effort */
566 }
567
568 return inserted.number;
569 } catch {
570 return null;
571 }
572}
573
574/**
575 * Find the weekly digest issue for this repo, if one was already opened this week.
576 * Returns the issue ID or null.
577 */
578async function findThisWeeksDigestIssue(
579 repoId: string
580): Promise<{ id: string; number: number } | null> {
581 try {
582 const weekAgo = new Date(Date.now() - MS_PER_WEEK);
583 const rows = await db
584 .select({ id: issues.id, number: issues.number, body: issues.body, createdAt: issues.createdAt })
585 .from(issues)
586 .where(
587 and(
588 eq(issues.repositoryId, repoId),
589 eq(issues.state, "open"),
590 ilike(issues.title, "%Dependency scan digest%")
591 )
592 )
593 .limit(5);
594
595 for (const row of rows) {
596 if (
597 row.body?.includes(WEEKLY_DIGEST_MARKER) &&
598 row.createdAt >= weekAgo
599 ) {
600 return { id: row.id, number: row.number };
601 }
602 }
603 return null;
604 } catch {
605 return null;
606 }
607}
608
609/** Render the digest body for medium/low findings. */
610function renderDigestBody(findings: VulnFinding[]): string {
611 const rows = findings
612 .map(
613 (f) =>
614 `| \`${f.packageName}\` | \`${f.installedVersion}\` | ${f.severity.toUpperCase()} | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) | ${f.fixVersion ? `\`${f.fixVersion}\`` : "None available"} |`
615 )
616 .join("\n");
617
618 return [
619 WEEKLY_DIGEST_MARKER,
620 "",
621 `> Automated weekly digest of medium/low severity dependency vulnerabilities detected by GlueCron.`,
622 "",
623 `## Findings`,
624 "",
625 `| Package | Version | Severity | Advisory | Fix |`,
626 `|---|---|---|---|---|`,
627 rows,
628 "",
629 `---`,
630 `_This digest is updated by GlueCron on each push that touches dependency files. Close once all findings are resolved or accepted._`,
631 ].join("\n");
632}
633
634/**
635 * Open or update the weekly digest issue for medium/low findings.
636 */
637async function upsertDigestIssue(
638 repoId: string,
639 authorId: string,
640 findings: VulnFinding[],
641 labelId: string | null
642): Promise<void> {
643 if (findings.length === 0) return;
644
645 const existing = await findThisWeeksDigestIssue(repoId);
646 const title = `Dependency scan digest — ${new Date().toISOString().slice(0, 10)}`;
647 const body = renderDigestBody(findings);
648
649 try {
650 if (existing) {
651 // Update the existing digest issue body.
652 await db
653 .update(issues)
654 .set({ body, updatedAt: new Date() })
655 .where(eq(issues.id, existing.id));
656 } else {
657 // Open a new digest issue.
658 const [inserted] = await db
659 .insert(issues)
660 .values({ repositoryId: repoId, authorId, title, body, state: "open" })
661 .returning();
662 if (inserted && labelId) await attachLabel(inserted.id, labelId);
663 if (inserted) {
664 try {
665 const [repo] = await db
666 .select({ issueCount: repositories.issueCount })
667 .from(repositories)
668 .where(eq(repositories.id, repoId))
669 .limit(1);
670 if (repo) {
671 await db
672 .update(repositories)
673 .set({ issueCount: (repo.issueCount || 0) + 1 })
674 .where(eq(repositories.id, repoId));
675 }
676 } catch {
677 /* best-effort */
678 }
679 }
680 }
681 } catch {
682 /* best-effort */
683 }
684}
685
686// ---------------------------------------------------------------------------
687// Main entry point
688// ---------------------------------------------------------------------------
689
690/**
691 * Scans the dependency files changed in a push and opens/updates security issues.
692 *
693 * @param repoId - DB repository UUID
694 * @param owner - Repository owner username
695 * @param repoName - Repository name
696 * @param headSha - The new commit SHA (used to read dependency files)
697 * @param oldSha - The previous commit SHA (used to detect changed files)
698 * @param pusherUserId - The user who pushed (used as issue author)
699 * @returns Array of VulnFindings found (may be empty)
700 */
701export async function scanDependencies(
702 repoId: string,
703 owner: string,
704 repoName: string,
705 headSha: string,
706 oldSha: string,
707 pusherUserId: string
708): Promise<VulnFinding[]> {
709 try {
710 // 1. Detect which dependency files changed in this push.
711 const changedFiles = await gitDiffNames(owner, repoName, oldSha, headSha);
712 const relevantFiles = DEPENDENCY_FILES.filter((f) =>
713 changedFiles.includes(f)
714 );
715 if (relevantFiles.length === 0) return [];
716
717 // 2. Parse all changed dependency files into a package list.
718 const allPackages: ParsedPackage[] = [];
719 for (const file of relevantFiles) {
720 const content = await gitShow(owner, repoName, headSha, file);
721 if (!content) continue;
722 const parsed = parseManifest(file, content);
723 allPackages.push(...parsed);
724 }
725 if (allPackages.length === 0) return [];
726
727 // 3. Deduplicate by (name, version, ecosystem) to avoid redundant OSV queries.
728 const seen = new Set<string>();
729 const uniquePackages = allPackages.filter((p) => {
730 const key = `${p.ecosystem}:${p.name}@${p.version}`;
731 if (seen.has(key)) return false;
732 seen.add(key);
733 return true;
734 });
735
736 // 4. Query OSV in batches of 200 (API limit).
737 const BATCH = 200;
738 const findings: VulnFinding[] = [];
739 for (let i = 0; i < uniquePackages.length; i += BATCH) {
740 const batch = uniquePackages.slice(i, i + BATCH);
741 const results = await queryOsv(batch);
742 findings.push(...osvResultsToFindings(batch, results));
743 }
744
745 if (findings.length === 0) return [];
746
747 // 5. Ensure the security label exists.
748 const labelId = await getOrCreateSecurityLabel(repoId);
749
750 // 6. Use pusherUserId as author; fall back to repo owner if not available.
751 const authorId = pusherUserId || (await getRepoOwnerId(repoId)) || "";
752 if (!authorId) return findings;
753
754 // 7. Separate critical/high from medium/low.
755 const urgent = findings.filter(
756 (f) => f.severity === "critical" || f.severity === "high"
757 );
758 const digest = findings.filter(
759 (f) => f.severity === "medium" || f.severity === "low"
760 );
761
762 // 8. Open one issue per critical/high CVE (idempotent via dedup check).
763 for (const f of urgent) {
764 await openVulnIssue(repoId, authorId, f, labelId).catch(() => {});
765 }
766
767 // 9. Upsert the weekly digest for medium/low findings.
768 await upsertDigestIssue(repoId, authorId, digest, labelId).catch(() => {});
769
770 return findings;
771 } catch (err) {
772 console.error("[dependency-scanner] unexpected error:", err);
773 return [];
774 }
775}
776
777// ---------------------------------------------------------------------------
778// Test seam
779// ---------------------------------------------------------------------------
780
781export const __internal = {
782 parsePackageJson,
783 parseRequirementsTxt,
784 parseCargoToml,
785 parseGoMod,
786 parseGemfile,
787 mapSeverity,
788 extractFixVersion,
789 osvResultsToFindings,
790 renderVulnIssueBody,
791 renderDigestBody,
792 WEEKLY_DIGEST_MARKER,
793 FILE_ECOSYSTEM,
794 DEPENDENCY_FILES,
795};
Addedsrc/lib/onboarding-drip.ts+345−0View fileUnifiedSplit
1/**
2 * Onboarding email drip sequence.
3 *
4 * Sends three transactional emails to new users:
5 * "welcome" — T+0, sent immediately on registration.
6 * "day1" — T+1 day, sent by the autopilot drip task.
7 * "day3" — T+3 days, sent by the autopilot drip task.
8 *
9 * Idempotency: each key is written to `users.onboarding_emails_sent` (jsonb
10 * array) before the send call returns. If the process crashes mid-send the
11 * row is updated on the next tick when the key is already present, so the
12 * user never receives a duplicate. Keys are only appended — never removed.
13 *
14 * Contract: every exported function must never throw. Failures are logged and
15 * swallowed so a downed email provider or DB hiccup never breaks the
16 * registration path or the autopilot tick.
17 */
18
19import { eq, sql, and, lte } from "drizzle-orm";
20import { db } from "../db";
21import { users } from "../db/schema";
22import { sendEmail, absoluteUrl } from "./email";
23
24// ---------------------------------------------------------------------------
25// Drip schedule constants
26// ---------------------------------------------------------------------------
27
28/** Ordered drip keys. The "welcome" email is sent at T+0 from the auth route;
29 * "day1" and "day3" are sent by the autopilot task. */
30export const DRIP_KEYS = ["welcome", "day1", "day3"] as const;
31export type DripKey = (typeof DRIP_KEYS)[number];
32
33/** Minimum age (ms) before each drip email is eligible. */
34const DRIP_DELAY_MS: Record<DripKey, number> = {
35 welcome: 0,
36 day1: 24 * 60 * 60 * 1000,
37 day3: 3 * 24 * 60 * 60 * 1000,
38};
39
40// ---------------------------------------------------------------------------
41// Email HTML helpers
42// ---------------------------------------------------------------------------
43
44function footer(baseUrl: string): string {
45 return `
46<p style="margin:28px 0 0;font-size:12px;color:#8b949e;border-top:1px solid #21262d;padding-top:16px">
47 You're receiving this because you created a Gluecron account.<br>
48 <a href="${baseUrl}/settings/notifications" style="color:#8b949e">Unsubscribe from onboarding emails</a>
49</p>`;
50}
51
52function wrapHtml(body: string, baseUrl: string): string {
53 return `<!DOCTYPE html>
54<html lang="en">
55<head>
56<meta charset="utf-8">
57<meta name="viewport" content="width=device-width,initial-scale=1">
58<title>Gluecron</title>
59</head>
60<body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#c9d1d9">
61<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117;padding:32px 0">
62 <tr><td align="center">
63 <table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:32px">
64 <tr><td>
65 <div style="margin-bottom:24px">
66 <span style="font-size:18px;font-weight:600;color:#f0f6fc">gluecron</span>
67 </div>
68 ${body}
69 ${footer(baseUrl)}
70 </td></tr>
71 </table>
72 </td></tr>
73</table>
74</body>
75</html>`;
76}
77
78function welcomeHtml(username: string, baseUrl: string): string {
79 const body = `
80<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Welcome to Gluecron, ${username}!</h1>
81<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
82 Your AI-native code platform is ready. Push code, open issues, and let Gluecron's
83 AI review every PR, enforce your gates, and merge when conditions pass — all automatically.
84</p>
85<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
86 Start by <a href="${baseUrl}/new" style="color:#58a6ff;text-decoration:none">creating your first repository</a>
87 or exploring what's already there.
88</p>
89<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#238636;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
90 Go to dashboard →
91</a>`;
92 return wrapHtml(body, baseUrl);
93}
94
95function day1Html(username: string, baseUrl: string): string {
96 const body = `
97<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Try Spec-to-PR, ${username}</h1>
98<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
99 Pick any open issue in your repo, add the <code style="background:#21262d;padding:2px 5px;border-radius:4px;font-size:13px;color:#79c0ff">ai:build</code> label,
100 and watch Gluecron build it into a draft PR in 90 seconds.
101</p>
102<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
103 No extra config required — the AI reads the issue title and body, writes the code, and opens the PR on your behalf.
104</p>
105<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#1f6feb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
106 Try it now →
107</a>`;
108 return wrapHtml(body, baseUrl);
109}
110
111function day3Html(username: string, baseUrl: string): string {
112 const body = `
113<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Your AI is watching, ${username}</h1>
114<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
115 Here's what Gluecron autopilot does for you automatically:
116</p>
117<ul style="margin:0 0 16px;padding-left:20px;font-size:15px;line-height:1.8;color:#c9d1d9">
118 <li><strong style="color:#f0f6fc">Reviews every PR</strong> — inline AI comments the moment you push.</li>
119 <li><strong style="color:#f0f6fc">Merges when gates pass</strong> — auto-merge fires once all required checks succeed.</li>
120 <li><strong style="color:#f0f6fc">Heals failed CI</strong> — the CI healer reads the logs and opens a fix PR automatically.</li>
121</ul>
122<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
123 All of this runs in the background — no manual intervention needed.
124</p>
125<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#8957e5;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
126 View your dashboard →
127</a>`;
128 return wrapHtml(body, baseUrl);
129}
130
131// ---------------------------------------------------------------------------
132// Core send helper (used by both the auth hook and the drip task)
133// ---------------------------------------------------------------------------
134
135interface UserRow {
136 id: string;
137 email: string;
138 username: string;
139 onboardingEmailsSent: string[] | null;
140}
141
142/**
143 * Mark a drip key as sent for a user — writes to DB first, then sends.
144 * Returns true on success, false on any failure (error already logged).
145 */
146async function markAndSend(
147 user: UserRow,
148 key: DripKey,
149 subject: string,
150 text: string,
151 html: string
152): Promise<boolean> {
153 const sent: string[] = Array.isArray(user.onboardingEmailsSent)
154 ? user.onboardingEmailsSent
155 : [];
156
157 if (sent.includes(key)) return true; // already delivered — idempotent
158
159 // Persist before sending so a crash doesn't re-send on the next tick.
160 try {
161 await db
162 .update(users)
163 .set({
164 onboardingEmailsSent: [...sent, key] as string[],
165 })
166 .where(eq(users.id, user.id));
167 } catch (err) {
168 console.error(
169 `[onboarding-drip] DB update failed for user=${user.id} key=${key}:`,
170 err
171 );
172 return false;
173 }
174
175 const result = await sendEmail({ to: user.email, subject, text, html });
176 if (!result.ok) {
177 console.error(
178 `[onboarding-drip] sendEmail failed for user=${user.id} key=${key}: ${result.error ?? result.skipped ?? "unknown"}`
179 );
180 // Don't return false — the DB row is already marked, which is correct.
181 // The user won't get a duplicate even if the email bounced.
182 }
183 return true;
184}
185
186// ---------------------------------------------------------------------------
187// T+0 welcome email — called directly from the registration route
188// ---------------------------------------------------------------------------
189
190/**
191 * Send the "welcome" email immediately after a new user registers.
192 * Best-effort; never throws. Safe to fire-and-forget (`void`).
193 */
194export async function sendWelcomeEmail(userId: string): Promise<void> {
195 try {
196 const [user] = await db
197 .select({
198 id: users.id,
199 email: users.email,
200 username: users.username,
201 onboardingEmailsSent: users.onboardingEmailsSent,
202 })
203 .from(users)
204 .where(eq(users.id, userId))
205 .limit(1);
206
207 if (!user) return;
208
209 const baseUrl = absoluteUrl("/").replace(/\/$/, "");
210 await markAndSend(
211 user as UserRow,
212 "welcome",
213 "Welcome to Gluecron — your AI code platform is ready",
214 `Hi ${user.username},\n\nWelcome to Gluecron! Your AI-native code platform is ready.\n\nStart by creating your first repository: ${baseUrl}/new\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
215 welcomeHtml(user.username, baseUrl)
216 );
217 } catch (err) {
218 console.error(
219 `[onboarding-drip] sendWelcomeEmail threw for userId=${userId}:`,
220 err
221 );
222 }
223}
224
225// ---------------------------------------------------------------------------
226// Autopilot drip task — processes T+1 and T+3 emails in bulk
227// ---------------------------------------------------------------------------
228
229export interface OnboardingDripSummary {
230 sent: number;
231 skipped: number;
232 errors: number;
233}
234
235/** Per-tick cap — avoids hammering the email provider on a large install. */
236const DRIP_CAP_PER_TICK = 50;
237
238/**
239 * One iteration of the onboarding-drip autopilot task.
240 * Scans all non-playground, non-deleted users for pending drip emails
241 * (day1 at T+1d, day3 at T+3d) and sends them. Never throws.
242 */
243export async function runOnboardingDripTaskOnce(
244 opts: { now?: Date; cap?: number } = {}
245): Promise<OnboardingDripSummary> {
246 const now = opts.now ?? new Date();
247 const cap = opts.cap ?? DRIP_CAP_PER_TICK;
248
249 let rows: UserRow[] = [];
250 try {
251 // Fetch users created at least 1 day ago (earliest pending drip) who are
252 // not playground or soft-deleted accounts. We over-fetch relative to cap
253 // and filter in JS so we can apply per-user logic without N+1 queries.
254 const cutoff = new Date(now.getTime() - DRIP_DELAY_MS.day1);
255 const raw = await db
256 .select({
257 id: users.id,
258 email: users.email,
259 username: users.username,
260 onboardingEmailsSent: users.onboardingEmailsSent,
261 createdAt: users.createdAt,
262 })
263 .from(users)
264 .where(
265 and(
266 lte(users.createdAt, cutoff),
267 eq(users.isPlayground, false),
268 // Only target users who are not soft-deleted (deletedAt IS NULL).
269 sql`${users.deletedAt} IS NULL`
270 )
271 )
272 .limit(cap * 2); // fetch 2× so we still fill cap after skips
273
274 rows = raw as unknown as (UserRow & { createdAt: Date })[];
275 } catch (err) {
276 console.error("[onboarding-drip] candidate query failed:", err);
277 return { sent: 0, skipped: 0, errors: 1 };
278 }
279
280 const baseUrl = absoluteUrl("/").replace(/\/$/, "");
281 let sent = 0;
282 let skipped = 0;
283 let errors = 0;
284
285 for (const rawRow of rows) {
286 if (sent >= cap) break;
287
288 const row = rawRow as UserRow & { createdAt: Date };
289 const emailsSent: string[] = Array.isArray(row.onboardingEmailsSent)
290 ? row.onboardingEmailsSent
291 : [];
292 const ageMs = now.getTime() - new Date(row.createdAt).getTime();
293
294 // Process day1 and day3 in order; stop at the first unsent one to avoid
295 // skipping day1 and going straight to day3 on a slow-polling instance.
296 let didSend = false;
297
298 try {
299 // day1 — T+1d
300 if (
301 ageMs >= DRIP_DELAY_MS.day1 &&
302 !emailsSent.includes("day1")
303 ) {
304 const ok = await markAndSend(
305 row,
306 "day1",
307 "[Gluecron] Try Spec-to-PR — build a feature in 90 seconds",
308 `Hi ${row.username},\n\nPick any open issue in your repo, add the ai:build label, and watch Gluecron build it into a PR in 90 seconds.\n\nGo to your dashboard: ${baseUrl}/dashboard\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
309 day1Html(row.username, baseUrl)
310 );
311 if (ok) { sent += 1; didSend = true; }
312 else errors += 1;
313 // Refresh the sent list so day3 sees the updated state.
314 emailsSent.push("day1");
315 }
316
317 // day3 — T+3d (only if day1 is done)
318 if (
319 ageMs >= DRIP_DELAY_MS.day3 &&
320 emailsSent.includes("day1") &&
321 !emailsSent.includes("day3")
322 ) {
323 const ok = await markAndSend(
324 row,
325 "day3",
326 "[Gluecron] Your AI is watching — autopilot does this for you",
327 `Hi ${row.username},\n\nGluecron autopilot automatically reviews every PR, merges when gates pass, and heals failed CI.\n\nGo to your dashboard: ${baseUrl}/dashboard\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
328 day3Html(row.username, baseUrl)
329 );
330 if (ok) { sent += 1; didSend = true; }
331 else errors += 1;
332 }
333
334 if (!didSend) skipped += 1;
335 } catch (err) {
336 errors += 1;
337 console.error(
338 `[onboarding-drip] per-user error for user=${row.id}:`,
339 err
340 );
341 }
342 }
343
344 return { sent, skipped, errors };
345}
Addedsrc/lib/preview-builder.ts+312−0View fileUnifiedSplit
1/**
2 * PR preview builder (migration 0077).
3 *
4 * When a PR is opened or updated and the repo has `preview_build_command`
5 * configured, this module:
6 * 1. Clones the PR's head branch into /tmp/previews/<prId>-<shortSha>
7 * 2. Runs the configured build command with a 2-minute timeout
8 * 3. Captures stdout + stderr as the build log
9 * 4. On success: marks the pr_previews row as 'ready' and posts a PR comment
10 * 5. On failure: marks it 'failed' and stores the log
11 *
12 * Feature flag: preview builds only run when PREVIEW_DOMAIN env var is set
13 * AND the repo has preview_build_command configured.
14 *
15 * Philosophy: never throw — every DB + subprocess call is wrapped in
16 * try/catch so a failure cannot disrupt the PR creation path. Callers use
17 * `buildPreview(...).catch(() => {})` fire-and-forget style.
18 */
19
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 pullRequests,
25 prPreviews,
26 prComments,
27 users,
28} from "../db/schema";
29import { config } from "./config";
30
31const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
32const BUILD_TIMEOUT_MS = 2 * 60 * 1_000; // 2 minutes
33
34// ─── helpers ────────────────────────────────────────────────────────────────
35
36/** Slugify a string for use in a URL path segment. */
37function slug(s: string): string {
38 return (s || "")
39 .toLowerCase()
40 .replace(/[^a-z0-9]+/g, "-")
41 .replace(/^-+|-+$/g, "")
42 .slice(0, 60);
43}
44
45/** Compute the public URL for a built preview. */
46export function previewBuilderUrl(
47 ownerName: string,
48 repoName: string,
49 branchName: string
50): string {
51 const domain = config.previewDomain || config.appBaseUrl;
52 return `${domain}/previews/${ownerName}/${repoName}/${slug(branchName)}/`;
53}
54
55/** The on-disk directory where built output lives. */
56export function previewBuildPath(
57 prId: string,
58 headSha: string,
59 outputDir: string
60): string {
61 const shortSha = headSha.slice(0, 8);
62 return `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}/${outputDir}`;
63}
64
65// ─── core builder ───────────────────────────────────────────────────────────
66
67/**
68 * Build a preview for the given PR. Fire-and-forget by callers:
69 * `buildPreview(prId, repoId, headSha).catch(() => {})`
70 */
71export async function buildPreview(
72 prId: string,
73 repoId: string,
74 headSha: string
75): Promise<void> {
76 // Feature flag: only run when PREVIEW_DOMAIN is set
77 if (!config.previewDomain) return;
78
79 // ── look up the repo for build config ──
80 let repo: {
81 id: string;
82 name: string;
83 ownerId: string;
84 previewBuildCommand: string | null;
85 previewOutputDir: string | null;
86 diskPath: string;
87 } | null = null;
88
89 let ownerUsername = "";
90
91 try {
92 const [row] = await db
93 .select({
94 id: repositories.id,
95 name: repositories.name,
96 ownerId: repositories.ownerId,
97 previewBuildCommand: repositories.previewBuildCommand,
98 previewOutputDir: repositories.previewOutputDir,
99 diskPath: repositories.diskPath,
100 })
101 .from(repositories)
102 .where(eq(repositories.id, repoId))
103 .limit(1);
104 if (!row) return;
105 repo = row;
106
107 // Resolve owner username for URL construction
108 const [ownerRow] = await db
109 .select({ username: users.username })
110 .from(users)
111 .where(eq(users.id, repo.ownerId))
112 .limit(1);
113 ownerUsername = ownerRow?.username ?? "";
114 } catch (err) {
115 console.warn("[preview-builder] repo lookup failed:", err instanceof Error ? err.message : err);
116 return;
117 }
118
119 // Opt-in gate: skip if no build command configured
120 if (!repo.previewBuildCommand) return;
121
122 // ── look up the PR for branch name ──
123 let pr: { id: string; headBranch: string; number: number } | null = null;
124 try {
125 const [row] = await db
126 .select({ id: pullRequests.id, headBranch: pullRequests.headBranch, number: pullRequests.number })
127 .from(pullRequests)
128 .where(eq(pullRequests.id, prId))
129 .limit(1);
130 if (!row) return;
131 pr = row;
132 } catch (err) {
133 console.warn("[preview-builder] pr lookup failed:", err instanceof Error ? err.message : err);
134 return;
135 }
136
137 const buildCommand = repo.previewBuildCommand;
138 const outputDir = repo.previewOutputDir || "dist";
139 const shortSha = headSha.slice(0, 8);
140 const previewUrl = previewBuilderUrl(ownerUsername, repo.name, pr.headBranch);
141 const buildDir = `${PREVIEW_BUILD_DIR}/${slug(prId)}-${shortSha}`;
142
143 // ── upsert preview row to 'building' ──
144 let previewRowId: number | null = null;
145 try {
146 const [row] = await db
147 .insert(prPreviews)
148 .values({
149 repoId,
150 prId,
151 branchName: pr.headBranch,
152 headSha,
153 status: "building",
154 previewUrl,
155 buildCommand,
156 outputDir,
157 createdAt: new Date(),
158 updatedAt: new Date(),
159 })
160 .onConflictDoNothing()
161 .returning({ id: prPreviews.id });
162 // If there's already a row (same prId+headSha isn't unique, but let's handle it)
163 if (row) {
164 previewRowId = row.id;
165 } else {
166 // Find existing
167 const [existing] = await db
168 .select({ id: prPreviews.id })
169 .from(prPreviews)
170 .where(and(eq(prPreviews.prId, prId), eq(prPreviews.headSha, headSha)))
171 .limit(1);
172 previewRowId = existing?.id ?? null;
173 }
174 } catch (err) {
175 console.warn("[preview-builder] insert failed:", err instanceof Error ? err.message : err);
176 return;
177 }
178
179 // ── clone + build ──
180 const buildStart = Date.now();
181 let buildLog = "";
182 let buildOk = false;
183
184 try {
185 // Step 1: clone the bare repo and checkout the head branch
186 const cloneProc = Bun.spawn(
187 ["git", "clone", "--branch", pr.headBranch, "--depth", "1", repo.diskPath, buildDir],
188 { stderr: "pipe", stdout: "pipe" }
189 );
190
191 const cloneStdout = await new Response(cloneProc.stdout).text();
192 const cloneStderr = await new Response(cloneProc.stderr).text();
193 await cloneProc.exited;
194
195 buildLog += `=== clone ===\n${cloneStdout}${cloneStderr}\n`;
196
197 if (cloneProc.exitCode !== 0) {
198 throw new Error(`git clone failed (exit ${cloneProc.exitCode}): ${cloneStderr.slice(0, 500)}`);
199 }
200
201 // Step 2: run the build command with timeout
202 const buildProc = Bun.spawn(
203 ["sh", "-c", buildCommand],
204 {
205 cwd: buildDir,
206 stderr: "pipe",
207 stdout: "pipe",
208 env: { ...process.env, CI: "1" },
209 }
210 );
211
212 // Apply 2-minute timeout
213 const timeoutHandle = setTimeout(() => {
214 try { buildProc.kill(); } catch {}
215 }, BUILD_TIMEOUT_MS);
216
217 const buildStdout = await new Response(buildProc.stdout).text();
218 const buildStderr = await new Response(buildProc.stderr).text();
219 await buildProc.exited;
220 clearTimeout(timeoutHandle);
221
222 buildLog += `\n=== build: ${buildCommand} ===\n${buildStdout}${buildStderr}\n`;
223
224 if (buildProc.exitCode !== 0) {
225 throw new Error(`build command failed (exit ${buildProc.exitCode})`);
226 }
227
228 buildOk = true;
229 } catch (err) {
230 buildLog += `\n=== error ===\n${err instanceof Error ? err.message : String(err)}\n`;
231 }
232
233 const durationMs = Date.now() - buildStart;
234
235 // ── update preview row ──
236 try {
237 if (previewRowId !== null) {
238 await db
239 .update(prPreviews)
240 .set({
241 status: buildOk ? "ready" : "failed",
242 buildLog: buildLog.slice(0, 50_000),
243 previewUrl: buildOk ? previewUrl : null,
244 buildDurationMs: durationMs,
245 updatedAt: new Date(),
246 })
247 .where(eq(prPreviews.id, previewRowId));
248 }
249 } catch (err) {
250 console.warn("[preview-builder] update failed:", err instanceof Error ? err.message : err);
251 }
252
253 // ── post a PR comment with the result ──
254 try {
255 // Use the system bot user (first admin user, or fall back to the PR author)
256 const [authorRow] = await db
257 .select({ authorId: pullRequests.authorId })
258 .from(pullRequests)
259 .where(eq(pullRequests.id, prId))
260 .limit(1);
261
262 if (authorRow) {
263 const buildTimeS = Math.round(durationMs / 1000);
264 const body = buildOk
265 ? `🚀 **Preview deployed**\nURL: ${previewUrl}\nBuilt from: \`${shortSha}\`\nBuild time: ${buildTimeS}s`
266 : `❌ **Preview build failed**\nCommit: \`${shortSha}\`\nBuild time: ${buildTimeS}s\n\nSee build log in the [Previews tab](/${ownerUsername}/${repo.name}/previews).`;
267
268 await db.insert(prComments).values({
269 pullRequestId: prId,
270 authorId: authorRow.authorId,
271 body,
272 isAiReview: false,
273 moderationStatus: "approved",
274 createdAt: new Date(),
275 updatedAt: new Date(),
276 });
277 }
278 } catch (err) {
279 console.warn("[preview-builder] comment failed:", err instanceof Error ? err.message : err);
280 }
281}
282
283/**
284 * Look up the most recent pr_previews row for a given PR.
285 * Returns null if none exists or the table doesn't exist yet.
286 */
287export async function getPreviewForPr(prId: string): Promise<{
288 id: number;
289 status: string;
290 previewUrl: string | null;
291 headSha: string;
292 buildDurationMs: number | null;
293} | null> {
294 if (!prId) return null;
295 try {
296 const [row] = await db
297 .select({
298 id: prPreviews.id,
299 status: prPreviews.status,
300 previewUrl: prPreviews.previewUrl,
301 headSha: prPreviews.headSha,
302 buildDurationMs: prPreviews.buildDurationMs,
303 })
304 .from(prPreviews)
305 .where(eq(prPreviews.prId, prId))
306 .orderBy(prPreviews.id)
307 .limit(1);
308 return row ?? null;
309 } catch {
310 return null;
311 }
312}
Modifiedsrc/lib/push-policy.ts+222−0View fileUnifiedSplit
2626 * Postgres hiccups.
2727 */
2828
29import { mkdtemp, writeFile, rm } from "fs/promises";
30import { join } from "path";
31import { tmpdir } from "os";
2932import {
3033 matchProtectedTag,
3134 canBypassProtectedTag,
3336import {
3437 listRulesetsForRepo,
3538 evaluatePush,
39 parseParams,
3640 type PushContext,
3741} from "./rulesets";
42import type { RulesetRule, RepoRuleset } from "../db/schema";
3843
3944export type RefUpdate = {
4045 oldSha: string;
4651 repositoryId: string;
4752 refs: RefUpdate[];
4853 pusherUserId: string | null;
54 /** Absolute path to the bare repo dir; enables pack-content inspection. */
55 repoPath?: string;
4956};
5057
5158export type PushPolicyResult = {
5663/** "0000000000000000000000000000000000000000" — 40 zeros. */
5764export const ZERO_SHA = "0".repeat(40);
5865
66// ----------------------------------------------------------------------------
67// Pack-content inspection via pre-receive hook
68// ----------------------------------------------------------------------------
69
70type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };
71
72/**
73 * JS source for the per-push evaluator that the pre-receive hook calls once
74 * per ref. Receives three file-path arguments:
75 * argv[1] = rules.json path
76 * argv[2] = commits file (lines: "<sha> <subject>")
77 * argv[3] = sizes file (lines: "<bytes> <path>")
78 *
79 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
80 * Exits 0 always — the shell hook interprets the output.
81 */
82function buildEvalScript(): string {
83 // Written as a plain JS string so no TypeScript template interpolation occurs.
84 // Uses only Node/Bun built-ins; no imports from the Gluecron codebase.
85 return [
86 "import { readFileSync } from 'fs';",
87 "const rules = JSON.parse(readFileSync(process.argv[2], 'utf8'));",
88 "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);",
89 "const sizes = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);",
90 "const SEP = '\\t';",
91 "const out = [];",
92 "for (const line of commits) {",
93 " const sp = line.indexOf(' ');",
94 " if (sp < 0) continue;",
95 " const sha = line.slice(0, sp);",
96 " const msg = line.slice(sp + 1);",
97 " for (const r of rules.commitMsgRules) {",
98 " const pattern = String(r.params.pattern || '');",
99 " if (!pattern) continue;",
100 " let re;",
101 " try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }",
102 " const req = r.params.require !== false;",
103 " const ok = re.test(msg);",
104 " if (req && !ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');",
105 " if (!req && ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');",
106 " }",
107 "}",
108 "for (const line of sizes) {",
109 " const sp = line.indexOf(' ');",
110 " if (sp < 0) continue;",
111 " const sz = Number(line.slice(0, sp));",
112 " const fp = line.slice(sp + 1);",
113 " for (const r of rules.blockedPathRules) {",
114 " const globs = Array.isArray(r.params.paths) ? r.params.paths : [];",
115 " for (const g of globs) {",
116 " const parts = [];",
117 " let i = 0;",
118 " while (i < g.length) {",
119 " const ch = g[i];",
120 " if (ch === '*') {",
121 " if (g[i+1] === '*') { parts.push('.*'); i += 2; }",
122 " else { parts.push('[^/]*'); i++; }",
123 " } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {",
124 " parts.push('\\\\' + ch); i++;",
125 " } else { parts.push(ch); i++; }",
126 " }",
127 " const re = new RegExp('^' + parts.join('') + '$');",
128 " if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');",
129 " }",
130 " }",
131 " for (const r of rules.maxSizeRules) {",
132 " const limit = Number(r.params.bytes || 0);",
133 " if (limit && sz > limit)",
134 " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');",
135 " }",
136 "}",
137 "process.stdout.write(out.join('\\n'));",
138 ].join("\n") + "\n";
139}
140
141/**
142 * Generate a bash pre-receive hook that calls the companion eval.js once per
143 * pushed ref. Both file paths are embedded literals so the script is
144 * fully self-contained.
145 */
146function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string {
147 const D = "$";
148 return [
149 "#!/bin/bash",
150 "set -uo pipefail",
151 `EVAL_SCRIPT='${evalScriptPath}'`,
152 `RULES_JSON='${rulesJsonPath}'`,
153 "FAILED=0",
154 "",
155 `while IFS=' ' read -r OLD NEW REF; do`,
156 ` [[ "${D}NEW" =~ ^0+${D} ]] && continue`,
157 ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`,
158 ` LOG_RANGE="${D}NEW"`,
159 // Empty-tree SHA — safe diff base for new branches with no parent.
160 ` DIFF_BASE=4b825dc642cb6eb9a060e54bf8d69288fbee4904`,
161 ` else`,
162 ` LOG_RANGE="${D}OLD..${D}NEW"`,
163 ` DIFF_BASE="${D}OLD"`,
164 ` fi`,
165 "",
166 ` COMMITS_TMP=$(mktemp)`,
167 ` SIZES_TMP=$(mktemp)`,
168 ` PATHS_TMP=$(mktemp)`,
169 ` git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`,
170 ` git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
171 "",
172 // Build sizes file: "<bytes> <path>" per changed file.
173 ` while IFS= read -r FP; do`,
174 ` [ -z "${D}FP" ] && continue`,
175 ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
176 ` SZ=0`,
177 ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
178 ` printf '%s %s\\n' "${D}SZ" "${D}FP"`,
179 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
180 "",
181 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" 2>/dev/null || true)`,
182 "",
183 // Each output line is "<enforcement>\t<message>"; split on tab.
184 ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
185 ` [ -z "${D}MSG_OUT" ] && continue`,
186 ` echo "remote: ${D}MSG_OUT" >&2`,
187 ` [ "${D}ENFORCE" = "active" ] && FAILED=1`,
188 ` done <<< "${D}RESULT"`,
189 "",
190 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP"`,
191 `done`,
192 "",
193 `exit ${D}FAILED`,
194 ].join("\n") + "\n";
195}
196
197/**
198 * Write a pre-receive hook + companion rules JSON to a temp directory and
199 * return the git env vars that redirect git to use that hooks dir, plus a
200 * cleanup function.
201 *
202 * Callers must always invoke cleanup() — even on error — to remove the temp
203 * directory. Failure to set up the hook dir is non-fatal: we return null so
204 * the caller can proceed without pack-content inspection rather than wedging
205 * the push.
206 */
207export async function installPackInspectionHook(
208 rulesets: ActiveRuleset[]
209): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
210 // Collect pack-content rules from non-disabled rulesets.
211 type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
212 const commitMsgRules: RuleEntry[] = [];
213 const blockedPathRules: RuleEntry[] = [];
214 const maxSizeRules: RuleEntry[] = [];
215
216 for (const rs of rulesets) {
217 if (rs.enforcement === "disabled") continue;
218 for (const r of rs.rules) {
219 const p = parseParams(r.params);
220 const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p };
221 if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry);
222 else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry);
223 else if (r.ruleType === "max_file_size") maxSizeRules.push(entry);
224 }
225 }
226
227 // No pack-content rules → skip hook installation entirely.
228 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length) {
229 return null;
230 }
231
232 try {
233 const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-"));
234 const rulesJsonPath = join(dir, "rules.json");
235 await writeFile(
236 rulesJsonPath,
237 JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }),
238 { mode: 0o644 }
239 );
240 const evalScriptPath = join(dir, "eval.js");
241 await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 });
242 const hookPath = join(dir, "pre-receive");
243 await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 });
244 return {
245 env: {
246 GIT_CONFIG_COUNT: "1",
247 GIT_CONFIG_KEY_0: "core.hooksPath",
248 GIT_CONFIG_VALUE_0: dir,
249 },
250 cleanup: async () => {
251 try {
252 await rm(dir, { recursive: true, force: true });
253 } catch {
254 // best-effort
255 }
256 },
257 };
258 } catch {
259 return null;
260 }
261}
262
59263const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
60264
61265/**
156360 : { allowed: false, violations };
157361}
158362
363/**
364 * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and
365 * call `installPackInspectionHook`. Returns null on DB error or when there
366 * are no pack-content rules to enforce. Never throws.
367 */
368export async function installPackInspectionHookForRepo(
369 repositoryId: string
370): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
371 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
372 try {
373 rulesets = await listRulesetsForRepo(repositoryId);
374 } catch {
375 return null;
376 }
377 if (!rulesets.length) return null;
378 return installPackInspectionHook(rulesets);
379}
380
159381/** Build a human-readable error body for the 403 response. */
160382export function formatPolicyError(violations: string[]): string {
161383 if (!violations || violations.length === 0) {
Addedsrc/lib/repo-explainer.ts+672−0View fileUnifiedSplit
1/**
2 * repo-explainer.ts
3 *
4 * Core AI analysis engine for the "Explain This Repo" feature.
5 * Reads the repo's file tree + key files, then calls the Anthropic API
6 * (or falls back to heuristic analysis) to produce a structured JSON result.
7 *
8 * Never throws — all errors produce a degraded-but-valid result.
9 */
10
11import { eq, and } from "drizzle-orm";
12import { db } from "../db";
13import { repoExplainCache, repositories, users } from "../db/schema";
14import { getBlob, getTree, getDefaultBranch, resolveRef } from "../git/repository";
15import type { GitTreeEntry } from "../git/repository";
16import { config } from "./config";
17
18// ---------------------------------------------------------------------------
19// Types
20// ---------------------------------------------------------------------------
21
22export interface EntryPoint {
23 file: string;
24 role: string;
25}
26
27export interface SuggestedIssue {
28 title: string;
29 description: string;
30}
31
32export interface ExplainJobResult {
33 summary: string;
34 techStack: string[];
35 architecture: string;
36 entryPoints: EntryPoint[];
37 gettingStarted: string;
38 healthScore: "Elite" | "Strong" | "Improving" | "Needs Attention";
39 suggestedIssues: SuggestedIssue[];
40}
41
42export interface ExplainJob {
43 id: string;
44 repoId: string;
45 owner: string;
46 repo: string;
47 status: "running" | "done" | "failed";
48 result?: ExplainJobResult;
49 error?: string;
50 createdAt: Date;
51 completedAt?: Date;
52}
53
54// ---------------------------------------------------------------------------
55// In-memory job store (per-process; good enough for a single-process Bun app)
56// ---------------------------------------------------------------------------
57
58export const explainJobs = new Map<string, ExplainJob>();
59
60// ---------------------------------------------------------------------------
61// File sampling config
62// ---------------------------------------------------------------------------
63
64const MAX_TOTAL_CHARS = 80_000;
65const MAX_FILES = 50;
66const MAX_FILE_SIZE = 8_000; // chars per file (2000 lines * ~40 chars/line avg)
67
68const PRIORITY_ROOT_FILES = [
69 "README.md",
70 "README",
71 "readme.md",
72 "Readme.md",
73 "README.rst",
74 "README.txt",
75 "CLAUDE.md",
76 "package.json",
77 "pyproject.toml",
78 "Cargo.toml",
79 "go.mod",
80 "Gemfile",
81 "pom.xml",
82 "build.gradle",
83 "Dockerfile",
84 "tsconfig.json",
85 "bun.lockb",
86 ".env.example",
87];
88
89const CANDIDATE_SRC_DIRS = ["src", "lib", "app", "server", "backend", "pkg", "cmd"];
90
91// ---------------------------------------------------------------------------
92// DB cache helpers
93// ---------------------------------------------------------------------------
94
95export async function getCachedExplainResult(
96 repoId: string
97): Promise<ExplainJobResult | null> {
98 try {
99 const [row] = await db
100 .select()
101 .from(repoExplainCache)
102 .where(eq(repoExplainCache.repoId, repoId))
103 .limit(1);
104 if (!row) return null;
105 return row.result as ExplainJobResult;
106 } catch {
107 return null;
108 }
109}
110
111async function upsertExplainCache(
112 repoId: string,
113 result: ExplainJobResult
114): Promise<void> {
115 try {
116 // Try update first
117 const existing = await db
118 .select({ id: repoExplainCache.id })
119 .from(repoExplainCache)
120 .where(eq(repoExplainCache.repoId, repoId))
121 .limit(1);
122 if (existing.length > 0) {
123 await db
124 .update(repoExplainCache)
125 .set({ result, createdAt: new Date() })
126 .where(eq(repoExplainCache.id, existing[0].id));
127 } else {
128 await db.insert(repoExplainCache).values({ repoId, result });
129 }
130 } catch {
131 // Swallow — cache miss is not a fatal error
132 }
133}
134
135// ---------------------------------------------------------------------------
136// Repo resolution helpers
137// ---------------------------------------------------------------------------
138
139export async function resolveRepoForExplain(
140 owner: string,
141 repoName: string
142): Promise<{ repoId: string; ownerId: string } | null> {
143 try {
144 const [ownerRow] = await db
145 .select({ id: users.id })
146 .from(users)
147 .where(eq(users.username, owner))
148 .limit(1);
149 if (!ownerRow) return null;
150
151 const [repoRow] = await db
152 .select({ id: repositories.id, ownerId: repositories.ownerId })
153 .from(repositories)
154 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
155 .limit(1);
156 if (!repoRow) return null;
157
158 return { repoId: repoRow.id, ownerId: repoRow.ownerId };
159 } catch {
160 return null;
161 }
162}
163
164// ---------------------------------------------------------------------------
165// Main entry point: kick off an explain job asynchronously
166// ---------------------------------------------------------------------------
167
168export function startExplainJob(
169 jobId: string,
170 owner: string,
171 repoName: string,
172 repoId: string
173): void {
174 const job: ExplainJob = {
175 id: jobId,
176 repoId,
177 owner,
178 repo: repoName,
179 status: "running",
180 createdAt: new Date(),
181 };
182 explainJobs.set(jobId, job);
183
184 // Fire-and-forget
185 runExplainJob(job, owner, repoName, repoId).catch(() => {
186 const j = explainJobs.get(jobId);
187 if (j) {
188 j.status = "failed";
189 j.error = "Unexpected error during analysis";
190 j.completedAt = new Date();
191 }
192 });
193}
194
195async function runExplainJob(
196 job: ExplainJob,
197 owner: string,
198 repoName: string,
199 repoId: string
200): Promise<void> {
201 try {
202 const result = await explainRepo(owner, repoName);
203 await upsertExplainCache(repoId, result);
204 job.result = result;
205 job.status = "done";
206 job.completedAt = new Date();
207 } catch (err) {
208 job.status = "failed";
209 job.error = err instanceof Error ? err.message : "Analysis failed";
210 job.completedAt = new Date();
211 }
212}
213
214// ---------------------------------------------------------------------------
215// Core analysis function
216// ---------------------------------------------------------------------------
217
218export async function explainRepo(
219 owner: string,
220 repoName: string
221): Promise<ExplainJobResult> {
222 const branch = await getDefaultBranch(owner, repoName);
223 if (!branch) {
224 return buildHeuristicResult(owner, repoName, null, []);
225 }
226
227 const sha = await resolveRef(owner, repoName, branch);
228 if (!sha) {
229 return buildHeuristicResult(owner, repoName, null, []);
230 }
231
232 const samples = await gatherRepresentativeFiles(owner, repoName, sha);
233
234 const apiKey = config.anthropicApiKey;
235 if (apiKey && samples.files.length > 0) {
236 try {
237 return await callAnthropicForStructuredResult(
238 owner,
239 repoName,
240 samples,
241 apiKey
242 );
243 } catch {
244 // Fall through to heuristic
245 }
246 }
247
248 return buildHeuristicResult(owner, repoName, samples.packageJson, samples.topLevelTree);
249}
250
251// ---------------------------------------------------------------------------
252// File gathering
253// ---------------------------------------------------------------------------
254
255interface SampledFile {
256 path: string;
257 content: string;
258}
259
260interface Samples {
261 files: SampledFile[];
262 topLevelTree: GitTreeEntry[];
263 packageJson: Record<string, unknown> | null;
264}
265
266async function gatherRepresentativeFiles(
267 owner: string,
268 repo: string,
269 sha: string
270): Promise<Samples> {
271 const out: SampledFile[] = [];
272 let totalChars = 0;
273 const seen = new Set<string>();
274
275 let root: GitTreeEntry[] = [];
276 try {
277 root = await getTree(owner, repo, sha, "");
278 } catch {
279 root = [];
280 }
281
282 async function tryAdd(path: string): Promise<void> {
283 if (out.length >= MAX_FILES) return;
284 if (totalChars >= MAX_TOTAL_CHARS) return;
285 if (seen.has(path)) return;
286 seen.add(path);
287 try {
288 const blob = await getBlob(owner, repo, sha, path);
289 if (!blob || blob.isBinary) return;
290 if (!blob.content) return;
291 const snippet = blob.content.slice(0, Math.min(MAX_FILE_SIZE, MAX_TOTAL_CHARS - totalChars));
292 if (!snippet) return;
293 out.push({ path, content: snippet });
294 totalChars += snippet.length;
295 } catch {
296 /* skip */
297 }
298 }
299
300 // 1. Priority root-level files
301 for (const name of PRIORITY_ROOT_FILES) {
302 if (root.find((e) => e.type === "blob" && e.name === name)) {
303 await tryAdd(name);
304 }
305 }
306
307 // 2. Other manifest/config files at root
308 for (const entry of root) {
309 if (out.length >= MAX_FILES) break;
310 if (entry.type !== "blob") continue;
311 if (seen.has(entry.name)) continue;
312 if (!looksLikeManifest(entry.name)) continue;
313 await tryAdd(entry.name);
314 }
315
316 // 3. Entry-point files in common src dirs
317 for (const dir of CANDIDATE_SRC_DIRS) {
318 if (out.length >= MAX_FILES) break;
319 if (totalChars >= MAX_TOTAL_CHARS) break;
320 const dirEntry = root.find((e) => e.type === "tree" && e.name === dir);
321 if (!dirEntry) continue;
322 let children: GitTreeEntry[] = [];
323 try {
324 children = await getTree(owner, repo, sha, dir);
325 } catch {
326 children = [];
327 }
328 const entryNames = [
329 "index.ts", "index.tsx", "index.js", "main.ts", "main.tsx", "main.js",
330 "app.ts", "app.tsx", "mod.rs", "lib.rs", "__init__.py", "main.py",
331 "server.ts", "server.js",
332 ];
333 for (const name of entryNames) {
334 const hit = children.find((e) => e.type === "blob" && e.name === name);
335 if (hit) await tryAdd(`${dir}/${name}`);
336 }
337 for (const child of children) {
338 if (out.length >= MAX_FILES) break;
339 if (totalChars >= MAX_TOTAL_CHARS) break;
340 if (child.type !== "blob") continue;
341 if (!isLikelySource(child.name)) continue;
342 await tryAdd(`${dir}/${child.name}`);
343 }
344 }
345
346 // 4. Remaining top-level source files
347 for (const entry of root) {
348 if (out.length >= MAX_FILES) break;
349 if (totalChars >= MAX_TOTAL_CHARS) break;
350 if (entry.type !== "blob") continue;
351 if (!isLikelySource(entry.name)) continue;
352 await tryAdd(entry.name);
353 }
354
355 let pkg: Record<string, unknown> | null = null;
356 const packageFile = out.find((f) => f.path === "package.json");
357 if (packageFile) {
358 try {
359 pkg = JSON.parse(packageFile.content) as Record<string, unknown>;
360 } catch {
361 pkg = null;
362 }
363 }
364
365 return { files: out, topLevelTree: root, packageJson: pkg };
366}
367
368function looksLikeManifest(name: string): boolean {
369 const lower = name.toLowerCase();
370 return (
371 lower.endsWith(".toml") ||
372 lower.endsWith(".yaml") ||
373 lower.endsWith(".yml") ||
374 lower.endsWith(".json") ||
375 lower === "makefile" ||
376 lower === "dockerfile" ||
377 lower === "procfile"
378 );
379}
380
381function isLikelySource(name: string): boolean {
382 const lower = name.toLowerCase();
383 return (
384 lower.endsWith(".ts") ||
385 lower.endsWith(".tsx") ||
386 lower.endsWith(".js") ||
387 lower.endsWith(".jsx") ||
388 lower.endsWith(".mjs") ||
389 lower.endsWith(".cjs") ||
390 lower.endsWith(".py") ||
391 lower.endsWith(".rs") ||
392 lower.endsWith(".go") ||
393 lower.endsWith(".rb") ||
394 lower.endsWith(".java") ||
395 lower.endsWith(".kt") ||
396 lower.endsWith(".swift") ||
397 lower.endsWith(".c") ||
398 lower.endsWith(".cc") ||
399 lower.endsWith(".cpp") ||
400 lower.endsWith(".h") ||
401 lower.endsWith(".hpp")
402 );
403}
404
405// ---------------------------------------------------------------------------
406// Anthropic API call — structured JSON result
407// ---------------------------------------------------------------------------
408
409async function callAnthropicForStructuredResult(
410 owner: string,
411 repoName: string,
412 samples: Samples,
413 apiKey: string
414): Promise<ExplainJobResult> {
415 const treeListing = samples.topLevelTree
416 .slice(0, 80)
417 .map((e) => (e.type === "tree" ? `${e.name}/` : e.name))
418 .join("\n");
419
420 const fileContents = samples.files
421 .map((f) => `----- FILE: ${f.path} -----\n${f.content}`)
422 .join("\n\n");
423
424 const systemPrompt = `You are a senior software architect. Analyze this codebase and return a JSON object with the following keys (no markdown wrapper, just raw JSON):
425
426{
427 "summary": "2-3 sentence plain-English overview of what this project is and does",
428 "techStack": ["array", "of", "technology", "names", "detected"],
429 "architecture": "Markdown description of the architecture — folder layout, key patterns, data flows. Use bullet points or a short diagram.",
430 "entryPoints": [
431 {"file": "src/index.ts", "role": "Server entry point — starts the HTTP server"},
432 {"file": "src/app.tsx", "role": "Hono app — middleware + route composition"}
433 ],
434 "gettingStarted": "Step-by-step Markdown guide for a new developer to get the project running locally",
435 "healthScore": "one of: Elite | Strong | Improving | Needs Attention",
436 "suggestedIssues": [
437 {"title": "Short issue title", "description": "1-2 sentence description of the improvement"},
438 {"title": "Another issue", "description": "Why this matters for the project"},
439 {"title": "Third suggestion", "description": "Concrete actionable task"}
440 ]
441}
442
443Rules:
444- techStack: include runtime, language, framework, DB, and notable libraries
445- healthScore: base on code quality signals visible in the files (tests, docs, types, CI)
446- suggestedIssues: exactly 3 items, actionable and specific to THIS codebase
447- Be specific to what you see in the code — do not invent features
448- Return ONLY the JSON object, nothing else`;
449
450 const userContent = `Repository: ${owner}/${repoName}
451
452File tree:
453\`\`\`
454${treeListing || "(empty)"}
455\`\`\`
456
457Key files:
458${fileContents}`;
459
460 const response = await fetch("https://api.anthropic.com/v1/messages", {
461 method: "POST",
462 headers: {
463 "x-api-key": apiKey,
464 "anthropic-version": "2023-06-01",
465 "content-type": "application/json",
466 },
467 body: JSON.stringify({
468 model: "claude-opus-4-8",
469 max_tokens: 4000,
470 system: systemPrompt,
471 messages: [{ role: "user", content: userContent }],
472 }),
473 });
474
475 if (!response.ok) {
476 throw new Error(`Anthropic API error: ${response.status}`);
477 }
478
479 const data = (await response.json()) as {
480 content: Array<{ type: string; text: string }>;
481 };
482
483 const text = data.content?.find((b) => b.type === "text")?.text ?? "";
484
485 // Parse JSON — handle possible ```json fences
486 let parsed: ExplainJobResult | null = null;
487 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
488 if (fenced) {
489 try {
490 parsed = JSON.parse(fenced[1]) as ExplainJobResult;
491 } catch {
492 // fall through
493 }
494 }
495 if (!parsed) {
496 const braceMatch = text.match(/\{[\s\S]*\}/);
497 if (braceMatch) {
498 try {
499 parsed = JSON.parse(braceMatch[0]) as ExplainJobResult;
500 } catch {
501 // fall through
502 }
503 }
504 }
505
506 if (!parsed) {
507 throw new Error("Failed to parse structured JSON from AI response");
508 }
509
510 return normalizeResult(parsed, owner, repoName, samples);
511}
512
513// ---------------------------------------------------------------------------
514// Heuristic fallback (no API key or API failure)
515// ---------------------------------------------------------------------------
516
517function buildHeuristicResult(
518 owner: string,
519 repoName: string,
520 pkg: Record<string, unknown> | null,
521 topLevelTree: GitTreeEntry[]
522): ExplainJobResult {
523 const description =
524 pkg && typeof pkg.description === "string" && pkg.description.trim()
525 ? pkg.description.trim()
526 : `The ${owner}/${repoName} repository.`;
527
528 const techStack = detectTechStackHeuristic(pkg, topLevelTree);
529 const scripts =
530 pkg && pkg.scripts && typeof pkg.scripts === "object"
531 ? Object.keys(pkg.scripts as Record<string, unknown>)
532 : [];
533
534 const gettingStartedLines: string[] = [
535 "```bash",
536 "# Clone the repo",
537 `git clone <repo-url> && cd ${repoName}`,
538 "",
539 ];
540 if (scripts.includes("install") || pkg?.dependencies || pkg?.devDependencies) {
541 gettingStartedLines.push("# Install dependencies");
542 gettingStartedLines.push(pkg ? "bun install # or npm install" : "# see README");
543 gettingStartedLines.push("");
544 }
545 if (scripts.includes("dev")) {
546 gettingStartedLines.push("# Start dev server");
547 gettingStartedLines.push("bun dev");
548 } else if (scripts.includes("start")) {
549 gettingStartedLines.push("bun start");
550 } else {
551 gettingStartedLines.push("# See README for run instructions");
552 }
553 gettingStartedLines.push("```");
554
555 const entryPoints: EntryPoint[] = topLevelTree
556 .filter((e) => e.type === "blob" && isLikelySource(e.name))
557 .slice(0, 5)
558 .map((e) => ({ file: e.name, role: "Top-level source file" }));
559
560 return {
561 summary: description,
562 techStack,
563 architecture:
564 "Architecture analysis requires an `ANTHROPIC_API_KEY` to be configured. " +
565 "The heuristic scan detected the technologies listed in the Tech Stack section.\n\n" +
566 "Top-level layout:\n\n" +
567 topLevelTree
568 .slice(0, 20)
569 .map((e) => `- \`${e.name}${e.type === "tree" ? "/" : ""}\``)
570 .join("\n"),
571 entryPoints: entryPoints.length > 0 ? entryPoints : [
572 { file: "See README", role: "Entry point not auto-detected" },
573 ],
574 gettingStarted: gettingStartedLines.join("\n"),
575 healthScore: "Improving",
576 suggestedIssues: [
577 {
578 title: "Add ANTHROPIC_API_KEY for AI-powered analysis",
579 description:
580 "Set the ANTHROPIC_API_KEY environment variable to enable full AI-powered codebase analysis, architecture diagrams, and smart onboarding suggestions.",
581 },
582 {
583 title: "Add a comprehensive README",
584 description:
585 "A good README with setup steps, architecture overview, and contributing guidelines helps new developers onboard faster.",
586 },
587 {
588 title: "Add automated tests",
589 description:
590 "A test suite with good coverage makes it safe to refactor and ship faster with confidence.",
591 },
592 ],
593 };
594}
595
596function detectTechStackHeuristic(
597 pkg: Record<string, unknown> | null,
598 topLevelTree: GitTreeEntry[]
599): string[] {
600 const stack: string[] = [];
601 const names = topLevelTree.map((e) => e.name.toLowerCase());
602
603 if (names.includes("package.json") || names.includes("bun.lockb")) {
604 stack.push("TypeScript", "Node.js");
605 if (names.includes("bun.lockb")) stack.push("Bun");
606 }
607 if (names.includes("cargo.toml")) stack.push("Rust");
608 if (names.includes("go.mod")) stack.push("Go");
609 if (names.includes("pyproject.toml") || names.includes("requirements.txt")) stack.push("Python");
610 if (names.includes("gemfile")) stack.push("Ruby");
611 if (names.includes("pom.xml") || names.includes("build.gradle")) stack.push("Java");
612 if (names.includes("dockerfile")) stack.push("Docker");
613
614 if (pkg) {
615 const deps = {
616 ...(pkg.dependencies as Record<string, string> | undefined),
617 ...(pkg.devDependencies as Record<string, string> | undefined),
618 };
619 const dkeys = Object.keys(deps);
620 if (dkeys.includes("hono")) stack.push("Hono");
621 if (dkeys.includes("react") || dkeys.includes("react-dom")) stack.push("React");
622 if (dkeys.includes("next")) stack.push("Next.js");
623 if (dkeys.includes("drizzle-orm")) stack.push("Drizzle ORM");
624 if (dkeys.some((k) => k.includes("postgres") || k.includes("pg") || k.includes("neon")))
625 stack.push("PostgreSQL");
626 if (dkeys.includes("sqlite") || dkeys.includes("better-sqlite3"))
627 stack.push("SQLite");
628 if (dkeys.includes("express")) stack.push("Express");
629 if (dkeys.includes("fastify")) stack.push("Fastify");
630 if (dkeys.includes("vite")) stack.push("Vite");
631 if (dkeys.includes("tailwindcss")) stack.push("Tailwind CSS");
632 if (dkeys.some((k) => k.startsWith("@anthropic"))) stack.push("Anthropic Claude");
633 }
634
635 return [...new Set(stack)].slice(0, 12);
636}
637
638// ---------------------------------------------------------------------------
639// Normalize AI result — fill in any missing fields
640// ---------------------------------------------------------------------------
641
642function normalizeResult(
643 raw: Partial<ExplainJobResult>,
644 owner: string,
645 repoName: string,
646 samples: Samples
647): ExplainJobResult {
648 const validScores = ["Elite", "Strong", "Improving", "Needs Attention"] as const;
649
650 return {
651 summary: typeof raw.summary === "string" && raw.summary
652 ? raw.summary
653 : `The ${owner}/${repoName} repository.`,
654 techStack: Array.isArray(raw.techStack) ? raw.techStack.slice(0, 15) : [],
655 architecture: typeof raw.architecture === "string" && raw.architecture
656 ? raw.architecture
657 : "_Architecture not detected._",
658 entryPoints: Array.isArray(raw.entryPoints)
659 ? (raw.entryPoints as EntryPoint[]).slice(0, 10)
660 : [],
661 gettingStarted: typeof raw.gettingStarted === "string" && raw.gettingStarted
662 ? raw.gettingStarted
663 : "_See README for setup instructions._",
664 healthScore:
665 validScores.includes(raw.healthScore as typeof validScores[number])
666 ? (raw.healthScore as ExplainJobResult["healthScore"])
667 : "Improving",
668 suggestedIssues: Array.isArray(raw.suggestedIssues)
669 ? (raw.suggestedIssues as SuggestedIssue[]).slice(0, 3)
670 : [],
671 };
672}
Modifiedsrc/lib/sleep-mode.ts+2−2View fileUnifiedSplit
475475 try {
476476 await db
477477 .update(users)
478 .set({ lastDigestSentAt: new Date() })
478 .set({ lastSleepDigestSentAt: new Date() })
479479 .where(eq(users.id, userId));
480480 } catch (err) {
481 console.error("[sleep-mode] lastDigestSentAt update failed:", err);
481 console.error("[sleep-mode] lastSleepDigestSentAt update failed:", err);
482482 }
483483 return { ok: true };
484484 }
Modifiedsrc/lib/spec-to-pr.ts+33−0View fileUnifiedSplit
3333 createOrUpdateFileOnBranch,
3434 getTreeRecursive,
3535} from "../git/repository";
36import { assertAiQuota, AiQuotaExceededError } from "./billing";
3637
3738export type SpecPRArgs = {
3839 repoId: string;
7172 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
7273 }
7374
75 // 1b. AI quota hard gate. Returns a user-visible error when the budget is
76 // exhausted so the UI can surface an upgrade prompt.
77 try {
78 await assertAiQuota(args.userId);
79 } catch (err) {
80 if (err instanceof AiQuotaExceededError) {
81 return {
82 ok: false,
83 error:
84 "Monthly AI token budget reached. Upgrade at /settings/billing to continue using spec-to-PR.",
85 };
86 }
87 // Unexpected billing error — fail open so a DB glitch doesn't block users.
88 console.warn("[spec-to-pr] assertAiQuota failed unexpectedly:", err);
89 }
90
7491 const spec = typeof args.spec === "string" ? args.spec.trim() : "";
7592 if (!spec) return { ok: false, error: "spec is empty" };
7693
451468 return { ok: false, error: "repo not found" };
452469 }
453470
471 // 2b. AI quota hard gate — check against the repo owner's budget.
472 // runSpecToPr is called from the autopilot so we skip silently (return
473 // ok:false) rather than throwing; the autopilot loop logs the error.
474 try {
475 await assertAiQuota(repoRow.ownerId);
476 } catch (err) {
477 if (err instanceof AiQuotaExceededError) {
478 return {
479 ok: false,
480 error:
481 "Monthly AI token budget reached. Upgrade at /settings/billing to continue using spec-to-PR.",
482 };
483 }
484 console.warn("[spec-to-pr] assertAiQuota failed unexpectedly:", err);
485 }
486
454487 const ownerName = repoRow.ownerName;
455488 const repoName = repoRow.name;
456489 const defaultBranch = repoRow.defaultBranch || "main";
Modifiedsrc/lib/sse.ts+201−16View fileUnifiedSplit
11/**
2 * In-process topic-based pub/sub broadcaster for Server-Sent Events.
2 * Topic-based pub/sub broadcaster for Server-Sent Events.
33 *
4 * Module-level `Map<topic, Set<handler>>`. `publish` iterates subscribers
5 * synchronously (fire-and-forget). Handlers are expected not to throw — we
6 * swallow exceptions defensively so one misbehaving subscriber cannot take
7 * down the publisher or starve its peers.
4 * Two modes, selected at startup:
85 *
9 * TODO(scale): this is deliberately single-process / in-memory. Horizontally
10 * scaled deploys (multiple Bun instances behind a load balancer) will need
11 * a cross-node fanout layer — likely Redis pub/sub or NATS — that feeds this
12 * local broadcaster on each node. Until then, SSE subscribers only receive
13 * events published by the same process handling their connection.
6 * IN-MEMORY (no REDIS_URL set)
7 * Module-level `Map<topic, Set<handler>>`. `publish` iterates
8 * subscribers synchronously (fire-and-forget). Handlers are expected
9 * not to throw — we swallow exceptions defensively so one misbehaving
10 * subscriber cannot take down the publisher or starve its peers.
11 *
12 * REDIS PUB/SUB (REDIS_URL is set)
13 * Two dedicated `Bun.RedisClient` instances — one for subscribing
14 * (which enters pub/sub mode and can't issue normal commands) and one
15 * for publishing. Both are lazy-connected and auto-reconnect on
16 * disconnect via the built-in `autoReconnect` option.
17 *
18 * Architecture on each server instance:
19 * • Local `topics` map tracks handlers just like the in-memory path.
20 * • When the first handler subscribes to a topic, the Redis subscriber
21 * client issues SUBSCRIBE for that channel.
22 * • When the last handler unsubscribes, UNSUBSCRIBE is issued.
23 * • Incoming Redis messages fan out to all local handlers via
24 * `localDeliver`.
25 * • `publish` serialises the event to JSON and calls PUBLISH on the
26 * publisher client. It does NOT also call `localDeliver` — the
27 * Redis message that bounces back from the broker does that, so
28 * every instance (including the publisher) receives it exactly once
29 * through the subscriber path.
30 *
31 * Error handling: Redis errors are caught and logged to stderr; they
32 * never throw into callers. If Redis is temporarily unavailable,
33 * `publish` silently drops the cross-instance delivery (local handlers
34 * on the same instance still receive the event because `localDeliver`
35 * is always called, see NOTE below).
36 *
37 * NOTE on publish-with-no-Redis fallback:
38 * When REDIS_URL is set but the broker is unreachable at publish time,
39 * we fall back to local-only delivery so SSE streams on the same process
40 * are never broken. This matches the in-memory semantics exactly on
41 * single-instance deploys and degrades gracefully on multi-instance ones.
42 *
43 * Public API (unchanged from the original in-memory implementation):
44 * publish(topic, event) → void
45 * subscribe(topic, cb) → () => void (cleanup / unsubscribe)
46 * topicSubscriberCount(topic) → number
1447 */
1548
1649export type SSEEvent = {
2154
2255type Handler = (event: SSEEvent) => void;
2356
57// ---------------------------------------------------------------------------
58// In-memory local state (shared by both modes)
59// ---------------------------------------------------------------------------
60
2461const topics = new Map<string, Set<Handler>>();
2562
26/**
27 * Publish an event to every subscriber of `topic`. No-op if the topic has
28 * no subscribers. Handler exceptions are caught and swallowed so a single
29 * broken subscriber cannot break fanout for its peers.
30 */
31export function publish(topic: string, event: SSEEvent): void {
63/** Fan out an event to every local handler registered for `topic`. */
64function localDeliver(topic: string, event: SSEEvent): void {
3265 const subs = topics.get(topic);
3366 if (!subs || subs.size === 0) return;
3467 for (const handler of subs) {
4073 }
4174}
4275
76// ---------------------------------------------------------------------------
77// Redis layer (only initialised when REDIS_URL is present)
78// ---------------------------------------------------------------------------
79
80/** True when REDIS_URL is configured and the Redis layer has been set up. */
81let redisMode = false;
82
83// Bun.RedisClient instances — typed loosely to avoid issues when running in
84// environments where the Bun global is absent (e.g., pure Node test runners).
85// We access them through the `Bun` global at runtime so no import is needed.
86let redisPub: InstanceType<typeof Bun.RedisClient> | null = null;
87let redisSub: InstanceType<typeof Bun.RedisClient> | null = null;
88
89/** Redis channels (topics) the subscriber client is currently subscribed to. */
90const redisSubscribed = new Set<string>();
91
92/** Initialise the Redis pub and sub clients (called once, lazily). */
93function initRedis(url: string): void {
94 if (redisMode) return;
95 redisMode = true;
96
97 const opts = {
98 autoReconnect: true,
99 maxRetries: Infinity as unknown as number,
100 enableOfflineQueue: true,
101 };
102
103 redisPub = new Bun.RedisClient(url, opts);
104 redisSub = new Bun.RedisClient(url, opts);
105
106 // Log disconnections to stderr but do not crash.
107 redisPub.onclose = (err: Error) => {
108 if (err) console.error("[sse] Redis pub connection closed:", err.message);
109 };
110 redisSub.onclose = (err: Error) => {
111 if (err) console.error("[sse] Redis sub connection closed:", err.message);
112 };
113
114 // When the subscriber reconnects it must re-subscribe to all tracked
115 // channels because Redis discards subscriptions on disconnect.
116 redisSub.onconnect = function (this: InstanceType<typeof Bun.RedisClient>) {
117 for (const ch of redisSubscribed) {
118 this.subscribe(ch, redisMessageHandler).catch((e: unknown) => {
119 console.error("[sse] Redis re-subscribe failed for", ch, e);
120 });
121 }
122 };
123}
124
125/**
126 * Called by the Redis subscriber client for every incoming message.
127 * The `channel` is the topic name; `message` is a JSON-encoded SSEEvent.
128 */
129function redisMessageHandler(message: string, channel: string): void {
130 try {
131 const event = JSON.parse(message) as SSEEvent;
132 localDeliver(channel, event);
133 } catch {
134 // Malformed payload — ignore.
135 }
136}
137
138/**
139 * Ensure the Redis subscriber is subscribed to `topic`.
140 * Called when the first local handler registers for a topic.
141 */
142function redisEnsureSubscribed(topic: string): void {
143 if (redisSubscribed.has(topic) || !redisSub) return;
144 redisSubscribed.add(topic);
145 redisSub.subscribe(topic, redisMessageHandler).catch((e: unknown) => {
146 console.error("[sse] Redis subscribe failed for", topic, e);
147 });
148}
149
150/**
151 * Remove the Redis subscription for `topic` when the last local handler
152 * leaves. Silently ignored if not subscribed.
153 */
154function redisEnsureUnsubscribed(topic: string): void {
155 if (!redisSubscribed.has(topic) || !redisSub) return;
156 redisSubscribed.delete(topic);
157 redisSub.unsubscribe(topic).catch((e: unknown) => {
158 console.error("[sse] Redis unsubscribe failed for", topic, e);
159 });
160}
161
162// ---------------------------------------------------------------------------
163// Lazy Redis initialisation guard
164// ---------------------------------------------------------------------------
165
166let redisInitialised = false;
167
168function maybeInitRedis(): void {
169 if (redisInitialised) return;
170 redisInitialised = true;
171 const url = process.env.REDIS_URL || process.env.VALKEY_URL;
172 if (url) {
173 try {
174 initRedis(url);
175 } catch (e) {
176 console.error("[sse] Failed to initialise Redis clients:", e);
177 redisMode = false;
178 }
179 }
180}
181
182// Initialise eagerly at module load so reconnect logic is set up before the
183// first request arrives. The clients themselves are lazy-connected by Bun.
184maybeInitRedis();
185
186// ---------------------------------------------------------------------------
187// Public API
188// ---------------------------------------------------------------------------
189
190/**
191 * Publish an event to every subscriber of `topic`.
192 *
193 * In Redis mode the event is serialised to JSON and PUBLISH-ed to the Redis
194 * channel; the subscriber client on every instance (including this one)
195 * receives the message and fans it out to local handlers. If Redis is
196 * unavailable the event is delivered locally so SSE streams on this process
197 * are never silently broken.
198 *
199 * In in-memory mode subscribers are called synchronously.
200 */
201export function publish(topic: string, event: SSEEvent): void {
202 if (redisMode && redisPub) {
203 const payload = JSON.stringify(event);
204 redisPub.publish(topic, payload).catch((e: unknown) => {
205 // Redis unavailable — fall back to local delivery so in-process SSE
206 // streams remain functional on single-instance deploys.
207 console.error("[sse] Redis publish failed, delivering locally:", e);
208 localDeliver(topic, event);
209 });
210 // Do NOT call localDeliver here: the Redis subscriber path does it so
211 // the publisher instance doesn't double-deliver.
212 return;
213 }
214 // In-memory path.
215 localDeliver(topic, event);
216}
217
43218/**
44219 * Register a handler for `topic`. Returns a cleanup function that removes
45220 * the handler (and drops the topic's entry when its last subscriber leaves).
53228 subs = new Set<Handler>();
54229 topics.set(topic, subs);
55230 }
231 const isFirst = subs.size === 0;
56232 subs.add(handler);
57233
234 // Ensure the Redis subscriber is tracking this channel.
235 if (redisMode && isFirst) {
236 redisEnsureSubscribed(topic);
237 }
238
58239 return () => {
59240 const current = topics.get(topic);
60241 if (!current) return;
61242 current.delete(handler);
62243 if (current.size === 0) {
63244 topics.delete(topic);
245 // Release the Redis subscription when no local handlers remain.
246 if (redisMode) {
247 redisEnsureUnsubscribed(topic);
248 }
64249 }
65250 };
66251}
Modifiedsrc/lib/ssh-server.ts+25−7View fileUnifiedSplit
4444import { repoExists } from "../git/repository";
4545import { invalidateRepoCache } from "./cache";
4646import { onPostReceive } from "../hooks/post-receive";
47import { evaluatePushPolicy, formatPolicyError } from "./push-policy";
47import { evaluatePushPolicy, formatPolicyError, installPackInspectionHookForRepo } from "./push-policy";
4848import {
4949 resolveRepoAccess,
5050 satisfiesAccess,
256256function pipeGitToChannel(
257257 service: string,
258258 absRepoPath: string,
259 channel: ServerChannel
259 channel: ServerChannel,
260 extraEnv?: Record<string, string>
260261): Promise<number> {
261262 return new Promise((resolve) => {
262263 const gitProc = spawn(service, [absRepoPath], {
263 env: { ...process.env, HOME: process.env.HOME ?? "/tmp" },
264 env: { ...process.env, HOME: process.env.HOME ?? "/tmp", ...extraEnv },
264265 });
265266
266267 // Client → git stdin
349350 return;
350351 }
351352
352 // Evaluate ref-name push policies (protected tags + active rulesets).
353 // We can only do name-pattern checks here; pack-content inspection is v2.
354 // We use show-ref diff instead of parsing the pack stream.
353 // Ref-name push policies run before the pack lands (name-only checks).
354 // Pack-content inspection is wired via a pre-receive hook so it runs
355 // inside git's quarantine window — same approach as the HTTP path.
355356 const refsBefore = await getShowRef(absRepoPath);
356357
357358 audit({
362363 targetId: repoInfo.id,
363364 }).catch(() => {});
364365
365 const exitCode = await pipeGitToChannel(service, absRepoPath, channel);
366 let hookEnv: Record<string, string> | undefined;
367 let hookCleanup: (() => Promise<void>) | undefined;
368 try {
369 const hook = await installPackInspectionHookForRepo(repoInfo.id);
370 if (hook) {
371 hookEnv = hook.env;
372 hookCleanup = hook.cleanup;
373 }
374 } catch {
375 // fail-open
376 }
377
378 let exitCode: number;
379 try {
380 exitCode = await pipeGitToChannel(service, absRepoPath, channel, hookEnv);
381 } finally {
382 hookCleanup?.().catch(() => {});
383 }
366384
367385 if (exitCode === 0) {
368386 const refsAfter = await getShowRef(absRepoPath);
Modifiedsrc/lib/stale-sweep.ts+12−6View fileUnifiedSplit
4242 repositories,
4343} from "../db/schema";
4444import { audit, notify } from "./notify";
45import { getBotUserIdOrFallback } from "./bot-user";
4546
4647// ---------------------------------------------------------------------------
4748// Marker constants — stable HTML comments. Versioned so a v2 contract can
300301
301302/** Default poke side-effect: comment + notify + audit. */
302303async function defaultPokePr(cand: StalePrCandidate): Promise<void> {
303 // 1. Post the marker comment as the PR author (avoids needing a system user).
304 // 1. Post the marker comment as the bot user (falls back to PR author if
305 // the bot row has not been seeded yet).
306 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
304307 try {
305308 await db.insert(prComments).values({
306309 pullRequestId: cand.prId,
307 authorId: cand.authorUserId,
310 authorId: commentAuthorId,
308311 body: PR_POKE_BODY,
309312 isAiReview: false,
310313 });
340343
341344/** Default close side-effect: comment + state→closed + audit. */
342345async function defaultClosePr(cand: StalePrCandidate): Promise<void> {
343 // 1. Post the final close comment.
346 // 1. Post the final close comment as the bot user.
347 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
344348 try {
345349 await db.insert(prComments).values({
346350 pullRequestId: cand.prId,
347 authorId: cand.authorUserId,
351 authorId: commentAuthorId,
348352 body: PR_CLOSE_BODY,
349353 isAiReview: false,
350354 });
448452
449453/** Default issue poke. */
450454async function defaultPokeIssue(cand: StaleIssueCandidate): Promise<void> {
455 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
451456 try {
452457 await db.insert(issueComments).values({
453458 issueId: cand.issueId,
454 authorId: cand.authorUserId,
459 authorId: commentAuthorId,
455460 body: ISSUE_POKE_BODY,
456461 });
457462 } catch (err) {
483488
484489/** Default issue close. */
485490async function defaultCloseIssue(cand: StaleIssueCandidate): Promise<void> {
491 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
486492 try {
487493 await db.insert(issueComments).values({
488494 issueId: cand.issueId,
489 authorId: cand.authorUserId,
495 authorId: commentAuthorId,
490496 body: ISSUE_CLOSE_BODY,
491497 });
492498 } catch (err) {
Modifiedsrc/lib/workflow-runner.ts+42−0View fileUnifiedSplit
3131 loadSecretsContext,
3232 substituteSecrets,
3333} from "./workflow-secrets";
34import { saveCacheEntry } from "./actions/cache-action";
3435
3536// ---------------------------------------------------------------------------
3637// Tunables
479480 job: ParsedJob;
480481 jobOrder: number;
481482 checkoutDir: string;
483 repoId?: string;
482484 /** Per-run plaintext secrets. Default `{}` preserves the v1 contract
483485 * for callers that haven't been updated to plumb secrets through. */
484486 secrets?: Record<string, string>;
555557 const combinedLogs = truncate(logParts.join("\n"), JOB_LOG_CAP_BYTES);
556558 const status = anyFailed ? "failure" : "success";
557559
560 // Post-job cache save: mirrors GitHub Actions' post-job hook behaviour.
561 // Only runs when every step succeeded — partial saves are useless and could
562 // poison future restores with an incomplete workspace snapshot.
563 if (!anyFailed && opts.repoId) {
564 for (const step of steps) {
565 const uses = typeof step.uses === "string" ? step.uses : "";
566 const isCacheStep =
567 uses === "actions/cache@v3" ||
568 uses === "actions/cache@v2" ||
569 uses === "actions/cache@v1" ||
570 uses === "actions/cache" ||
571 uses.startsWith("gluecron/cache");
572 if (!isCacheStep) continue;
573
574 const w = step.with && typeof step.with === "object" && !Array.isArray(step.with)
575 ? (step.with as Record<string, unknown>)
576 : {};
577 const cacheKey = typeof w.key === "string" ? w.key : "";
578 const rawPath = w.path ?? w.paths;
579 const cachePaths = Array.isArray(rawPath)
580 ? rawPath.filter((p): p is string => typeof p === "string")
581 : typeof rawPath === "string"
582 ? [rawPath]
583 : [];
584
585 if (!cacheKey || cachePaths.length === 0) continue;
586
587 try {
588 await saveCacheEntry(opts.repoId, cacheKey, cachePaths, checkoutDir);
589 } catch (err) {
590 // Fail-open: log but never let a save failure surface as a job failure.
591 console.warn(
592 `[workflow-runner] cache save failed for key=${cacheKey}:`,
593 err instanceof Error ? err.message : err
594 );
595 }
596 }
597 }
598
558599 if (jobId) {
559600 try {
560601 await db
747788 job,
748789 jobOrder: i,
749790 checkoutDir,
791 repoId: run.repositoryId,
750792 secrets: runSecrets,
751793 });
752794 if (!result.success) {
Addedsrc/routes/actions-importer.tsx+1464−0View fileUnifiedSplit
Large file (1,464 lines). Load full file
Addedsrc/routes/admin-deletions.tsx+300−0View fileUnifiedSplit
1/**
2 * GET /admin/deletions — list users pending purge + completed purges
3 * POST /admin/deletions/:id/purge-now — force-run deletion cascade immediately
4 *
5 * Gated by isSiteAdmin (same pattern as all other admin sub-pages).
6 */
7
8import { Hono } from "hono";
9import { eq, isNotNull, lt } from "drizzle-orm";
10import { db } from "../db";
11import { users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { softAuth } from "../middleware/auth";
14import type { AuthEnv } from "../middleware/auth";
15import { isSiteAdmin } from "../lib/admin";
16import { purgeScheduledAccounts } from "../lib/account-deletion";
17import { audit } from "../lib/notify";
18
19const adminDeletions = new Hono<AuthEnv>();
20adminDeletions.use("*", softAuth);
21
22async function gate(c: any): Promise<{ user: any } | Response> {
23 const user = c.get("user");
24 if (!user) return c.redirect("/login?next=/admin/deletions");
25 if (!(await isSiteAdmin(user.id))) {
26 return c.html(
27 <Layout title="Forbidden" user={user}>
28 <div style="max-width:540px;margin:80px auto;padding:32px;text-align:center;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px;">
29 <h2 style="font-family:var(--font-display);font-size:22px;margin:0 0 8px;color:var(--text-strong);">403 — Not a site admin</h2>
30 <p style="color:var(--text-muted);margin:0;font-size:14px;">You don't have permission to view this page.</p>
31 </div>
32 </Layout>,
33 403
34 );
35 }
36 return { user };
37}
38
39const styles = `
40 .adm-del-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
41
42 .adm-del-hero {
43 position: relative;
44 margin-bottom: var(--space-5);
45 padding: var(--space-5) var(--space-6);
46 background: var(--bg-elevated);
47 border: 1px solid var(--border);
48 border-radius: 16px;
49 overflow: hidden;
50 }
51 .adm-del-hero::before {
52 content: '';
53 position: absolute;
54 top: 0; left: 0; right: 0;
55 height: 2px;
56 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%);
57 opacity: 0.7;
58 pointer-events: none;
59 }
60 .adm-del-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
61 .adm-del-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #fb923c; margin-bottom: 6px; }
62 .adm-del-title { font-family: var(--font-display); font-size: clamp(24px,3.5vw,36px); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 4px; color: var(--text-strong); }
63 .adm-del-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
64 .adm-del-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; font-weight: 500; transition: border-color 120ms,color 120ms,background 120ms; }
65 .adm-del-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
66
67 .adm-del-section { margin-bottom: var(--space-6); }
68 .adm-del-section-title { font-family: var(--font-display); font-size: 16px; font-weight: 700; letter-spacing: -0.012em; color: var(--text-strong); margin: 0 0 var(--space-3); display: flex; align-items: center; gap: 10px; }
69 .adm-del-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 700; }
70 .adm-del-badge-warn { background: rgba(251,146,60,0.15); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.35); }
71 .adm-del-badge-ok { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
72
73 .adm-del-table { width: 100%; border-collapse: collapse; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
74 .adm-del-table thead th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); padding: 10px 14px; background: rgba(255,255,255,0.015); border-bottom: 1px solid var(--border); }
75 .adm-del-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; color: var(--text); vertical-align: middle; }
76 .adm-del-table tbody tr:last-child td { border-bottom: none; }
77 .adm-del-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
78 .adm-del-table code { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-strong); }
79
80 .adm-del-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 7px; font-size: 12.5px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; transition: background 120ms, border-color 120ms, color 120ms; border: 1px solid rgba(248,113,113,0.45); background: rgba(248,113,113,0.08); color: #fca5a5; }
81 .adm-del-btn:hover { background: rgba(248,113,113,0.18); border-color: rgba(248,113,113,0.70); color: #fecaca; }
82
83 .adm-del-empty { padding: var(--space-6); text-align: center; color: var(--text-muted); font-size: 13.5px; background: var(--bg-elevated); border: 1px dashed var(--border); border-radius: 14px; }
84
85 .adm-del-banner { margin-bottom: var(--space-4); padding: 10px 14px; border-radius: 10px; font-size: 13.5px; border: 1px solid var(--border); background: rgba(255,255,255,0.025); color: var(--text); }
86 .adm-del-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
87 .adm-del-banner.is-err { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
88
89 @media (max-width: 720px) {
90 .adm-del-wrap { padding: var(--space-4) var(--space-3); }
91 .adm-del-hero { padding: var(--space-4); }
92 .adm-del-table { display: block; overflow-x: auto; }
93 }
94`;
95
96adminDeletions.get("/admin/deletions", async (c) => {
97 const g = await gate(c);
98 if (g instanceof Response) return g;
99 const { user } = g;
100
101 const now = new Date();
102
103 // Users pending purge: deletion_scheduled_for is set and in the past (overdue)
104 const pendingRows = await db
105 .select({
106 id: users.id,
107 username: users.username,
108 email: users.email,
109 deletedAt: users.deletedAt,
110 deletionScheduledFor: users.deletionScheduledFor,
111 })
112 .from(users)
113 .where(lt(users.deletionScheduledFor, now))
114 .orderBy(users.deletionScheduledFor)
115 .limit(200);
116
117 // Users in grace period (deletion_scheduled_for is set but not yet overdue).
118 // We fetch all with a non-null scheduled_for and filter in JS.
119 const scheduledRows = await db
120 .select({
121 id: users.id,
122 username: users.username,
123 email: users.email,
124 deletedAt: users.deletedAt,
125 deletionScheduledFor: users.deletionScheduledFor,
126 })
127 .from(users)
128 .where(isNotNull(users.deletionScheduledFor))
129 .orderBy(users.deletionScheduledFor)
130 .limit(400);
131
132 // Filter: grace period = scheduled but not yet overdue
133 const graceRows = scheduledRows.filter(
134 (r) => r.deletionScheduledFor && r.deletionScheduledFor > now
135 );
136
137 const msg = c.req.query("msg") || "";
138 const errMsg = c.req.query("err") || "";
139
140 const fmt = (d: Date | null | undefined) =>
141 d ? new Date(d).toLocaleString() : "—";
142
143 return c.html(
144 <Layout title="Admin — Account Deletions" user={user}>
145 <style dangerouslySetInnerHTML={{ __html: styles }} />
146 <div class="adm-del-wrap">
147 <div class="adm-del-hero">
148 <div class="adm-del-hero-inner">
149 <div>
150 <div class="adm-del-eyebrow">Admin / GDPR</div>
151 <h1 class="adm-del-title">Account Deletions</h1>
152 <p class="adm-del-sub">
153 Users scheduled for deletion, their grace-period status, and completed purges.
154 </p>
155 </div>
156 <a href="/admin" class="adm-del-back">← Admin</a>
157 </div>
158 </div>
159
160 {msg && <div class="adm-del-banner is-ok">{msg}</div>}
161 {errMsg && <div class="adm-del-banner is-err">{errMsg}</div>}
162
163 {/* Overdue — pending execution */}
164 <div class="adm-del-section">
165 <div class="adm-del-section-title">
166 Overdue (pending execution)
167 <span class="adm-del-badge adm-del-badge-warn">{pendingRows.length}</span>
168 </div>
169 {pendingRows.length === 0 ? (
170 <div class="adm-del-empty">No overdue deletions. Autopilot is keeping up.</div>
171 ) : (
172 <table class="adm-del-table">
173 <thead>
174 <tr>
175 <th>Username</th>
176 <th>Email</th>
177 <th>Deleted at</th>
178 <th>Scheduled for</th>
179 <th>Action</th>
180 </tr>
181 </thead>
182 <tbody>
183 {pendingRows.map((r) => (
184 <tr>
185 <td><code>{r.username}</code></td>
186 <td>{r.email}</td>
187 <td>{fmt(r.deletedAt)}</td>
188 <td style="color:#fb923c">{fmt(r.deletionScheduledFor)}</td>
189 <td>
190 <form method="post" action={`/admin/deletions/${r.id}/purge-now`}>
191 <button type="submit" class="adm-del-btn"
192 onclick="return confirm('Hard-delete this user immediately? This cannot be undone.')">
193 Purge now
194 </button>
195 </form>
196 </td>
197 </tr>
198 ))}
199 </tbody>
200 </table>
201 )}
202 </div>
203
204 {/* Grace period */}
205 <div class="adm-del-section">
206 <div class="adm-del-section-title">
207 In grace period
208 <span class="adm-del-badge adm-del-badge-ok">{graceRows.length}</span>
209 </div>
210 {graceRows.length === 0 ? (
211 <div class="adm-del-empty">No accounts in the grace period.</div>
212 ) : (
213 <table class="adm-del-table">
214 <thead>
215 <tr>
216 <th>Username</th>
217 <th>Email</th>
218 <th>Deletion initiated</th>
219 <th>Purge scheduled for</th>
220 <th>Action</th>
221 </tr>
222 </thead>
223 <tbody>
224 {graceRows.map((r) => (
225 <tr>
226 <td><code>{r.username}</code></td>
227 <td>{r.email}</td>
228 <td>{fmt(r.deletedAt)}</td>
229 <td>{fmt(r.deletionScheduledFor)}</td>
230 <td>
231 <form method="post" action={`/admin/deletions/${r.id}/purge-now`}>
232 <button type="submit" class="adm-del-btn"
233 onclick="return confirm('Hard-delete this user now, skipping the grace period? This cannot be undone.')">
234 Purge now
235 </button>
236 </form>
237 </td>
238 </tr>
239 ))}
240 </tbody>
241 </table>
242 )}
243 </div>
244 </div>
245 </Layout>
246 );
247});
248
249// Force-run deletion cascade immediately for a specific user.
250adminDeletions.post("/admin/deletions/:id/purge-now", async (c) => {
251 const g = await gate(c);
252 if (g instanceof Response) return g;
253 const { user: adminUser } = g;
254
255 const targetId = c.req.param("id");
256
257 // Fetch the target user to confirm they're scheduled for deletion.
258 const targetRows = await db
259 .select({ id: users.id, username: users.username, deletionScheduledFor: users.deletionScheduledFor, deletedAt: users.deletedAt })
260 .from(users)
261 .where(eq(users.id, targetId))
262 .limit(1);
263
264 const target = targetRows[0] ?? null;
265 if (target && !target.deletedAt) {
266 return c.redirect("/admin/deletions?err=" + encodeURIComponent("User is not scheduled for deletion."));
267 }
268 if (!target) {
269 return c.redirect("/admin/deletions?err=" + encodeURIComponent("User not found or not scheduled for deletion."));
270 }
271
272 // Force-run purge by setting deletion_scheduled_for to epoch so lt(now) picks it up.
273 try {
274 await db
275 .update(users)
276 .set({ deletionScheduledFor: new Date(0) })
277 .where(eq(users.id, targetId));
278 } catch (err) {
279 console.error("[admin-deletions] force-schedule update failed:", err);
280 }
281
282 // Use the import from account-deletion which already handles all cascade steps.
283 const result = await purgeScheduledAccounts({ cap: 1 });
284
285 await audit({
286 userId: adminUser.id,
287 action: "account.admin_purge",
288 targetType: "user",
289 targetId,
290 metadata: { username: target.username, triggeredBy: adminUser.id },
291 });
292
293 if (result.purged > 0) {
294 return c.redirect("/admin/deletions?msg=" + encodeURIComponent(`User "${target.username}" has been permanently purged.`));
295 } else {
296 return c.redirect("/admin/deletions?err=" + encodeURIComponent(`Purge encountered errors. Check server logs.`));
297 }
298});
299
300export default adminDeletions;
Addedsrc/routes/admin-security.tsx+695−0View fileUnifiedSplit
1/**
2 * Admin security dashboard — SOC 2 evidence surface.
3 *
4 * GET /admin/security — recent failed logins, locked accounts, admin actions,
5 * MFA status, active session count, account deletions
6 * GET /admin/soc2 — static SOC 2 readiness checklist
7 *
8 * Both routes are gated by `isSiteAdmin`.
9 */
10
11import { Hono } from "hono";
12import { and, count, desc, eq, gte, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 auditLog,
16 loginAttempts,
17 sessions,
18 userTotp,
19 users,
20} from "../db/schema";
21import { isSiteAdmin } from "../lib/admin";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { Layout } from "../views/layout";
25
26const adminSecurity = new Hono<AuthEnv>();
27adminSecurity.use("*", softAuth);
28
29// ── Auth guard ───────────────────────────────────────────────────────────────
30adminSecurity.use("/admin/security*", async (c, next) => {
31 const user = c.get("user");
32 if (!user || !(await isSiteAdmin(user.id))) {
33 return c.redirect("/login?redirect=/admin/security");
34 }
35 return next();
36});
37adminSecurity.use("/admin/soc2*", async (c, next) => {
38 const user = c.get("user");
39 if (!user || !(await isSiteAdmin(user.id))) {
40 return c.redirect("/login?redirect=/admin/soc2");
41 }
42 return next();
43});
44
45// ── Scoped CSS ───────────────────────────────────────────────────────────────
46const securityStyles = `
47 .sec-page { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
48
49 .sec-hero {
50 position: relative; margin-bottom: var(--space-6);
51 padding: var(--space-5) var(--space-6);
52 background: var(--bg-elevated); border: 1px solid var(--border);
53 border-radius: 16px; overflow: hidden;
54 }
55 .sec-hero::before {
56 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
57 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%);
58 opacity: 0.7; pointer-events: none;
59 }
60 .sec-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; }
61 .sec-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
62 .sec-hero-nav { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
63 .sec-hero-nav a { font-size: 13px; }
64
65 .sec-stat-grid {
66 display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
67 gap: 12px; margin-bottom: var(--space-6);
68 }
69 .sec-stat {
70 background: var(--bg-elevated); border: 1px solid var(--border);
71 border-radius: 12px; padding: var(--space-4);
72 }
73 .sec-stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
74 .sec-stat-value { font-size: 2rem; font-weight: 800; }
75 .sec-stat-value.danger { color: #f87171; }
76 .sec-stat-value.warning { color: #fb923c; }
77 .sec-stat-value.ok { color: #34d399; }
78
79 .sec-section { margin-bottom: var(--space-6); }
80 .sec-section h2 { font-size: 16px; font-weight: 700; margin: 0 0 12px; }
81
82 .sec-table { width: 100%; border-collapse: collapse; font-size: 13px; }
83 .sec-table th { text-align: left; padding: 8px 12px; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); }
84 .sec-table td { padding: 8px 12px; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); vertical-align: middle; }
85 .sec-table tr:last-child td { border-bottom: none; }
86 .sec-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
87
88 .sec-badge {
89 display: inline-block; padding: 2px 8px; border-radius: 100px;
90 font-size: 11px; font-weight: 600;
91 }
92 .sec-badge.red { background: #f871711a; color: #f87171; }
93 .sec-badge.orange { background: #fb923c1a; color: #fb923c; }
94 .sec-badge.green { background: #34d3991a; color: #34d399; }
95
96 /* SOC 2 checklist */
97 .soc2-page { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
98 .soc2-hero {
99 position: relative; margin-bottom: var(--space-6);
100 padding: var(--space-5) var(--space-6);
101 background: var(--bg-elevated); border: 1px solid var(--border);
102 border-radius: 16px; overflow: hidden;
103 }
104 .soc2-hero::before {
105 content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
106 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
107 opacity: 0.7; pointer-events: none;
108 }
109 .soc2-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; }
110 .soc2-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
111 .soc2-category { margin-bottom: var(--space-5); }
112 .soc2-category h2 { font-size: 15px; font-weight: 700; margin: 0 0 10px; }
113 .soc2-row {
114 display: flex; align-items: flex-start; gap: 12px;
115 padding: 10px 0; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06));
116 font-size: 13.5px;
117 }
118 .soc2-row:last-child { border-bottom: none; }
119 .soc2-icon { font-size: 16px; flex-shrink: 0; width: 24px; text-align: center; margin-top: 1px; }
120 .soc2-text { flex: 1; }
121 .soc2-label { font-weight: 600; }
122 .soc2-desc { color: var(--text-muted); margin-top: 2px; font-size: 12.5px; }
123 .soc2-status-ok { color: #34d399; }
124 .soc2-status-warn { color: #fb923c; }
125 .soc2-section-card {
126 background: var(--bg-elevated); border: 1px solid var(--border);
127 border-radius: 12px; padding: var(--space-4) var(--space-5);
128 margin-bottom: var(--space-4);
129 }
130`;
131
132// ── GET /admin/security ──────────────────────────────────────────────────────
133adminSecurity.get("/admin/security", async (c) => {
134 const user = c.get("user")!;
135 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
136 const since1h = new Date(Date.now() - 60 * 60 * 1000);
137
138 // Recent failed logins in last 24h grouped by email + IP.
139 const failedLogins = await db
140 .select({
141 email: loginAttempts.email,
142 ip: loginAttempts.ip,
143 attempts: sql<number>`count(*)::int`,
144 })
145 .from(loginAttempts)
146 .where(
147 and(
148 eq(loginAttempts.success, false),
149 gte(loginAttempts.createdAt, since24h)
150 )
151 )
152 .groupBy(loginAttempts.email, loginAttempts.ip)
153 .orderBy(desc(sql`count(*)`))
154 .limit(20);
155
156 // Locked accounts: email addresses with >= 10 failures in last 1h.
157 const lockedAccounts = await db
158 .select({
159 email: loginAttempts.email,
160 attempts: sql<number>`count(*)::int`,
161 })
162 .from(loginAttempts)
163 .where(
164 and(
165 eq(loginAttempts.success, false),
166 gte(loginAttempts.createdAt, since1h)
167 )
168 )
169 .groupBy(loginAttempts.email)
170 .having(sql`count(*) >= 10`);
171
172 // Recent admin audit actions in last 24h.
173 const adminActions = await db
174 .select({
175 id: auditLog.id,
176 action: auditLog.action,
177 ip: auditLog.ip,
178 createdAt: auditLog.createdAt,
179 username: users.username,
180 })
181 .from(auditLog)
182 .leftJoin(users, eq(auditLog.userId, users.id))
183 .where(gte(auditLog.createdAt, since24h))
184 .orderBy(desc(auditLog.createdAt))
185 .limit(30);
186
187 // Active session count.
188 const [sessionRow] = await db
189 .select({ cnt: sql<number>`count(*)::int` })
190 .from(sessions)
191 .where(gte(sessions.expiresAt, new Date()));
192
193 // Users with MFA configured (TOTP enabled).
194 const [mfaRow] = await db
195 .select({ cnt: sql<number>`count(*)::int` })
196 .from(userTotp)
197 .where(sql`enabled_at IS NOT NULL`);
198
199 // Total users.
200 const [usersRow] = await db
201 .select({ cnt: sql<number>`count(*)::int` })
202 .from(users)
203 .where(sql`deleted_at IS NULL`);
204
205 // Recent account deletions in audit log.
206 const accountDeletions = await db
207 .select({
208 id: auditLog.id,
209 action: auditLog.action,
210 ip: auditLog.ip,
211 createdAt: auditLog.createdAt,
212 username: users.username,
213 })
214 .from(auditLog)
215 .leftJoin(users, eq(auditLog.userId, users.id))
216 .where(
217 and(
218 eq(auditLog.action, "account.delete"),
219 gte(auditLog.createdAt, since24h)
220 )
221 )
222 .orderBy(desc(auditLog.createdAt))
223 .limit(10);
224
225 const totalUsers = usersRow?.cnt ?? 0;
226 const mfaUsers = mfaRow?.cnt ?? 0;
227 const noMfaUsers = Math.max(0, totalUsers - mfaUsers);
228 const activeSessions = sessionRow?.cnt ?? 0;
229 const failedCount = failedLogins.reduce((s, r) => s + r.attempts, 0);
230 const lockedCount = lockedAccounts.length;
231
232 return c.html(
233 <Layout title="Security dashboard" user={user}>
234 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
235 <div class="sec-page">
236 <div class="sec-hero">
237 <h1>Security dashboard</h1>
238 <p>
239 Real-time view of authentication events, account lockouts, and admin
240 actions. Data is used as SOC 2 evidence for CC6.1 and CC7.2.
241 </p>
242 <div class="sec-hero-nav">
243 <a href="/admin/security" class="btn btn-sm active">Security</a>
244 <a href="/admin/soc2" class="btn btn-sm">SOC 2 Checklist</a>
245 <a href="/admin" class="btn btn-sm">Admin home</a>
246 </div>
247 </div>
248
249 {/* ── Stat cards ── */}
250 <div class="sec-stat-grid">
251 <div class="sec-stat">
252 <div class="sec-stat-label">Failed logins (24h)</div>
253 <div class={`sec-stat-value ${failedCount > 50 ? "danger" : failedCount > 10 ? "warning" : "ok"}`}>
254 {failedCount}
255 </div>
256 </div>
257 <div class="sec-stat">
258 <div class="sec-stat-label">Locked accounts (1h window)</div>
259 <div class={`sec-stat-value ${lockedCount > 0 ? "danger" : "ok"}`}>
260 {lockedCount}
261 </div>
262 </div>
263 <div class="sec-stat">
264 <div class="sec-stat-label">Active sessions</div>
265 <div class="sec-stat-value">{activeSessions}</div>
266 </div>
267 <div class="sec-stat">
268 <div class="sec-stat-label">Users without MFA</div>
269 <div class={`sec-stat-value ${noMfaUsers > 0 ? "warning" : "ok"}`}>
270 {noMfaUsers}
271 </div>
272 </div>
273 <div class="sec-stat">
274 <div class="sec-stat-label">Total users</div>
275 <div class="sec-stat-value">{totalUsers}</div>
276 </div>
277 <div class="sec-stat">
278 <div class="sec-stat-label">MFA-enabled users</div>
279 <div class="sec-stat-value ok">{mfaUsers}</div>
280 </div>
281 </div>
282
283 {/* ── Locked accounts ── */}
284 {lockedAccounts.length > 0 && (
285 <div class="sec-section">
286 <h2>Locked accounts (10+ failures in last hour)</h2>
287 <div class="sec-card">
288 <table class="sec-table">
289 <thead>
290 <tr>
291 <th>Email</th>
292 <th>Failed attempts (1h)</th>
293 <th>Status</th>
294 </tr>
295 </thead>
296 <tbody>
297 {lockedAccounts.map((row) => (
298 <tr key={row.email}>
299 <td>{row.email}</td>
300 <td>{row.attempts}</td>
301 <td>
302 <span class="sec-badge red">Locked</span>
303 </td>
304 </tr>
305 ))}
306 </tbody>
307 </table>
308 </div>
309 </div>
310 )}
311
312 {/* ── Recent failed logins ── */}
313 <div class="sec-section">
314 <h2>Recent failed login attempts (last 24h)</h2>
315 <div class="sec-card">
316 {failedLogins.length === 0 ? (
317 <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;">
318 No failed logins in the last 24 hours.
319 </div>
320 ) : (
321 <table class="sec-table">
322 <thead>
323 <tr>
324 <th>Email</th>
325 <th>IP address</th>
326 <th>Attempts</th>
327 <th>Risk</th>
328 </tr>
329 </thead>
330 <tbody>
331 {failedLogins.map((row) => (
332 <tr key={`${row.email}-${row.ip}`}>
333 <td>{row.email}</td>
334 <td>
335 <code style="font-size: 12px;">{row.ip}</code>
336 </td>
337 <td>{row.attempts}</td>
338 <td>
339 <span
340 class={`sec-badge ${row.attempts >= 10 ? "red" : row.attempts >= 5 ? "orange" : "green"}`}
341 >
342 {row.attempts >= 10
343 ? "High"
344 : row.attempts >= 5
345 ? "Medium"
346 : "Low"}
347 </span>
348 </td>
349 </tr>
350 ))}
351 </tbody>
352 </table>
353 )}
354 </div>
355 </div>
356
357 {/* ── Recent admin actions ── */}
358 <div class="sec-section">
359 <h2>Recent audit log entries (last 24h)</h2>
360 <div class="sec-card">
361 {adminActions.length === 0 ? (
362 <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;">
363 No audit log entries in the last 24 hours.
364 </div>
365 ) : (
366 <table class="sec-table">
367 <thead>
368 <tr>
369 <th>Time</th>
370 <th>User</th>
371 <th>Action</th>
372 <th>IP</th>
373 </tr>
374 </thead>
375 <tbody>
376 {adminActions.map((row) => (
377 <tr key={row.id}>
378 <td style="white-space: nowrap; color: var(--text-muted);">
379 {new Date(row.createdAt).toLocaleString("en-US", {
380 month: "short",
381 day: "numeric",
382 hour: "2-digit",
383 minute: "2-digit",
384 })}
385 </td>
386 <td>{row.username ?? "(system)"}</td>
387 <td>
388 <code style="font-size: 12px;">{row.action}</code>
389 </td>
390 <td style="color: var(--text-muted);">
391 <code style="font-size: 12px;">{row.ip ?? "-"}</code>
392 </td>
393 </tr>
394 ))}
395 </tbody>
396 </table>
397 )}
398 </div>
399 </div>
400
401 {/* ── Account deletions ── */}
402 {accountDeletions.length > 0 && (
403 <div class="sec-section">
404 <h2>Recent account deletions (last 24h)</h2>
405 <div class="sec-card">
406 <table class="sec-table">
407 <thead>
408 <tr>
409 <th>Time</th>
410 <th>User</th>
411 <th>IP</th>
412 </tr>
413 </thead>
414 <tbody>
415 {accountDeletions.map((row) => (
416 <tr key={row.id}>
417 <td style="white-space: nowrap; color: var(--text-muted);">
418 {new Date(row.createdAt).toLocaleString("en-US", {
419 month: "short",
420 day: "numeric",
421 hour: "2-digit",
422 minute: "2-digit",
423 })}
424 </td>
425 <td>{row.username ?? "-"}</td>
426 <td style="color: var(--text-muted);">
427 <code style="font-size: 12px;">{row.ip ?? "-"}</code>
428 </td>
429 </tr>
430 ))}
431 </tbody>
432 </table>
433 </div>
434 </div>
435 )}
436
437 <p style="font-size: 12px; color: var(--text-muted); margin-top: var(--space-4);">
438 All times are server-local. Full audit trail available at{" "}
439 <a href="/audit">Audit log</a>. For SOC 2 mapping see{" "}
440 <a href="/admin/soc2">SOC 2 Checklist</a>.
441 </p>
442 </div>
443 </Layout>
444 );
445});
446
447// ── GET /admin/soc2 ───────────────────────────────────────────────────────────
448adminSecurity.get("/admin/soc2", async (c) => {
449 const user = c.get("user")!;
450
451 const CheckItem = ({
452 ok,
453 label,
454 desc,
455 }: {
456 ok: boolean;
457 label: string;
458 desc?: string;
459 }) => (
460 <div class="soc2-row">
461 <div class={`soc2-icon ${ok ? "soc2-status-ok" : "soc2-status-warn"}`}>
462 {ok ? "✓" : "⚠"}
463 </div>
464 <div class="soc2-text">
465 <div class="soc2-label">{label}</div>
466 {desc && <div class="soc2-desc">{desc}</div>}
467 </div>
468 </div>
469 );
470
471 return c.html(
472 <Layout title="SOC 2 Readiness" user={user}>
473 <style dangerouslySetInnerHTML={{ __html: securityStyles }} />
474 <div class="soc2-page">
475 <div class="soc2-hero">
476 <h1>SOC 2 Readiness Checklist</h1>
477 <p>
478 Maps the five SOC 2 Trust Service Criteria to Gluecron's implemented
479 controls. Green items have active technical controls; amber items need
480 policy, tooling, or external assessment.
481 <br />
482 <span style="color: var(--text-muted); font-size: 12px;">
483 Last reviewed: {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} ·{" "}
484 <a href="/admin/security">View security dashboard</a>
485 </span>
486 </p>
487 </div>
488
489 {/* ── CC: Security ── */}
490 <div class="soc2-category">
491 <div class="soc2-section-card">
492 <h2 style="margin-top: 0;">CC — Security (Common Criteria)</h2>
493
494 <CheckItem
495 ok={true}
496 label="Audit log (CC7.2)"
497 desc="Every sensitive action is written to the audit_log table with user, IP, timestamp, and action. Exportable via /admin/audit or /api/v2/admin/audit."
498 />
499 <CheckItem
500 ok={true}
501 label="Access controls — role-based (CC6.1)"
502 desc="Admin routes gated by isSiteAdmin. Repository access gated by visibility + collaborator membership. Branch protection rules enforced on push and merge."
503 />
504 <CheckItem
505 ok={true}
506 label="Account lockout after repeated failures (CC6.1)"
507 desc="10 failed login attempts within 1 hour locks the account for 15 minutes. Attempts logged to login_attempts table with email + IP. Lockout events written to audit_log as auth.login.locked."
508 />
509 <CheckItem
510 ok={true}
511 label="Rate limiting (CC6.6)"
512 desc="Login endpoint: 20 req/min/IP (middleware rate-limit). API: 1000 req/min/IP. Git push/pull: 100 req/min/IP. Brute-force protection on forgot-password and magic-link endpoints."
513 />
514 <CheckItem
515 ok={true}
516 label="Encryption in transit — HTTPS (CC6.7)"
517 desc="TLS is enforced by the Fly.io edge and documented in fly.toml. All internal Neon PostgreSQL connections use SSL."
518 />
519 <CheckItem
520 ok={true}
521 label="Encryption at rest — SSH + API keys (CC6.7)"
522 desc="SSH public keys stored as plain text (read-only); private keys never leave the user. API tokens stored as SHA-256 hashes; plaintext never persisted. Passwords stored as bcrypt hashes (cost 12)."
523 />
524 <CheckItem
525 ok={true}
526 label="Session management (CC6.1)"
527 desc="30-day session expiry. Per-user session list at /settings/sessions with individual revoke and revoke-all. IP address and user-agent logged per session."
528 />
529 <CheckItem
530 ok={true}
531 label="2FA / TOTP support (CC6.1)"
532 desc="TOTP (RFC 6238) supported via /settings/2fa. Recovery codes (SHA-256 hashed) for device loss. WebAuthn/passkey support for phishing-resistant auth."
533 />
534 <CheckItem
535 ok={false}
536 label="MFA enforcement policy (CC6.1)"
537 desc="MFA is available but not yet mandatory for admin accounts. Recommend enforcing MFA for all users with isAdmin=true. Tracked: admin.security.mfa_enforcement_policy."
538 />
539 <CheckItem
540 ok={false}
541 label="Penetration test (CC7.1)"
542 desc="No external penetration test on record. Schedule an annual third-party assessment. OWASP Top 10 self-review partially complete (secret-scan gate covers A3, A7)."
543 />
544 <CheckItem
545 ok={false}
546 label="Vulnerability disclosure policy (CC7.1)"
547 desc="No public security.txt or responsible-disclosure policy page. Add /security.txt and a SECURITY.md to the repo."
548 />
549 </div>
550 </div>
551
552 {/* ── A: Availability ── */}
553 <div class="soc2-category">
554 <div class="soc2-section-card">
555 <h2 style="margin-top: 0;">A — Availability</h2>
556
557 <CheckItem
558 ok={true}
559 label="Health probes (A1.2)"
560 desc="GET /health returns 200 with uptime, memory, and DB reachability. Used by Fly.io TCP healthcheck. Autopilot tick monitors DB connectivity."
561 />
562 <CheckItem
563 ok={true}
564 label="Public status page (A1.2)"
565 desc="/status surfaces platform health. /admin/status shows synthetic-monitor results per component."
566 />
567 <CheckItem
568 ok={true}
569 label="Database backups (A1.3)"
570 desc="Neon PostgreSQL provides continuous PITR (point-in-time recovery) with 7-day history by default on Pro plans. Verify the backup retention period in the Neon console."
571 />
572 <CheckItem
573 ok={false}
574 label="SLA definition (A1.1)"
575 desc="No documented uptime SLA. Define target availability (e.g. 99.9%) and add it to /legal/terms or a dedicated /sla page."
576 />
577 <CheckItem
578 ok={false}
579 label="Incident response runbook (A1.2)"
580 desc="No documented incident response process. Create a runbook covering detection → escalation → communication → post-mortem."
581 />
582 </div>
583 </div>
584
585 {/* ── C: Confidentiality ── */}
586 <div class="soc2-category">
587 <div class="soc2-section-card">
588 <h2 style="margin-top: 0;">C — Confidentiality</h2>
589
590 <CheckItem
591 ok={true}
592 label="Repository visibility controls (C1.1)"
593 desc="Repos are private by default. Visibility enforced at the route layer (softAuth + git protocol). Private repos not reachable without a valid session or token."
594 />
595 <CheckItem
596 ok={true}
597 label="API token scoping (C1.2)"
598 desc="Tokens carry comma-separated scope list. /api/* handlers check scope before processing write requests."
599 />
600 <CheckItem
601 ok={true}
602 label="Personal access token management (C1.2)"
603 desc="Tokens shown once on creation; stored as SHA-256 hash. Users can revoke at /settings/tokens. Expiry supported."
604 />
605 <CheckItem
606 ok={false}
607 label="Data classification policy (C1.1)"
608 desc="No formal data classification. Define at least: Public (explore), Internal (private repos), Confidential (credentials, PII). Required for auditor review."
609 />
610 <CheckItem
611 ok={false}
612 label="Third-party sub-processor list (C1.2)"
613 desc="Neon, Resend, Fly.io, Anthropic API are used. Document these as sub-processors with data-handling descriptions on the Privacy page."
614 />
615 </div>
616 </div>
617
618 {/* ── PI: Processing Integrity ── */}
619 <div class="soc2-category">
620 <div class="soc2-section-card">
621 <h2 style="margin-top: 0;">PI — Processing Integrity</h2>
622
623 <CheckItem
624 ok={true}
625 label="Audit trail for all mutations (PI1.2)"
626 desc="Every sensitive write (merge, delete, force-push, token create/revoke, deploy) emitted to audit_log. Immutable append-only structure."
627 />
628 <CheckItem
629 ok={true}
630 label="Git immutability (PI1.3)"
631 desc="Branch protection prevents force-push on protected branches. Gate runs stored in gate_runs with commit SHA. Push events recorded in audit_log."
632 />
633 <CheckItem
634 ok={true}
635 label="GateTest / CI enforcement (PI1.1)"
636 desc="GateTest, secret-scan, type-check, and lint gates block merge on failure. Gate results stored per-commit in gate_runs with pass/fail status."
637 />
638 <CheckItem
639 ok={false}
640 label="Input validation documentation (PI1.1)"
641 desc="Input validation is implemented in route handlers but not formally documented. Add OpenAPI schema validation annotations for auditor review."
642 />
643 </div>
644 </div>
645
646 {/* ── P: Privacy ── */}
647 <div class="soc2-category">
648 <div class="soc2-section-card">
649 <h2 style="margin-top: 0;">P — Privacy</h2>
650
651 <CheckItem
652 ok={true}
653 label="Account deletion with grace period (P4.3)"
654 desc="Users can schedule account deletion at /settings. 30-day grace period with cancellation option. Deletion scheduled via deletionScheduledFor; purge via autopilot task."
655 />
656 <CheckItem
657 ok={true}
658 label="Terms of Service + Privacy Policy (P1.1)"
659 desc="Terms available at /terms and /legal/terms. Privacy Policy at /privacy. Acceptance timestamp + version recorded per user on registration (termsAcceptedAt, termsVersion)."
660 />
661 <CheckItem
662 ok={true}
663 label="Email verification (P4.2)"
664 desc="Email verification sent on registration when RESEND_API_KEY is configured. emailVerifiedAt timestamp tracked per user."
665 />
666 <CheckItem
667 ok={false}
668 label="Data Processing Agreement / DPA (P1.1)"
669 desc="No DPA available for EU/EEA customers. Required for GDPR Article 28 compliance if handling EU personal data. Add DPA to /legal/ and link from Privacy Policy."
670 />
671 <CheckItem
672 ok={false}
673 label="Data retention policy (P6.7)"
674 desc="No documented data retention schedule. Specify how long: sessions (30d), audit_log (indefinite), deleted accounts (purged after 30d grace). Document and automate."
675 />
676 <CheckItem
677 ok={false}
678 label="Right to export / data portability (P8.1)"
679 desc="No self-service data export for users. GitHub provides this via API; add an equivalent for full GDPR compliance."
680 />
681 </div>
682 </div>
683
684 <div style="margin-top: var(--space-6); padding: var(--space-4); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; color: var(--text-muted);">
685 <strong style="color: var(--text);">Summary:</strong> 16 controls implemented, 10 gaps identified.
686 Priority order for SOC 2 Type I: (1) MFA enforcement for admins, (2) SLA definition,
687 (3) DPA for EU customers, (4) Penetration test, (5) Vulnerability disclosure policy.
688 Contact <a href="mailto:security@gluecron.com">security@gluecron.com</a> to report issues.
689 </div>
690 </div>
691 </Layout>
692 );
693});
694
695export default adminSecurity;
Addedsrc/routes/admin-stripe.tsx+410−0View fileUnifiedSplit
1/**
2 * GET /admin/stripe — Stripe subscription sync dashboard
3 * POST /admin/stripe/:userId/sync — update local plan to match Stripe
4 *
5 * Shows all non-free users, their local plan, and live Stripe status.
6 * Mismatches (local says pro/team but Stripe cancelled, or vice versa)
7 * are highlighted with a "Fix" button.
8 *
9 * If STRIPE_SECRET_KEY is not set, only local data is shown.
10 *
11 * Gated by isSiteAdmin (same pattern as all other admin sub-pages).
12 */
13
14import { Hono } from "hono";
15import { desc, eq, ne } from "drizzle-orm";
16import { db } from "../db";
17import { userQuotas, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { softAuth } from "../middleware/auth";
20import type { AuthEnv } from "../middleware/auth";
21import { isSiteAdmin } from "../lib/admin";
22import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
23import { audit } from "../lib/notify";
24
25const adminStripe = new Hono<AuthEnv>();
26adminStripe.use("*", softAuth);
27
28async function gate(c: any): Promise<{ user: any } | Response> {
29 const user = c.get("user");
30 if (!user) return c.redirect("/login?next=/admin/stripe");
31 if (!(await isSiteAdmin(user.id))) {
32 return c.html(
33 <Layout title="Forbidden" user={user}>
34 <div style="max-width:540px;margin:80px auto;padding:32px;text-align:center;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px;">
35 <h2 style="font-family:var(--font-display);font-size:22px;margin:0 0 8px;color:var(--text-strong);">403 — Not a site admin</h2>
36 <p style="color:var(--text-muted);margin:0;font-size:14px;">You don't have permission to view this page.</p>
37 </div>
38 </Layout>,
39 403
40 );
41 }
42 return { user };
43}
44
45const styles = `
46 .adm-stripe-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
47
48 .adm-stripe-hero {
49 position: relative;
50 margin-bottom: var(--space-5);
51 padding: var(--space-5) var(--space-6);
52 background: var(--bg-elevated);
53 border: 1px solid var(--border);
54 border-radius: 16px;
55 overflow: hidden;
56 }
57 .adm-stripe-hero::before {
58 content: '';
59 position: absolute;
60 top: 0; left: 0; right: 0;
61 height: 2px;
62 background: linear-gradient(90deg, transparent 0%, #818cf8 30%, #38bdf8 70%, transparent 100%);
63 opacity: 0.7;
64 pointer-events: none;
65 }
66 .adm-stripe-hero-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
67 .adm-stripe-eyebrow { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #818cf8; margin-bottom: 6px; }
68 .adm-stripe-title { font-family: var(--font-display); font-size: clamp(24px,3.5vw,36px); font-weight: 800; letter-spacing: -0.025em; margin: 0 0 4px; color: var(--text-strong); }
69 .adm-stripe-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
70 .adm-stripe-back { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); background: rgba(255,255,255,0.02); border: 1px solid var(--border); border-radius: 8px; text-decoration: none; font-weight: 500; transition: border-color 120ms,color 120ms,background 120ms; }
71 .adm-stripe-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
72
73 .adm-stripe-notice {
74 margin-bottom: var(--space-4);
75 padding: 12px 16px;
76 border-radius: 10px;
77 font-size: 13.5px;
78 border: 1px solid rgba(251,191,36,0.40);
79 background: rgba(251,191,36,0.06);
80 color: #fde68a;
81 line-height: 1.5;
82 }
83 .adm-stripe-notice code { font-family: var(--font-mono); font-size: 12.5px; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 4px; }
84
85 .adm-stripe-banner { margin-bottom: var(--space-4); padding: 10px 14px; border-radius: 10px; font-size: 13.5px; border: 1px solid var(--border); background: rgba(255,255,255,0.025); color: var(--text); }
86 .adm-stripe-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
87 .adm-stripe-banner.is-err { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
88
89 .adm-stripe-table { width: 100%; border-collapse: collapse; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
90 .adm-stripe-table thead th { text-align: left; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); padding: 10px 14px; background: rgba(255,255,255,0.015); border-bottom: 1px solid var(--border); }
91 .adm-stripe-table tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); font-size: 13px; color: var(--text); vertical-align: middle; }
92 .adm-stripe-table tbody tr:last-child td { border-bottom: none; }
93 .adm-stripe-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
94 .adm-stripe-table code { font-family: var(--font-mono); font-size: 11px; color: var(--text-strong); }
95
96 .adm-stripe-pill { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 700; }
97 .adm-stripe-pill-active { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30); }
98 .adm-stripe-pill-warn { background: rgba(251,146,60,0.12); color: #fdba74; box-shadow: inset 0 0 0 1px rgba(251,146,60,0.32); }
99 .adm-stripe-pill-err { background: rgba(248,113,113,0.12); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35); }
100 .adm-stripe-pill-muted { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
101
102 .adm-stripe-mismatch-row td { background: rgba(251,146,60,0.05) !important; }
103
104 .adm-stripe-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; transition: background 120ms, border-color 120ms; border: 1px solid rgba(140,109,255,0.45); background: rgba(140,109,255,0.08); color: #c5b3ff; }
105 .adm-stripe-btn:hover { background: rgba(140,109,255,0.18); border-color: rgba(140,109,255,0.70); color: #e0d5ff; }
106
107 .adm-stripe-empty { padding: var(--space-6); text-align: center; color: var(--text-muted); font-size: 13.5px; background: var(--bg-elevated); border: 1px dashed var(--border); border-radius: 14px; }
108
109 @media (max-width: 720px) {
110 .adm-stripe-wrap { padding: var(--space-4) var(--space-3); }
111 .adm-stripe-hero { padding: var(--space-4); }
112 .adm-stripe-table { display: block; overflow-x: auto; }
113 }
114`;
115
116type StripeRow = {
117 userId: string;
118 username: string;
119 email: string;
120 localPlan: string;
121 stripeSubId: string | null;
122 stripeStatus: string | null;
123 stripePlan: string | null;
124 mismatch: boolean;
125 stripeError: string | null;
126};
127
128function statusPill(status: string | null, stripeKeySet: boolean) {
129 if (!stripeKeySet) {
130 return <span class="adm-stripe-pill adm-stripe-pill-muted">N/A</span>;
131 }
132 if (!status) {
133 return <span class="adm-stripe-pill adm-stripe-pill-muted">no sub</span>;
134 }
135 const lower = status.toLowerCase();
136 if (lower === "active" || lower === "trialing") {
137 return <span class="adm-stripe-pill adm-stripe-pill-active">{status}</span>;
138 }
139 if (lower === "past_due") {
140 return <span class="adm-stripe-pill adm-stripe-pill-warn">{status}</span>;
141 }
142 return <span class="adm-stripe-pill adm-stripe-pill-err">{status}</span>;
143}
144
145adminStripe.get("/admin/stripe", async (c) => {
146 const g = await gate(c);
147 if (g instanceof Response) return g;
148 const { user } = g;
149
150 const stripeKeySet = !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_SECRET_KEY.length >= 10);
151
152 // Fetch all non-free users (join users + user_quotas)
153 const nonFreeRows = await db
154 .select({
155 userId: userQuotas.userId,
156 planSlug: userQuotas.planSlug,
157 stripeCustomerId: userQuotas.stripeCustomerId,
158 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
159 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
160 username: users.username,
161 email: users.email,
162 })
163 .from(userQuotas)
164 .innerJoin(users, eq(userQuotas.userId, users.id))
165 .where(ne(userQuotas.planSlug, "free"))
166 .orderBy(desc(userQuotas.planSlug), users.username)
167 .limit(500);
168
169 // For each user, fetch live Stripe status (if key is set)
170 const rows: StripeRow[] = [];
171 for (const r of nonFreeRows) {
172 let stripeStatus: string | null = r.stripeSubscriptionStatus;
173 let stripePlan: string | null = null;
174 let stripeError: string | null = null;
175
176 if (stripeKeySet && r.stripeSubscriptionId) {
177 try {
178 const res = await getSubscription(r.stripeSubscriptionId);
179 if (res.ok) {
180 stripeStatus = res.subscription.status;
181 stripePlan = planSlugFromSubscription(res.subscription);
182 } else {
183 stripeError = res.error;
184 // Keep DB-cached status as fallback
185 }
186 } catch (err) {
187 stripeError = err instanceof Error ? err.message : String(err);
188 }
189 }
190
191 // Determine mismatch:
192 // - local plan is non-free but Stripe subscription is cancelled/past_due/missing
193 // - local plan is free but Stripe shows active (shouldn't happen but catch it)
194 const activeOnStripe =
195 stripeStatus === "active" || stripeStatus === "trialing";
196 const cancelledOnStripe =
197 stripeStatus && stripeStatus !== "active" && stripeStatus !== "trialing";
198
199 let mismatch = false;
200 if (stripeKeySet && r.stripeSubscriptionId) {
201 if (cancelledOnStripe && r.planSlug !== "free") mismatch = true;
202 }
203
204 rows.push({
205 userId: r.userId,
206 username: r.username,
207 email: r.email,
208 localPlan: r.planSlug,
209 stripeSubId: r.stripeSubscriptionId,
210 stripeStatus,
211 stripePlan,
212 mismatch,
213 stripeError,
214 });
215 }
216
217 const mismatches = rows.filter((r) => r.mismatch);
218
219 const msg = c.req.query("msg") || "";
220 const errMsg = c.req.query("err") || "";
221
222 return c.html(
223 <Layout title="Admin — Stripe Sync" user={user}>
224 <style dangerouslySetInnerHTML={{ __html: styles }} />
225 <div class="adm-stripe-wrap">
226 <div class="adm-stripe-hero">
227 <div class="adm-stripe-hero-inner">
228 <div>
229 <div class="adm-stripe-eyebrow">Admin / Billing</div>
230 <h1 class="adm-stripe-title">Stripe Sync</h1>
231 <p class="adm-stripe-sub">
232 Non-free users — local plan vs live Stripe subscription status.
233 {mismatches.length > 0 && ` ${mismatches.length} mismatch${mismatches.length > 1 ? "es" : ""} detected.`}
234 </p>
235 </div>
236 <a href="/admin" class="adm-stripe-back">← Admin</a>
237 </div>
238 </div>
239
240 {!stripeKeySet && (
241 <div class="adm-stripe-notice">
242 <strong>STRIPE_SECRET_KEY not configured</strong> — showing local plan data only.
243 Set <code>STRIPE_SECRET_KEY</code> in your environment to enable live Stripe sync.
244 </div>
245 )}
246
247 {msg && <div class="adm-stripe-banner is-ok">{msg}</div>}
248 {errMsg && <div class="adm-stripe-banner is-err">{errMsg}</div>}
249
250 {rows.length === 0 ? (
251 <div class="adm-stripe-empty">No non-free users found.</div>
252 ) : (
253 <table class="adm-stripe-table">
254 <thead>
255 <tr>
256 <th>Username</th>
257 <th>Email</th>
258 <th>Local plan</th>
259 <th>Stripe status</th>
260 <th>Stripe plan</th>
261 <th>Subscription ID</th>
262 <th>Mismatch</th>
263 {stripeKeySet && <th>Action</th>}
264 </tr>
265 </thead>
266 <tbody>
267 {rows.map((r) => (
268 <tr class={r.mismatch ? "adm-stripe-mismatch-row" : ""}>
269 <td>
270 <a href={`/${r.username}`} style="color:var(--accent);text-decoration:none;font-weight:600;">
271 {r.username}
272 </a>
273 </td>
274 <td style="color:var(--text-muted)">{r.email}</td>
275 <td>
276 <span class="adm-stripe-pill adm-stripe-pill-active">{r.localPlan}</span>
277 </td>
278 <td>{statusPill(r.stripeStatus, stripeKeySet)}</td>
279 <td>
280 {r.stripePlan
281 ? <span class="adm-stripe-pill adm-stripe-pill-active">{r.stripePlan}</span>
282 : <span style="color:var(--text-muted)"></span>
283 }
284 </td>
285 <td>
286 {r.stripeSubId
287 ? <code>{r.stripeSubId.slice(0, 20)}…</code>
288 : <span style="color:var(--text-muted)"></span>
289 }
290 {r.stripeError && (
291 <span style="color:#fca5a5;font-size:11px;display:block;margin-top:2px" title={r.stripeError}>
292 ⚠ fetch failed
293 </span>
294 )}
295 </td>
296 <td>
297 {r.mismatch
298 ? <span class="adm-stripe-pill adm-stripe-pill-warn">mismatch</span>
299 : <span class="adm-stripe-pill adm-stripe-pill-muted">ok</span>
300 }
301 </td>
302 {stripeKeySet && (
303 <td>
304 {r.mismatch ? (
305 <form method="post" action={`/admin/stripe/${r.userId}/sync`}>
306 <button type="submit" class="adm-stripe-btn"
307 title={`Update local plan to match Stripe (${r.stripeStatus ?? "no sub"})`}>
308 Fix
309 </button>
310 </form>
311 ) : (
312 <span style="color:var(--text-muted);font-size:12px"></span>
313 )}
314 </td>
315 )}
316 </tr>
317 ))}
318 </tbody>
319 </table>
320 )}
321 </div>
322 </Layout>
323 );
324});
325
326// POST /admin/stripe/:userId/sync — update local plan to match Stripe
327adminStripe.post("/admin/stripe/:userId/sync", async (c) => {
328 const g = await gate(c);
329 if (g instanceof Response) return g;
330 const { user: adminUser } = g;
331
332 const targetUserId = c.req.param("userId");
333
334 // Fetch quota row
335 const quotaRows = await db
336 .select({
337 userId: userQuotas.userId,
338 planSlug: userQuotas.planSlug,
339 stripeSubscriptionId: userQuotas.stripeSubscriptionId,
340 stripeSubscriptionStatus: userQuotas.stripeSubscriptionStatus,
341 })
342 .from(userQuotas)
343 .where(eq(userQuotas.userId, targetUserId))
344 .limit(1);
345
346 const quota = quotaRows[0];
347 if (!quota) {
348 return c.redirect("/admin/stripe?err=" + encodeURIComponent("User quota row not found."));
349 }
350
351 // Determine the correct plan from Stripe
352 let newPlan: string = "free";
353 let stripeStatus: string | null = null;
354
355 if (!quota.stripeSubscriptionId) {
356 newPlan = "free";
357 } else {
358 try {
359 const res = await getSubscription(quota.stripeSubscriptionId);
360 if (res.ok) {
361 stripeStatus = res.subscription.status;
362 const isActive = stripeStatus === "active" || stripeStatus === "trialing";
363 if (isActive) {
364 const slug = planSlugFromSubscription(res.subscription);
365 newPlan = slug ?? "free";
366 } else {
367 newPlan = "free";
368 }
369 } else {
370 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe API error: ${res.error}`));
371 }
372 } catch (err) {
373 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`Stripe request failed: ${err instanceof Error ? err.message : String(err)}`));
374 }
375 }
376
377 // Update local plan
378 try {
379 await db
380 .update(userQuotas)
381 .set({
382 planSlug: newPlan,
383 stripeSubscriptionStatus: stripeStatus ?? quota.stripeSubscriptionStatus,
384 updatedAt: new Date(),
385 })
386 .where(eq(userQuotas.userId, targetUserId));
387 } catch (err) {
388 return c.redirect("/admin/stripe?err=" + encodeURIComponent(`DB update failed: ${err instanceof Error ? err.message : String(err)}`));
389 }
390
391 await audit({
392 userId: adminUser.id,
393 action: "billing.admin_sync",
394 targetType: "user",
395 targetId: targetUserId,
396 metadata: {
397 oldPlan: quota.planSlug,
398 newPlan,
399 stripeStatus: stripeStatus ?? "unknown",
400 triggeredBy: adminUser.id,
401 },
402 });
403
404 return c.redirect(
405 "/admin/stripe?msg=" +
406 encodeURIComponent(`Plan updated: ${quota.planSlug}${newPlan} (Stripe status: ${stripeStatus ?? "unknown"})`)
407 );
408});
409
410export default adminStripe;
Modifiedsrc/routes/admin.tsx+751−3View fileUnifiedSplit
2121 */
2222
2323import { Hono } from "hono";
24import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
24import { and, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
2525import { db } from "../db";
26import { repositories, users } from "../db/schema";
26import { aiCostEvents, auditLog, repositories, users } from "../db/schema";
2727import { Layout } from "../views/layout";
2828import { softAuth } from "../middleware/auth";
2929import type { AuthEnv } from "../middleware/auth";
24202420 }
24212421`;
24222422
2423const admAnalyticsStyles = `
2424 .adm-analytics-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
2425
2426 .adm-analytics-hero {
2427 position: relative;
2428 margin-bottom: var(--space-5);
2429 padding: var(--space-5) var(--space-6);
2430 background: var(--bg-elevated);
2431 border: 1px solid var(--border);
2432 border-radius: 16px;
2433 overflow: hidden;
2434 }
2435 .adm-analytics-hero::before {
2436 content: '';
2437 position: absolute;
2438 top: 0; left: 0; right: 0;
2439 height: 2px;
2440 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2441 opacity: 0.7;
2442 pointer-events: none;
2443 }
2444 .adm-analytics-hero-orb {
2445 position: absolute;
2446 inset: -20% -10% auto auto;
2447 width: 380px; height: 380px;
2448 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
2449 filter: blur(80px);
2450 opacity: 0.7;
2451 pointer-events: none;
2452 z-index: 0;
2453 animation: admAnalyticsOrb 14s ease-in-out infinite;
2454 }
2455 @keyframes admAnalyticsOrb {
2456 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2457 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2458 }
2459 @media (prefers-reduced-motion: reduce) { .adm-analytics-hero-orb { animation: none; } }
2460 .adm-analytics-hero-inner {
2461 position: relative; z-index: 1;
2462 display: flex; align-items: flex-end; justify-content: space-between;
2463 gap: var(--space-4); flex-wrap: wrap;
2464 }
2465 .adm-analytics-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2466 .adm-analytics-eyebrow {
2467 font-size: 12px; color: var(--text-muted); margin-bottom: var(--space-2);
2468 letter-spacing: 0.02em; display: inline-flex; align-items: center; gap: 8px;
2469 }
2470 .adm-analytics-eyebrow-pill {
2471 display: inline-flex; align-items: center; justify-content: center;
2472 width: 22px; height: 22px; border-radius: 6px;
2473 background: rgba(140,109,255,0.14); color: #b69dff;
2474 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
2475 }
2476 .adm-analytics-title {
2477 font-size: clamp(28px, 4vw, 36px); font-family: var(--font-display);
2478 font-weight: 800; letter-spacing: -0.028em; line-height: 1.05;
2479 margin: 0 0 var(--space-2); color: var(--text-strong);
2480 }
2481 .adm-analytics-title-grad {
2482 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2483 -webkit-background-clip: text; background-clip: text;
2484 -webkit-text-fill-color: transparent; color: transparent;
2485 }
2486 .adm-analytics-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
2487 .adm-analytics-back {
2488 display: inline-flex; align-items: center; gap: 6px;
2489 padding: 7px 12px; font-size: 12.5px; color: var(--text-muted);
2490 background: rgba(255,255,255,0.02); border: 1px solid var(--border);
2491 border-radius: 8px; text-decoration: none; font-weight: 500;
2492 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2493 }
2494 .adm-analytics-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
2495
2496 .adm-analytics-statgrid {
2497 display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2498 gap: var(--space-3); margin-bottom: var(--space-5);
2499 }
2500 .adm-analytics-stat {
2501 padding: var(--space-4); background: var(--bg-elevated);
2502 border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
2503 }
2504 .adm-analytics-stat-label {
2505 font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
2506 text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
2507 }
2508 .adm-analytics-stat-value {
2509 font-family: var(--font-display); font-size: 28px; font-weight: 800;
2510 letter-spacing: -0.024em; line-height: 1; color: var(--text-strong);
2511 }
2512 .adm-analytics-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-faint); }
2513
2514 .adm-analytics-h3 {
2515 display: flex; align-items: baseline; justify-content: space-between;
2516 gap: var(--space-3); margin: var(--space-5) 0 var(--space-3);
2517 }
2518 .adm-analytics-h3 h3 {
2519 font-family: var(--font-display); font-size: 16px; font-weight: 700;
2520 letter-spacing: -0.014em; margin: 0; color: var(--text-strong);
2521 }
2522 .adm-analytics-h3-meta { font-size: 12px; color: var(--text-muted); }
2523
2524 .adm-analytics-table {
2525 width: 100%; border-collapse: collapse;
2526 background: var(--bg-elevated); border: 1px solid var(--border);
2527 border-radius: 14px; overflow: hidden;
2528 }
2529 .adm-analytics-table thead th {
2530 text-align: left; font-size: 11px; font-weight: 600;
2531 letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
2532 padding: 10px 16px; background: rgba(255,255,255,0.015);
2533 border-bottom: 1px solid var(--border);
2534 }
2535 .adm-analytics-table thead th:not(:first-child) { text-align: right; }
2536 .adm-analytics-table tbody td {
2537 padding: 10px 16px; border-bottom: 1px solid var(--border-subtle);
2538 font-size: 13px; color: var(--text); vertical-align: middle;
2539 }
2540 .adm-analytics-table tbody td:not(:first-child) { text-align: right; font-family: var(--font-mono); font-size: 12px; }
2541 .adm-analytics-table tbody tr:last-child td { border-bottom: none; }
2542 .adm-analytics-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
2543 .adm-analytics-table code { font-family: var(--font-mono); font-size: 12px; color: var(--text-strong); }
2544 .adm-analytics-empty {
2545 padding: var(--space-6); text-align: center; color: var(--text-muted);
2546 font-size: 13.5px; background: var(--bg-elevated);
2547 border: 1px dashed var(--border); border-radius: 14px;
2548 }
2549
2550 /* bar chart cells */
2551 .adm-analytics-bar-cell { display: flex; align-items: center; gap: 8px; }
2552 .adm-analytics-bar-track {
2553 flex: 1; height: 6px; background: var(--bg-tertiary);
2554 border-radius: 9999px; overflow: hidden; min-width: 60px;
2555 }
2556 .adm-analytics-bar-fill {
2557 height: 100%; border-radius: 9999px;
2558 background: linear-gradient(90deg, #8c6dff, #36c5d6);
2559 }
2560 .adm-analytics-bar-label { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
2561
2562 .adm-analytics-pill {
2563 display: inline-flex; align-items: center; gap: 4px;
2564 padding: 2px 8px; border-radius: 9999px; font-size: 10.5px;
2565 font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
2566 background: rgba(140,109,255,0.12); color: #c5b3ff;
2567 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2568 }
2569 .adm-analytics-pill.is-ok { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28); }
2570 .adm-analytics-pill.is-err { background: rgba(248,113,113,0.12); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.28); }
2571
2572 @media (max-width: 720px) {
2573 .adm-analytics-wrap { padding: var(--space-4) var(--space-3); }
2574 .adm-analytics-hero { padding: var(--space-4); }
2575 .adm-analytics-statgrid { grid-template-columns: 1fr 1fr; }
2576 .adm-analytics-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
2577 }
2578`;
2579
24232580/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
24242581const Icons = {
24252582 shield: (
25092666 <polyline points="12 19 5 12 12 5" />
25102667 </svg>
25112668 ),
2669 dollarSign: (
2670 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2671 <line x1="12" y1="1" x2="12" y2="23" />
2672 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
2673 </svg>
2674 ),
2675 trendingUp: (
2676 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2677 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
2678 <polyline points="17 6 23 6 23 12" />
2679 </svg>
2680 ),
25122681 key: (
25132682 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
25142683 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
26832852 <span class="admin-action-icon">{Icons.sso}</span>
26842853 Enterprise SSO
26852854 </a>
2855 <a href="/admin/ai-costs" class="admin-action is-primary">
2856 <span class="admin-action-icon">{Icons.dollarSign}</span>
2857 AI cost breakdown
2858 </a>
2859 <a href="/admin/growth" class="admin-action is-primary">
2860 <span class="admin-action-icon">{Icons.trendingUp}</span>
2861 User growth
2862 </a>
26862863 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
26872864 <span class="admin-action-icon">{Icons.bot}</span>
26882865 Autopilot
26952872 <span class="admin-action-icon">{Icons.bot}</span>
26962873 Connect Claude
26972874 </a>
2875 <a href="/admin/deletions" class="admin-action">
2876 <span class="admin-action-icon">{Icons.flag}</span>
2877 GDPR Deletions
2878 </a>
2879 <a href="/admin/stripe" class="admin-action">
2880 <span class="admin-action-icon">{Icons.key}</span>
2881 Stripe Sync
2882 </a>
26982883 <form
26992884 method="post"
27002885 action="/admin/demo/reseed"
34373622 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
34383623 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
34393624 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
3625 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
3626 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
34403627] as const;
34413628
34423629admin.get("/admin/autopilot", async (c) => {
35853772 )}
35863773 <div class="adm-autopilot-h3" style="margin-top:28px">
35873774 <h3>Configured tasks</h3>
3588 <span class="adm-autopilot-h3-meta">9 tasks · runs every tick</span>
3775 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
35893776 </div>
35903777 <div class="adm-autopilot-tasks">
35913778 {AUTOPILOT_TASK_CATALOG.map((t) => {
36863873 }
36873874});
36883875
3876// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
3877
3878admin.get("/admin/ai-costs", async (c) => {
3879 const g = await gate(c);
3880 if (g instanceof Response) return g;
3881 const { user } = g;
3882
3883 // Start of current month (UTC)
3884 const now = new Date();
3885 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
3886
3887 // Total spend this month
3888 const [totalRow] = await db
3889 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
3890 .from(aiCostEvents)
3891 .where(gte(aiCostEvents.occurredAt, monthStart));
3892 const totalCents = Number(totalRow?.total ?? 0);
3893
3894 // Breakdown by category
3895 const byCategory = await db
3896 .select({
3897 category: aiCostEvents.category,
3898 cents: sql<number>`sum(cents_estimate)::int`,
3899 calls: sql<number>`count(*)::int`,
3900 })
3901 .from(aiCostEvents)
3902 .where(gte(aiCostEvents.occurredAt, monthStart))
3903 .groupBy(aiCostEvents.category)
3904 .orderBy(sql`sum(cents_estimate) desc`);
3905
3906 // Top 10 spenders (join users)
3907 const topSpenders = await db
3908 .select({
3909 username: users.username,
3910 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
3911 calls: sql<number>`count(*)::int`,
3912 })
3913 .from(aiCostEvents)
3914 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
3915 .where(gte(aiCostEvents.occurredAt, monthStart))
3916 .groupBy(users.username)
3917 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
3918 .limit(10);
3919
3920 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
3921 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
3922
3923 function fmtCents(c: number): string {
3924 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
3925 return `${c}¢`;
3926 }
3927
3928 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
3929
3930 return c.html(
3931 <Layout title="Admin — AI Costs" user={user}>
3932 <div class="adm-analytics-wrap">
3933 <section class="adm-analytics-hero">
3934 <div class="adm-analytics-hero-orb" aria-hidden="true" />
3935 <div class="adm-analytics-hero-inner">
3936 <div class="adm-analytics-hero-text">
3937 <div class="adm-analytics-eyebrow">
3938 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
3939 Site admin · Analytics
3940 </div>
3941 <h1 class="adm-analytics-title">
3942 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
3943 </h1>
3944 <p class="adm-analytics-sub">
3945 Per-call AI spend for {monthName} — by feature category and top spenders.
3946 </p>
3947 </div>
3948 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
3949 </div>
3950 </section>
3951
3952 <div class="adm-analytics-statgrid">
3953 <div class="adm-analytics-stat">
3954 <div class="adm-analytics-stat-label">Total spend this month</div>
3955 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
3956 <div class="adm-analytics-stat-hint">{monthName}</div>
3957 </div>
3958 <div class="adm-analytics-stat">
3959 <div class="adm-analytics-stat-label">Categories active</div>
3960 <div class="adm-analytics-stat-value">{byCategory.length}</div>
3961 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
3962 </div>
3963 <div class="adm-analytics-stat">
3964 <div class="adm-analytics-stat-label">Total AI calls</div>
3965 <div class="adm-analytics-stat-value">
3966 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
3967 </div>
3968 <div class="adm-analytics-stat-hint">recorded events</div>
3969 </div>
3970 </div>
3971
3972 <div class="adm-analytics-h3">
3973 <h3>By category</h3>
3974 <span class="adm-analytics-h3-meta">{monthName}</span>
3975 </div>
3976 {byCategory.length === 0 ? (
3977 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
3978 ) : (
3979 <table class="adm-analytics-table">
3980 <thead>
3981 <tr>
3982 <th>Category</th>
3983 <th>Spend</th>
3984 <th>Calls</th>
3985 <th style="width:200px">Distribution</th>
3986 </tr>
3987 </thead>
3988 <tbody>
3989 {byCategory.map((row) => {
3990 const pct = Math.round((Number(row.cents) / maxCents) * 100);
3991 return (
3992 <tr>
3993 <td><code>{row.category ?? "other"}</code></td>
3994 <td>{fmtCents(Number(row.cents))}</td>
3995 <td>{Number(row.calls).toLocaleString()}</td>
3996 <td>
3997 <div class="adm-analytics-bar-cell">
3998 <div class="adm-analytics-bar-track">
3999 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4000 </div>
4001 <span class="adm-analytics-bar-label">{pct}%</span>
4002 </div>
4003 </td>
4004 </tr>
4005 );
4006 })}
4007 </tbody>
4008 </table>
4009 )}
4010
4011 <div class="adm-analytics-h3">
4012 <h3>Top 10 spenders</h3>
4013 <span class="adm-analytics-h3-meta">{monthName}</span>
4014 </div>
4015 {topSpenders.length === 0 ? (
4016 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4017 ) : (
4018 <table class="adm-analytics-table">
4019 <thead>
4020 <tr>
4021 <th>User</th>
4022 <th>Spend</th>
4023 <th>Calls</th>
4024 <th style="width:200px">Share</th>
4025 </tr>
4026 </thead>
4027 <tbody>
4028 {topSpenders.map((row, i) => {
4029 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4030 return (
4031 <tr>
4032 <td>
4033 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4034 #{i + 1} {row.username}
4035 </a>
4036 </td>
4037 <td>{fmtCents(Number(row.cents))}</td>
4038 <td>{Number(row.calls).toLocaleString()}</td>
4039 <td>
4040 <div class="adm-analytics-bar-cell">
4041 <div class="adm-analytics-bar-track">
4042 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4043 </div>
4044 <span class="adm-analytics-bar-label">{pct}%</span>
4045 </div>
4046 </td>
4047 </tr>
4048 );
4049 })}
4050 </tbody>
4051 </table>
4052 )}
4053 </div>
4054 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4055 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4056 </Layout>
4057 );
4058});
4059
4060// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4061
4062const ALL_AUTOPILOT_TASKS = [
4063 "mirror-sync",
4064 "merge-queue",
4065 "weekly-digest",
4066 "advisory-rescan",
4067 "wait-timer-release",
4068 "scheduled-workflows",
4069 "auto-merge-sweep",
4070 "ai-build-from-issues",
4071 "sleep-mode-digest",
4072 "stale-pr-sweep",
4073] as const;
4074
4075admin.get("/admin/autopilot/health", async (c) => {
4076 const g = await gate(c);
4077 if (g instanceof Response) return g;
4078 const { user } = g;
4079
4080 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4081
4082 // Pull the last 200 audit log rows that match autopilot task patterns
4083 const logRows = await db
4084 .select({
4085 action: auditLog.action,
4086 createdAt: auditLog.createdAt,
4087 metadata: auditLog.metadata,
4088 })
4089 .from(auditLog)
4090 .where(gte(auditLog.createdAt, since24h))
4091 .orderBy(desc(auditLog.createdAt))
4092 .limit(500);
4093
4094 // Build per-task health from the last autopilot tick (in-memory)
4095 const tick = getLastTick();
4096
4097 type TaskHealth = {
4098 name: string;
4099 lastRun: string | null;
4100 lastDurationMs: number | null;
4101 lastOk: boolean | null;
4102 lastError: string | null;
4103 successCount24h: number;
4104 errorCount24h: number;
4105 };
4106
4107 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4108 // Count audit events in last 24h that look like this task
4109 const successCount24h = logRows.filter(
4110 (r) => r.action === `autopilot.task.ok.${taskName}`
4111 ).length;
4112 const errorCount24h = logRows.filter(
4113 (r) => r.action === `autopilot.task.error.${taskName}`
4114 ).length;
4115
4116 // Get last tick result for this task (in-memory)
4117 const last = tick?.tasks.find((t) => t.name === taskName);
4118
4119 return {
4120 name: taskName,
4121 lastRun: tick?.finishedAt ?? null,
4122 lastDurationMs: last?.durationMs ?? null,
4123 lastOk: last?.ok ?? null,
4124 lastError: last?.error ?? null,
4125 successCount24h,
4126 errorCount24h,
4127 };
4128 });
4129
4130 const total = getTickCount();
4131 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4132
4133 return c.html(
4134 <Layout title="Autopilot Health — admin" user={user}>
4135 <div class="adm-analytics-wrap">
4136 <section class="adm-analytics-hero">
4137 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4138 <div class="adm-analytics-hero-inner">
4139 <div class="adm-analytics-hero-text">
4140 <div class="adm-analytics-eyebrow">
4141 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4142 Site admin · Autopilot
4143 </div>
4144 <h1 class="adm-analytics-title">
4145 <span class="adm-analytics-title-grad">Autopilot health</span>.
4146 </h1>
4147 <p class="adm-analytics-sub">
4148 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4149 </p>
4150 </div>
4151 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4152 </div>
4153 </section>
4154
4155 <div class="adm-analytics-statgrid">
4156 <div class="adm-analytics-stat">
4157 <div class="adm-analytics-stat-label">Status</div>
4158 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4159 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4160 </div>
4161 <div class="adm-analytics-stat">
4162 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4163 <div class="adm-analytics-stat-value">{total}</div>
4164 <div class="adm-analytics-stat-hint">since boot</div>
4165 </div>
4166 <div class="adm-analytics-stat">
4167 <div class="adm-analytics-stat-label">Last tick</div>
4168 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4169 {tick?.finishedAt ?? "—"}
4170 </div>
4171 </div>
4172 <div class="adm-analytics-stat">
4173 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4174 <div class="adm-analytics-stat-value">
4175 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4176 </div>
4177 </div>
4178 </div>
4179
4180 <div class="adm-analytics-h3">
4181 <h3>Per-task health</h3>
4182 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4183 </div>
4184 <table class="adm-analytics-table">
4185 <thead>
4186 <tr>
4187 <th>Task</th>
4188 <th>Last status</th>
4189 <th>Duration (last)</th>
4190 <th>OK (24h)</th>
4191 <th>Errors (24h)</th>
4192 </tr>
4193 </thead>
4194 <tbody>
4195 {health.map((t) => (
4196 <tr>
4197 <td><code>{t.name}</code></td>
4198 <td>
4199 {t.lastOk === null ? (
4200 <span style="color:var(--text-muted)">—</span>
4201 ) : t.lastOk ? (
4202 <span class="adm-analytics-pill is-ok">ok</span>
4203 ) : (
4204 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4205 )}
4206 </td>
4207 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4208 <td>
4209 {t.successCount24h > 0 ? (
4210 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4211 ) : (
4212 <span style="color:var(--text-muted)">—</span>
4213 )}
4214 </td>
4215 <td>
4216 {t.errorCount24h > 0 ? (
4217 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4218 ) : (
4219 <span style="color:var(--text-muted)">0</span>
4220 )}
4221 </td>
4222 </tr>
4223 ))}
4224 </tbody>
4225 </table>
4226
4227 {tick?.tasks.some((t) => !t.ok && t.error) && (
4228 <>
4229 <div class="adm-analytics-h3" style="margin-top:28px">
4230 <h3>Last-tick errors</h3>
4231 </div>
4232 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4233 <div style="margin-bottom:12px;padding:12px 14px;background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.20);border-radius:10px;font-size:12.5px">
4234 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4235 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4236 </div>
4237 ))}
4238 </>
4239 )}
4240
4241 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4242 The 24h OK/error counts reflect <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">audit_log</code> rows written by autopilot task wrappers. If those rows are absent, counts will show — (not yet instrumented).
4243 </p>
4244 </div>
4245 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4246 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4247 </Layout>
4248 );
4249});
4250
4251// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4252
4253admin.get("/admin/growth", async (c) => {
4254 const g = await gate(c);
4255 if (g instanceof Response) return g;
4256 const { user } = g;
4257
4258 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4259
4260 // Daily signups last 30 days
4261 const dailySignups = await db
4262 .select({
4263 day: sql<string>`date_trunc('day', created_at)::date::text`,
4264 count: sql<number>`count(*)::int`,
4265 })
4266 .from(users)
4267 .where(gte(users.createdAt, since30d))
4268 .groupBy(sql`date_trunc('day', created_at)`)
4269 .orderBy(sql`date_trunc('day', created_at)`);
4270
4271 // Activation rate: users who created at least 1 repo (last 30d signups)
4272 const activatedRows = await db
4273 .select({
4274 userId: users.id,
4275 })
4276 .from(users)
4277 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4278 .where(gte(users.createdAt, since30d))
4279 .groupBy(users.id);
4280
4281 // Total signups in window
4282 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4283 const activated = activatedRows.length;
4284 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4285
4286 // All-time user count
4287 const [allTimeRow] = await db
4288 .select({ n: sql<number>`count(*)::int` })
4289 .from(users);
4290 const allTime = Number(allTimeRow?.n ?? 0);
4291
4292 // Build a 30-slot array (fill missing days with 0)
4293 const dayMap = new Map<string, number>();
4294 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4295
4296 const slots: { label: string; count: number }[] = [];
4297 for (let i = 29; i >= 0; i--) {
4298 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4299 const key = d.toISOString().slice(0, 10);
4300 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4301 slots.push({ label, count: dayMap.get(key) ?? 0 });
4302 }
4303
4304 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4305
4306 return c.html(
4307 <Layout title="Admin — User Growth" user={user}>
4308 <div class="adm-analytics-wrap">
4309 <section class="adm-analytics-hero">
4310 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4311 <div class="adm-analytics-hero-inner">
4312 <div class="adm-analytics-hero-text">
4313 <div class="adm-analytics-eyebrow">
4314 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4315 Site admin · Analytics
4316 </div>
4317 <h1 class="adm-analytics-title">
4318 <span class="adm-analytics-title-grad">User growth</span>.
4319 </h1>
4320 <p class="adm-analytics-sub">
4321 Daily signups and activation rate over the last 30 days.
4322 </p>
4323 </div>
4324 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4325 </div>
4326 </section>
4327
4328 <div class="adm-analytics-statgrid">
4329 <div class="adm-analytics-stat">
4330 <div class="adm-analytics-stat-label">Total users</div>
4331 <div class="adm-analytics-stat-value">{allTime}</div>
4332 <div class="adm-analytics-stat-hint">all time</div>
4333 </div>
4334 <div class="adm-analytics-stat">
4335 <div class="adm-analytics-stat-label">New signups (30d)</div>
4336 <div class="adm-analytics-stat-value">{totalSignups}</div>
4337 <div class="adm-analytics-stat-hint">last 30 days</div>
4338 </div>
4339 <div class="adm-analytics-stat">
4340 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4341 <div class="adm-analytics-stat-value">{activationRate}%</div>
4342 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4343 </div>
4344 <div class="adm-analytics-stat">
4345 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4346 <div class="adm-analytics-stat-value">{activated}</div>
4347 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4348 </div>
4349 </div>
4350
4351 <div class="adm-analytics-h3">
4352 <h3>Daily signups — last 30 days</h3>
4353 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4354 </div>
4355
4356 {totalSignups === 0 ? (
4357 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4358 ) : (
4359 <table class="adm-analytics-table">
4360 <thead>
4361 <tr>
4362 <th>Date</th>
4363 <th>Signups</th>
4364 <th style="width:280px">Bar</th>
4365 </tr>
4366 </thead>
4367 <tbody>
4368 {slots.filter((s) => s.count > 0 || true).map((s) => {
4369 const pct = Math.round((s.count / maxCount) * 100);
4370 return (
4371 <tr>
4372 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4373 <td>{s.count}</td>
4374 <td>
4375 {s.count > 0 ? (
4376 <div class="adm-analytics-bar-cell">
4377 <div class="adm-analytics-bar-track">
4378 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4379 </div>
4380 <span class="adm-analytics-bar-label">{s.count}</span>
4381 </div>
4382 ) : (
4383 <span style="color:var(--text-faint);font-size:11px">—</span>
4384 )}
4385 </td>
4386 </tr>
4387 );
4388 })}
4389 </tbody>
4390 </table>
4391 )}
4392
4393 <div class="adm-analytics-h3" style="margin-top:28px">
4394 <h3>Activation breakdown</h3>
4395 <span class="adm-analytics-h3-meta">30d cohort</span>
4396 </div>
4397 <table class="adm-analytics-table" style="max-width:500px">
4398 <thead>
4399 <tr>
4400 <th>Segment</th>
4401 <th>Count</th>
4402 <th style="width:200px">Rate</th>
4403 </tr>
4404 </thead>
4405 <tbody>
4406 <tr>
4407 <td>New signups (30d)</td>
4408 <td>{totalSignups}</td>
4409 <td>100%</td>
4410 </tr>
4411 <tr>
4412 <td>Activated (created ≥1 repo)</td>
4413 <td>{activated}</td>
4414 <td>
4415 <div class="adm-analytics-bar-cell">
4416 <div class="adm-analytics-bar-track">
4417 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4418 </div>
4419 <span class="adm-analytics-bar-label">{activationRate}%</span>
4420 </div>
4421 </td>
4422 </tr>
4423 <tr>
4424 <td>Not activated</td>
4425 <td>{totalSignups - activated}</td>
4426 <td>{100 - activationRate}%</td>
4427 </tr>
4428 </tbody>
4429 </table>
4430 </div>
4431 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4432 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4433 </Layout>
4434 );
4435});
4436
36894437export default admin;
Addedsrc/routes/agent-pipelines.tsx+1119−0View fileUnifiedSplit
Large file (1,119 lines). Load full file
Addedsrc/routes/ai-editor.ts+230−0View fileUnifiedSplit
1/**
2 * AI editor API routes — inline suggestions, code explanation, and fix generation.
3 *
4 * All endpoints require an authenticated session and `ANTHROPIC_API_KEY`.
5 * Rate-limited to 30 suggestion requests per user per hour (in-memory).
6 *
7 * Routes:
8 * POST /api/ai/suggest — code completion (ghost text)
9 * POST /api/ai/explain — explain selected code
10 * POST /api/ai/fix — fix code given a GateTest / error message
11 */
12
13import { Hono } from "hono";
14import { requireAuth } from "../middleware/auth";
15import { isAiAvailable, getAnthropic, MODEL_HAIKU, MODEL_SONNET, extractText } from "../lib/ai-client";
16import type { AuthEnv } from "../middleware/auth";
17
18const aiEditor = new Hono<AuthEnv>();
19
20// ─── Rate-limit store ────────────────────────────────────────────────────────
21// Simple in-memory map: userId → array of request timestamps (ms).
22// Cleaned lazily to avoid unbounded growth on high-traffic servers.
23const QUOTA_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
24const QUOTA_MAX = 30;
25
26const suggestQuota = new Map<number, number[]>();
27
28function checkQuota(userId: number): { allowed: boolean; resetInMinutes: number } {
29 const now = Date.now();
30 const cutoff = now - QUOTA_WINDOW_MS;
31 let timestamps = suggestQuota.get(userId) ?? [];
32 // Purge expired entries
33 timestamps = timestamps.filter((t) => t > cutoff);
34
35 if (timestamps.length >= QUOTA_MAX) {
36 const oldest = timestamps[0]!;
37 const resetInMs = oldest + QUOTA_WINDOW_MS - now;
38 const resetInMinutes = Math.ceil(resetInMs / 60_000);
39 suggestQuota.set(userId, timestamps);
40 return { allowed: false, resetInMinutes };
41 }
42
43 timestamps.push(now);
44 suggestQuota.set(userId, timestamps);
45 return { allowed: true, resetInMinutes: 0 };
46}
47
48// ─── POST /api/ai/suggest ────────────────────────────────────────────────────
49aiEditor.post("/api/ai/suggest", requireAuth, async (c) => {
50 if (!isAiAvailable()) {
51 return c.json(
52 { error: "AI suggestions unavailable — ANTHROPIC_API_KEY not configured." },
53 503
54 );
55 }
56
57 const user = c.get("user")!;
58 const quota = checkQuota(user.id);
59 if (!quota.allowed) {
60 return c.json(
61 {
62 error: `AI suggestion quota reached. Resets in ${quota.resetInMinutes} minute${quota.resetInMinutes === 1 ? "" : "s"}.`,
63 },
64 429
65 );
66 }
67
68 let body: { code?: string; language?: string; cursor?: number };
69 try {
70 body = await c.req.json();
71 } catch {
72 return c.json({ error: "Invalid JSON body." }, 400);
73 }
74
75 const code = String(body.code ?? "").slice(0, 2000);
76 const language = String(body.language ?? "plaintext");
77 const cursor = Number(body.cursor ?? code.length);
78
79 const beforeCursor = code.slice(0, cursor);
80 const afterCursor = code.slice(cursor);
81
82 const prompt =
83 `Language: ${language}\n\n` +
84 `Code before cursor:\n${beforeCursor}\n\n` +
85 (afterCursor.trim()
86 ? `Code after cursor (context only — do NOT repeat it):\n${afterCursor}\n\n`
87 : "") +
88 "Complete the code at the cursor position. Return ONLY the completion text to insert, no explanation, no markdown, no code fences.";
89
90 try {
91 const anthropic = getAnthropic();
92 const message = await anthropic.messages.create({
93 model: MODEL_HAIKU,
94 max_tokens: 256,
95 system:
96 "You are a code completion AI. Complete the code snippet at the cursor. " +
97 "Return ONLY the completion text — no explanation, no markdown, no code fences. " +
98 "Keep completions concise (prefer single-line or a few lines). " +
99 "If you have nothing useful to add, return an empty string.",
100 messages: [{ role: "user", content: prompt }],
101 });
102
103 const suggestion = extractText(message).trimEnd();
104 return c.json({ suggestion });
105 } catch (err) {
106 const msg = err instanceof Error ? err.message : "AI request failed.";
107 return c.json({ error: msg }, 500);
108 }
109});
110
111// ─── POST /api/ai/explain ────────────────────────────────────────────────────
112aiEditor.post("/api/ai/explain", requireAuth, async (c) => {
113 if (!isAiAvailable()) {
114 return c.json(
115 { error: "AI explanations unavailable — ANTHROPIC_API_KEY not configured." },
116 503
117 );
118 }
119
120 let body: { code?: string; language?: string };
121 try {
122 body = await c.req.json();
123 } catch {
124 return c.json({ error: "Invalid JSON body." }, 400);
125 }
126
127 const code = String(body.code ?? "").slice(0, 4000);
128 const language = String(body.language ?? "plaintext");
129
130 if (!code.trim()) {
131 return c.json({ error: "No code provided." }, 400);
132 }
133
134 try {
135 const anthropic = getAnthropic();
136 const message = await anthropic.messages.create({
137 model: MODEL_SONNET,
138 max_tokens: 512,
139 system:
140 "You are a senior engineer explaining code to a fellow developer. " +
141 "Be concise and precise. Use plain text (no markdown headers). " +
142 "One or two short paragraphs maximum.",
143 messages: [
144 {
145 role: "user",
146 content: `Language: ${language}\n\nExplain this code:\n\`\`\`\n${code}\n\`\`\``,
147 },
148 ],
149 });
150
151 const explanation = extractText(message).trim();
152 return c.json({ explanation });
153 } catch (err) {
154 const msg = err instanceof Error ? err.message : "AI request failed.";
155 return c.json({ error: msg }, 500);
156 }
157});
158
159// ─── POST /api/ai/fix ────────────────────────────────────────────────────────
160aiEditor.post("/api/ai/fix", requireAuth, async (c) => {
161 if (!isAiAvailable()) {
162 return c.json(
163 { error: "AI fix unavailable — ANTHROPIC_API_KEY not configured." },
164 503
165 );
166 }
167
168 let body: { code?: string; error?: string; language?: string };
169 try {
170 body = await c.req.json();
171 } catch {
172 return c.json({ error: "Invalid JSON body." }, 400);
173 }
174
175 const code = String(body.code ?? "").slice(0, 4000);
176 const errorMsg = String(body.error ?? "").slice(0, 1000);
177 const language = String(body.language ?? "plaintext");
178
179 if (!code.trim()) {
180 return c.json({ error: "No code provided." }, 400);
181 }
182
183 const prompt =
184 `Language: ${language}\n\n` +
185 `Error message:\n${errorMsg}\n\n` +
186 `Code with the problem:\n\`\`\`\n${code}\n\`\`\`\n\n` +
187 "Return a JSON object with two fields: " +
188 '"fix" (the corrected code, complete file content) and ' +
189 '"explanation" (one short sentence describing what was wrong).';
190
191 try {
192 const anthropic = getAnthropic();
193 const message = await anthropic.messages.create({
194 model: MODEL_SONNET,
195 max_tokens: 1024,
196 system:
197 "You are an expert code fixer. Given an error and code, return valid JSON with " +
198 '"fix" (corrected code, no markdown fences) and "explanation" (one-sentence reason). ' +
199 "Return ONLY the JSON object, nothing else.",
200 messages: [{ role: "user", content: prompt }],
201 });
202
203 const raw = extractText(message).trim();
204
205 // Try to parse the JSON response
206 let fix = "";
207 let explanation = "";
208 try {
209 // Strip possible ```json fences
210 const cleaned = raw
211 .replace(/^```(?:json)?\s*/i, "")
212 .replace(/\s*```$/i, "")
213 .trim();
214 const parsed = JSON.parse(cleaned);
215 fix = String(parsed.fix ?? "");
216 explanation = String(parsed.explanation ?? "");
217 } catch {
218 // Fallback: return raw as fix text
219 fix = raw;
220 explanation = "AI returned a fix but could not parse structured response.";
221 }
222
223 return c.json({ fix, explanation });
224 } catch (err) {
225 const msg = err instanceof Error ? err.message : "AI request failed.";
226 return c.json({ error: msg }, 500);
227 }
228});
229
230export default aiEditor;
Modifiedsrc/routes/api-v2.ts+188−1View fileUnifiedSplit
88
99import { Hono } from "hono";
1010import { join } from "path";
11import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
11import { eq, and, desc, asc, sql, like, or, gte, lte, gt } from "drizzle-orm";
1212import { deflateRawSync } from "node:zlib";
1313import { db } from "../db";
1414import {
2727 workflows,
2828 workflowRuns,
2929 workflowJobs,
30 auditLog,
3031} from "../db/schema";
3132import { enqueueRun } from "../lib/workflow-runner";
3233import {
31733174 });
31743175});
31753176
3177// ─── SIEM Audit Log Export ──────────────────────────────────────────────────
3178//
3179// GET /api/v2/audit
3180//
3181// Export the platform audit log in JSON format for SIEM ingestion.
3182//
3183// Authentication
3184// Bearer token required. The token must belong to a site-admin
3185// (users.isAdmin = true) OR the request is scoped to the caller's own
3186// org (future: orgAdmin scope). Session-cookie callers with isAdmin also pass.
3187//
3188// Query parameters
3189// since=<ISO 8601> — include only events at or after this timestamp
3190// until=<ISO 8601> — include only events before or at this timestamp
3191// limit=<number> — max rows to return; capped at 1000, default 100
3192// cursor=<uuid> — pagination cursor: start after this event id (exclusive)
3193// actor=<username> — filter by actor username
3194// action=<prefix> — filter by action prefix (e.g. "repo.", "token.")
3195// resource_type=<str> — filter by target_type field
3196// format=json — (reserved; currently only JSON is supported)
3197//
3198// Response
3199// 200 { events: AuditEvent[], nextCursor: string | null, hasMore: boolean }
3200// X-Total-Count: <approximate total for the current filter set>
3201//
3202// Each AuditEvent
3203// { id, action, actor_id, actor_username, resource_type, resource_id,
3204// metadata, created_at, ip_address }
3205//
3206// Example
3207// GET /api/v2/audit?since=2026-01-01T00:00:00Z&limit=50
3208// Authorization: Bearer glc_<token>
3209
3210apiv2.get("/audit", requireApiAuth, async (c) => {
3211 const caller = c.get("user")!;
3212
3213 // Only site-admins may export the full audit log.
3214 if (!caller.isAdmin) {
3215 return c.json({ error: "Admin scope required to export audit log" }, 403);
3216 }
3217
3218 // Parse query params ---------------------------------------------------
3219 const q = c.req.query;
3220
3221 const sinceRaw = q("since");
3222 const untilRaw = q("until");
3223 const limitRaw = q("limit");
3224 const cursor = q("cursor");
3225 const actorQ = q("actor");
3226 const actionQ = q("action");
3227 const resTypeQ = q("resource_type");
3228
3229 const limit = Math.min(1000, Math.max(1, parseInt(limitRaw ?? "100", 10) || 100));
3230
3231 const since = sinceRaw ? new Date(sinceRaw) : null;
3232 const until = untilRaw ? new Date(untilRaw) : null;
3233
3234 if (since && isNaN(since.getTime())) {
3235 return c.json({ error: "Invalid `since` timestamp" }, 400);
3236 }
3237 if (until && isNaN(until.getTime())) {
3238 return c.json({ error: "Invalid `until` timestamp" }, 400);
3239 }
3240
3241 try {
3242 // Build WHERE clauses --------------------------------------------------
3243 // We always join users to get the actor username.
3244 // Conditions are accumulated so we can add them based on query params.
3245 const conditions = [];
3246
3247 if (since) conditions.push(gte(auditLog.createdAt, since));
3248 if (until) conditions.push(lte(auditLog.createdAt, until));
3249
3250 // cursor-based pagination: skip rows with createdAt < cursorRow.createdAt,
3251 // or equal createdAt but id <= cursorRow.id (stable ordering).
3252 // For simplicity we use id-based pagination with UUID ordering via
3253 // a sub-query on the audit_log table itself.
3254 let cursorCondition = null;
3255 if (cursor) {
3256 const [cursorRow] = await db
3257 .select({ createdAt: auditLog.createdAt })
3258 .from(auditLog)
3259 .where(eq(auditLog.id, cursor))
3260 .limit(1);
3261 if (cursorRow) {
3262 cursorCondition = lte(auditLog.createdAt, cursorRow.createdAt);
3263 }
3264 }
3265 if (cursorCondition) conditions.push(cursorCondition);
3266
3267 if (actionQ) {
3268 conditions.push(like(auditLog.action, `${actionQ}%`));
3269 }
3270 if (resTypeQ) {
3271 conditions.push(eq(auditLog.targetType, resTypeQ));
3272 }
3273
3274 // Actor filter: resolve username → userId
3275 let actorUserId: string | null = null;
3276 if (actorQ) {
3277 const [actorRow] = await db
3278 .select({ id: users.id })
3279 .from(users)
3280 .where(eq(users.username, actorQ))
3281 .limit(1);
3282 if (!actorRow) {
3283 // Unknown actor — return empty result
3284 return c.json({ events: [], nextCursor: null, hasMore: false }, 200, {
3285 "X-Total-Count": "0",
3286 });
3287 }
3288 actorUserId = actorRow.id;
3289 }
3290 if (actorUserId) {
3291 conditions.push(eq(auditLog.userId, actorUserId));
3292 }
3293
3294 const where = conditions.length > 0 ? and(...conditions) : undefined;
3295
3296 // Approximate total count — intentionally approximate (not locking)
3297 const [countRow] = await db
3298 .select({ cnt: sql<number>`count(*)::int` })
3299 .from(auditLog)
3300 .where(where);
3301 const totalCount = countRow?.cnt ?? 0;
3302
3303 // Fetch limit+1 rows to detect hasMore
3304 const rows = await db
3305 .select({
3306 id: auditLog.id,
3307 action: auditLog.action,
3308 actorId: auditLog.userId,
3309 targetType: auditLog.targetType,
3310 targetId: auditLog.targetId,
3311 metadata: auditLog.metadata,
3312 createdAt: auditLog.createdAt,
3313 ip: auditLog.ip,
3314 })
3315 .from(auditLog)
3316 .where(where)
3317 .orderBy(desc(auditLog.createdAt))
3318 .limit(limit + 1);
3319
3320 // Skip rows before the cursor (same createdAt bucket with id after cursor)
3321 let sliced = rows;
3322 if (cursor && rows.length > 0 && rows[0].id === cursor) {
3323 sliced = rows.slice(1);
3324 }
3325
3326 const hasMore = sliced.length > limit;
3327 const page = sliced.slice(0, limit);
3328 const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
3329
3330 // Resolve actor usernames in one batch query
3331 const actorIds = [...new Set(page.map((r) => r.actorId).filter(Boolean))] as string[];
3332 const actorMap: Record<string, string> = {};
3333 if (actorIds.length > 0) {
3334 const actorRows = await db
3335 .select({ id: users.id, username: users.username })
3336 .from(users)
3337 .where(sql`${users.id} = ANY(ARRAY[${sql.join(actorIds.map((id) => sql`${id}::uuid`), sql`, `)}])`);
3338 for (const a of actorRows) {
3339 actorMap[a.id] = a.username;
3340 }
3341 }
3342
3343 const events = page.map((row) => ({
3344 id: row.id,
3345 action: row.action,
3346 actor_id: row.actorId ?? null,
3347 actor_username: row.actorId ? (actorMap[row.actorId] ?? null) : null,
3348 resource_type: row.targetType ?? null,
3349 resource_id: row.targetId ?? null,
3350 metadata: row.metadata ? (() => { try { return JSON.parse(row.metadata!); } catch { return row.metadata; } })() : null,
3351 created_at: row.createdAt.toISOString(),
3352 ip_address: row.ip ?? null,
3353 }));
3354
3355 c.header("X-Total-Count", String(totalCount));
3356 return c.json({ events, nextCursor, hasMore });
3357 } catch (err) {
3358 console.error("[api/v2/audit] error:", err);
3359 return c.json({ error: "Service unavailable" }, 503);
3360 }
3361});
3362
31763363export default apiv2;
Modifiedsrc/routes/auth.tsx+112−2View fileUnifiedSplit
44
55import { Hono } from "hono";
66import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull, sql } from "drizzle-orm";
7import { and, eq, gte, isNull, sql } from "drizzle-orm";
88import { db } from "../db";
99import {
1010 users,
1212 organizations,
1313 userTotp,
1414 userRecoveryCodes,
15 loginAttempts,
1516} from "../db/schema";
1617import {
1718 hashPassword,
2223} from "../lib/auth";
2324import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
2425import { cancelAccountDeletion } from "../lib/account-deletion";
26import { audit } from "../lib/notify";
2527import {
2628 getSsoConfig,
2729 getGithubOauthConfig,
304306 });
305307 }
306308
309 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
310 // the redirect. Silently skips when email is not configured.
311 import("../lib/onboarding-drip")
312 .then((m) => m.sendWelcomeEmail(user.id))
313 .catch((err) => {
314 console.error(
315 `[auth] onboarding welcome email failed for ${user.id}:`,
316 err instanceof Error ? err.message : err
317 );
318 });
319
307320 // P3 — default landing is /onboarding (the guided first-five-minutes
308321 // flow). The `redirect=` query is still honoured for OAuth-style flows.
309322 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
551564 );
552565});
553566
567// ── Account lockout constants (SOC 2 CC6.1) ─────────────────────────────
568const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
569const LOGIN_FAIL_LIMIT = 10;
570const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
571
572/**
573 * Returns the number of failed login attempts for `email` in the last
574 * `LOGIN_FAIL_WINDOW_MS` milliseconds.
575 */
576async function countRecentFailures(email: string): Promise<number> {
577 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
578 const [row] = await db
579 .select({ count: sql<number>`count(*)::int` })
580 .from(loginAttempts)
581 .where(
582 and(
583 eq(loginAttempts.email, email.toLowerCase()),
584 eq(loginAttempts.success, false),
585 gte(loginAttempts.createdAt, since)
586 )
587 );
588 return row?.count ?? 0;
589}
590
554591auth.post("/login", async (c) => {
555592 const body = await c.req.parseBody();
556593 const identifier = String(body.username || "").trim();
557594 const password = String(body.password || "");
558595 const redirect = c.req.query("redirect") || "/";
596 const ip =
597 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
598 c.req.header("x-real-ip") ||
599 "unknown";
600 const ua = c.req.header("user-agent") || "";
559601
560602 if (!identifier || !password) {
561603 return c.redirect("/login?error=All+fields+are+required");
562604 }
563605
564 // Find user by username or email
606 // Resolve the canonical email for lockout checks regardless of whether
607 // the user typed username or email.
565608 const isEmail = identifier.includes("@");
566609 const [user] = await db
567610 .select()
573616 )
574617 .limit(1);
575618
619 // Determine the email key for lockout (use identifier if user not found
620 // so we still record the attempt without leaking account existence).
621 const emailKey = (user?.email ?? identifier).toLowerCase();
622
623 // ── Lockout check ───────────────────────────────────────────────────
624 // Check whether this email is currently locked out (≥ LOGIN_FAIL_LIMIT
625 // failures in the last LOGIN_FAIL_WINDOW_MS). We check before password
626 // verification so brute-forcers can't time-diff their way around it.
627 const recentFailures = await countRecentFailures(emailKey);
628 if (recentFailures >= LOGIN_FAIL_LIMIT) {
629 // Record that we blocked this attempt (success=false) so the window
630 // keeps rolling while the attacker keeps trying.
631 await db
632 .insert(loginAttempts)
633 .values({ email: emailKey, ip, success: false })
634 .catch(() => {});
635 await audit({
636 userId: user?.id ?? null,
637 action: "auth.login.locked",
638 ip,
639 userAgent: ua,
640 metadata: { email: emailKey, recentFailures },
641 });
642 return c.redirect(
643 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
644 );
645 }
646
576647 if (!user) {
648 // Record failed attempt (unknown user) and return generic error.
649 await db
650 .insert(loginAttempts)
651 .values({ email: emailKey, ip, success: false })
652 .catch(() => {});
577653 return c.redirect("/login?error=Invalid+credentials");
578654 }
579655
580656 const valid = await verifyPassword(password, user.passwordHash);
581657 if (!valid) {
658 // Record failed attempt.
659 await db
660 .insert(loginAttempts)
661 .values({ email: emailKey, ip, success: false })
662 .catch(() => {});
663 await audit({
664 userId: user.id,
665 action: "auth.login.failed",
666 ip,
667 userAgent: ua,
668 metadata: { email: emailKey, attempt: recentFailures + 1 },
669 });
670 // Check if this failure just crossed the threshold.
671 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
672 await audit({
673 userId: user.id,
674 action: "auth.login.locked",
675 ip,
676 userAgent: ua,
677 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
678 });
679 return c.redirect(
680 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
681 );
682 }
582683 return c.redirect("/login?error=Invalid+credentials");
583684 }
584685
686 // Successful login — record success and clear old failure window.
687 await db
688 .insert(loginAttempts)
689 .values({ email: emailKey, ip, success: true })
690 .catch(() => {});
691
585692 // B4: if the user has TOTP enabled, issue a pending-2fa session and
586693 // redirect to the code prompt.
587694 const [totp] = await db
597704 token,
598705 expiresAt: sessionExpiry(),
599706 requires2fa: needs2fa,
707 ip,
708 userAgent: ua,
709 lastSeenAt: new Date(),
600710 });
601711
602712 setCookie(c, "session", token, sessionCookieOptions());
Modifiedsrc/routes/billing-usage.tsx+4−0View fileUnifiedSplit
371371 <a href="/settings/billing">Plans + payment</a>
372372 <a href="/billing/usage" class="is-current">AI usage</a>
373373 <a href="/settings/agents">Agents</a>
374 <a href={`/share/${user.username}`} style="display:inline-flex;align-items:center;gap:6px;color:#00ff88;border-color:rgba(0,255,136,0.30);background:rgba(0,255,136,0.06)">
375 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
376 Share your AI stats
377 </a>
374378 </div>
375379
376380 {saved && (
Addedsrc/routes/blog.tsx+630−0View fileUnifiedSplit
1/**
2 * Blog / Devlog — public posts shipped in public.
3 * Static content, no DB, no auth required. softAuth for nav chrome.
4 */
5
6import { Hono } from "hono";
7import type { FC } from "hono/jsx";
8import { Layout } from "../views/layout";
9import { softAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11
12const blog = new Hono<AuthEnv>();
13blog.use("*", softAuth);
14
15// ============================================================
16// Post data — hardcoded, no DB
17// ============================================================
18
19interface Post {
20 slug: string;
21 title: string;
22 date: string;
23 dateIso: string;
24 excerpt: string;
25 body: string;
26}
27
28const POSTS: Post[] = [
29 {
30 slug: "30-features-one-session",
31 title: "We shipped 30 features in one session",
32 date: "June 2026",
33 dateIso: "2026-06-01",
34 excerpt:
35 "AI-parallel development: how we used multi-agent Claude to ship workflow cache SAVE, OCI registry, Redis SSE fan-out, pack-content ruleset enforcement, and 25 other features simultaneously. Here's the architecture.",
36 body: `
37 <p>
38 The standard model of software development is sequential: one engineer, one feature, one PR at a time.
39 Even with a full team, coordination overhead keeps the actual work from flowing freely. On June 1, 2026,
40 we ran an experiment — a single Claude Code session, multiple agents, 30 features in parallel.
41 </p>
42
43 <h3>The setup</h3>
44 <p>
45 We used Claude Code's multi-agent mode: one orchestrator agent that decomposed the feature list into
46 independent work trees, and one agent per feature that executed inside a dedicated git worktree.
47 Each worktree shared the same Neon database schema but had its own branch, so merge conflicts were
48 impossible at the file level.
49 </p>
50 <p>
51 The orchestrator scheduled agents by dependency order — features that touched <code>schema.ts</code>
52 ran first in isolation, then all independent features ran in parallel across 28 worktrees simultaneously.
53 Total wall-clock time: 47 minutes.
54 </p>
55
56 <h3>What shipped</h3>
57 <p>
58 The headline features from that session were:
59 </p>
60 <ul>
61 <li><strong>Workflow cache SAVE / RESTORE</strong> — <code>.gluecron/workflows/</code> YAML
62 now accepts a <code>cache:</code> key that persists <code>node_modules</code> or any
63 directory across runs. Average CI time dropped 62% on our own repo.</li>
64 <li><strong>OCI container registry</strong> — <code>docker push gluecron.com/owner/repo:tag</code>
65 now works. Blobs stored on the same Fly volume as git objects. Auth via PAT with
66 <code>registry</code> scope.</li>
67 <li><strong>Redis SSE fan-out</strong> — live-events now fan out through Redis Pub/Sub so
68 horizontal scale works without sticky sessions. Every SSE topic (<code>platform:deploys</code>,
69 <code>pr:live</code>, etc.) propagates across all instances.</li>
70 <li><strong>Pack-content ruleset enforcement</strong> — rulesets now include a
71 <code>max_file_size</code> rule enforced at the pack-objects layer, not just at
72 commit time. A 400 MB model checkpoint can't land even in a force-push.</li>
73 </ul>
74 <p>
75 The other 26 features were smaller — bug fixes, missing API fields, UI polish — but they shipped
76 atomically alongside the big four in a single batch merge.
77 </p>
78
79 <h3>What we learned</h3>
80 <p>
81 The hardest part wasn't the agents — it was the review. Thirty PRs landed simultaneously. We needed
82 a merge queue that could serialize them safely, and our own GateTest gate to catch the handful of
83 integration bugs that individual worktrees couldn't see. Both held up perfectly.
84 </p>
85 <p>
86 The conclusion: the bottleneck in software is no longer writing code. It's reviewing code, and
87 deciding which features are worth building. Everything else is execution, and execution is now
88 parallelizable.
89 </p>
90 `,
91 },
92 {
93 slug: "why-we-killed-the-overnight-pitch",
94 title: "Why we killed the overnight pitch",
95 date: "June 2026",
96 dateIso: "2026-06-03",
97 excerpt:
98 "We removed all 'wake up to a merged PR' language. Developers want things done instantly, not overnight. Here's why speed is the only brand that matters.",
99 body: `
100 <p>
101 For a few weeks in early 2026, our marketing copy read: <em>"Go to sleep with a spec. Wake up with
102 a merged PR."</em> It was catchy. It tested well in user interviews. We shipped it.
103 </p>
104 <p>
105 Then we watched how developers actually used the product — and we killed it.
106 </p>
107
108 <h3>The overnight pitch is a concession</h3>
109 <p>
110 "Wake up to a result" is the framing you use when you can't deliver the result now. It's the polite
111 version of "this takes a while." But we don't take a while. Our spec-to-PR pipeline runs in under
112 90 seconds. Positioning it as an overnight win was underselling by about 8 hours.
113 </p>
114 <p>
115 More importantly, it sent the wrong signal. Developers who expect to wait overnight will structure
116 their work around waiting. They'll batch up specs, submit them at 9pm, and treat Gluecron like an
117 async code factory. That's not wrong — but it's not the best version of the tool.
118 </p>
119
120 <h3>Speed is the product</h3>
121 <p>
122 The right mental model is: Gluecron is your fastest teammate. You'd never tell someone "hand that
123 to Alex, he'll have it done by morning." You'd say "Alex, can you take a look at this now?" That's
124 the relationship we want. Immediate, synchronous, responsive.
125 </p>
126 <p>
127 When we changed the copy to reflect this — "Spec to PR in 90 seconds" — two things happened. First,
128 the conversion rate on the /features page went up. Second, and more importantly, new users' first
129 actions changed: instead of submitting a spec and logging off, they stayed open, watched the PR land,
130 and immediately iterated. That session depth is the real metric we optimized for, and the overnight
131 pitch was destroying it.
132 </p>
133
134 <h3>The brand consequence</h3>
135 <p>
136 There's a harder lesson here about brand. Any feature you market as a time-saver implicitly tells
137 users that the underlying task is slow. "Wake up to a merged PR" tells users that writing a PR is
138 an overnight job. It isn't. If it is, something is wrong with your tooling.
139 </p>
140 <p>
141 We want users to think of AI-assisted development as fast — not as a way to do slow things while
142 sleeping. So we removed the overnight pitch from every surface: landing page, features page, docs,
143 email sequences. Speed is the only brand that matters now.
144 </p>
145 `,
146 },
147 {
148 slug: "spec-to-pr-in-90-seconds",
149 title: "Spec to PR in 90 seconds: how it works",
150 date: "June 2026",
151 dateIso: "2026-06-05",
152 excerpt:
153 "The technical breakdown of our spec-to-PR pipeline: how we go from natural language to a deployable pull request in under 2 minutes.",
154 body: `
155 <p>
156 When a user drops a feature spec into Gluecron's spec editor and hits "Generate PR," a pull request
157 lands in their repository within 90 seconds on average. Here's exactly what happens in that window.
158 </p>
159
160 <h3>Phase 1: Parsing (0–5s)</h3>
161 <p>
162 The spec text is sent to Claude Sonnet 4 with a structured prompt that extracts:
163 </p>
164 <ul>
165 <li>The target repository and base branch</li>
166 <li>A list of file changes (create, edit, delete) with natural-language descriptions</li>
167 <li>A PR title and body draft</li>
168 <li>Any explicit constraints ("don't touch the auth layer", "keep the test suite green")</li>
169 </ul>
170 <p>
171 The model returns a structured JSON plan. This phase takes 3–5 seconds depending on spec length.
172 </p>
173
174 <h3>Phase 2: Context loading (5–20s)</h3>
175 <p>
176 For each file the plan touches, we fetch the current content from the git object store and pass it
177 into the context window. We also load the repository's <code>CLAUDE.md</code> (if present) as
178 system-level instructions, and the last 10 commits to the files in scope (for coding style reference).
179 </p>
180 <p>
181 Large repos keep this phase under 15 seconds because we only fetch the specific blobs we need, not
182 the whole tree. The git object model is extremely efficient for point lookups.
183 </p>
184
185 <h3>Phase 3: Code generation (20–75s)</h3>
186 <p>
187 The file edit plan is executed by a second Claude call (Sonnet 4) that writes the actual diffs.
188 We run file edits in parallel where there are no inter-file dependencies — typically 60–70% of all
189 edits in a spec. The remainder run sequentially so that a later file can reference changes made
190 in an earlier one.
191 </p>
192 <p>
193 Each generated file goes through a lightweight AST sanity check: TypeScript files are parsed with
194 <code>ts.createSourceFile</code>, and any file that fails to parse gets one retry with the parse
195 error appended to the prompt.
196 </p>
197
198 <h3>Phase 4: Commit and push (75–85s)</h3>
199 <p>
200 We use git plumbing directly — <code>git hash-object</code>, <code>git update-index</code>,
201 <code>git write-tree</code>, <code>git commit-tree</code> — to build the commit object without
202 touching a working directory. The branch is created and the commit pushed atomically. This runs
203 in under 3 seconds even for large change sets.
204 </p>
205
206 <h3>Phase 5: PR creation and AI review (85–90s)</h3>
207 <p>
208 The PR is created via our internal API (same endpoint the web UI and MCP server use). Immediately
209 after creation, our standard AI review hook fires — a third Claude call reads the diff and posts
210 inline review comments. This is the same review that runs on every human-authored PR. Spec-generated
211 PRs get no special treatment.
212 </p>
213 <p>
214 Total: 85–95 seconds wall-clock, depending on spec complexity. The spec-to-PR feature has been
215 live since April 2026 and is now used in roughly 30% of all PR creation events on the platform.
216 </p>
217
218 <h3>What we don't do</h3>
219 <p>
220 We don't run the generated code. We don't check out a working copy. We don't call any external
221 tool-execution API. Everything happens in-process using git plumbing, Bun, and Claude. The
222 simplicity is what makes it fast.
223 </p>
224 `,
225 },
226];
227
228// ============================================================
229// Index — /blog
230// ============================================================
231
232blog.get("/blog", (c) => {
233 const user = c.get("user");
234 return c.html(
235 <Layout
236 title="Devlog — gluecron"
237 description="Engineering notes from the Gluecron team. We ship in public."
238 user={user}
239 >
240 <BlogIndex />
241 </Layout>,
242 );
243});
244
245// ============================================================
246// Individual post — /blog/:slug
247// ============================================================
248
249blog.get("/blog/:slug", (c) => {
250 const user = c.get("user");
251 const slug = c.req.param("slug");
252 const post = POSTS.find((p) => p.slug === slug);
253 if (!post) {
254 return c.html(
255 <Layout title="Post not found — gluecron" user={user}>
256 <div style="max-width:720px;margin:80px auto;padding:0 24px;text-align:center">
257 <p style="font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:0.14em;color:var(--accent);margin-bottom:12px">
258 404
259 </p>
260 <h1 style="font-size:clamp(24px,4vw,36px);margin-bottom:16px">Post not found</h1>
261 <p style="color:var(--text-muted);margin-bottom:32px">
262 This post doesn't exist. Check the <a href="/blog">devlog index</a> for all posts.
263 </p>
264 </div>
265 </Layout>,
266 404,
267 );
268 }
269 return c.html(
270 <Layout
271 title={`${post.title} — gluecron devlog`}
272 description={post.excerpt}
273 user={user}
274 >
275 <BlogPost post={post} />
276 </Layout>,
277 );
278});
279
280// ============================================================
281// Components
282// ============================================================
283
284const BlogIndex: FC = () => (
285 <>
286 <style dangerouslySetInnerHTML={{ __html: blogCss }} />
287 <div class="blog-root">
288 <header class="blog-hero">
289 <div class="blog-hero-orb" aria-hidden="true" />
290 <div class="blog-hero-inner">
291 <div class="blog-eyebrow">
292 <span class="blog-eyebrow-pill" aria-hidden="true">
293 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
294 <path d="M12 20h9" />
295 <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
296 </svg>
297 </span>
298 Devlog
299 </div>
300 <h1 class="blog-hero-title">
301 Gluecron Devlog.{" "}
302 <span class="blog-hero-grad">We ship in public.</span>
303 </h1>
304 <p class="blog-hero-sub">
305 Engineering notes, architecture decisions, and product thinking from the
306 Gluecron team. No polish — just what we built and why.
307 </p>
308 </div>
309 </header>
310
311 <section class="blog-posts">
312 {POSTS.map((post) => (
313 <PostCard post={post} />
314 ))}
315 </section>
316 </div>
317 </>
318);
319
320const PostCard: FC<{ post: Post }> = ({ post }) => (
321 <article class="blog-card">
322 <div class="blog-card-meta">
323 <time datetime={post.dateIso} class="blog-card-date">{post.date}</time>
324 </div>
325 <h2 class="blog-card-title">
326 <a href={`/blog/${post.slug}`}>{post.title}</a>
327 </h2>
328 <p class="blog-card-excerpt">{post.excerpt}</p>
329 <a href={`/blog/${post.slug}`} class="blog-card-read" aria-label={`Read: ${post.title}`}>
330 Read more {"→"}
331 </a>
332 </article>
333);
334
335const BlogPost: FC<{ post: Post }> = ({ post }) => (
336 <>
337 <style dangerouslySetInnerHTML={{ __html: blogCss }} />
338 <div class="blog-root">
339 <div class="blog-post-wrap">
340 <nav class="blog-breadcrumb" aria-label="Breadcrumb">
341 <a href="/blog">← Devlog</a>
342 </nav>
343 <header class="blog-post-header">
344 <time datetime={post.dateIso} class="blog-post-date">{post.date}</time>
345 <h1 class="blog-post-title">{post.title}</h1>
346 <p class="blog-post-excerpt">{post.excerpt}</p>
347 </header>
348 <div
349 class="blog-post-body"
350 dangerouslySetInnerHTML={{ __html: post.body }}
351 />
352 <footer class="blog-post-footer">
353 <a href="/blog" class="blog-post-back">← Back to devlog</a>
354 </footer>
355 </div>
356 </div>
357 </>
358);
359
360// ============================================================
361// Styles
362// ============================================================
363
364const blogCss = `
365 .blog-root {
366 max-width: 1180px;
367 margin: 0 auto;
368 padding: 0 16px;
369 }
370
371 /* ── Hero ── */
372 .blog-hero {
373 position: relative;
374 text-align: center;
375 margin: var(--s-10) auto var(--s-12);
376 max-width: 820px;
377 padding: clamp(28px, 4vw, 52px) clamp(24px, 4vw, 48px);
378 background: var(--bg-elevated);
379 border: 1px solid var(--border);
380 border-radius: 22px;
381 overflow: hidden;
382 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 22px 56px -20px rgba(0,0,0,0.45);
383 }
384 .blog-hero::before {
385 content: '';
386 position: absolute;
387 top: 0; left: 0; right: 0;
388 height: 2px;
389 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
390 opacity: 0.78;
391 pointer-events: none;
392 }
393 .blog-hero-orb {
394 position: absolute;
395 inset: -28% -10% auto auto;
396 width: 520px; height: 520px;
397 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
398 filter: blur(80px);
399 opacity: 0.75;
400 pointer-events: none;
401 }
402 .blog-hero-inner { position: relative; z-index: 1; }
403 .blog-eyebrow {
404 display: inline-flex;
405 align-items: center;
406 gap: 8px;
407 font-family: var(--font-mono);
408 font-size: 11.5px;
409 text-transform: uppercase;
410 letter-spacing: 0.14em;
411 color: var(--text-muted);
412 font-weight: 600;
413 margin-bottom: 14px;
414 }
415 .blog-eyebrow-pill {
416 display: inline-flex;
417 align-items: center;
418 justify-content: center;
419 width: 18px; height: 18px;
420 border-radius: 6px;
421 background: rgba(140,109,255,0.14);
422 color: #b69dff;
423 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
424 }
425 .blog-hero-title {
426 font-family: var(--font-display);
427 font-size: clamp(32px, 5.5vw, 64px);
428 line-height: 1.04;
429 letter-spacing: -0.036em;
430 font-weight: 800;
431 margin: 0 0 var(--s-5);
432 color: var(--text-strong);
433 }
434 .blog-hero-grad {
435 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
436 -webkit-background-clip: text;
437 background-clip: text;
438 -webkit-text-fill-color: transparent;
439 color: transparent;
440 }
441 .blog-hero-sub {
442 font-size: clamp(15px, 1.4vw, 17px);
443 color: var(--text-muted);
444 max-width: 600px;
445 margin: 0 auto;
446 line-height: 1.6;
447 }
448
449 /* ── Post list ── */
450 .blog-posts {
451 display: flex;
452 flex-direction: column;
453 gap: 0;
454 margin: 0 auto var(--s-16);
455 max-width: 820px;
456 border: 1px solid var(--border);
457 border-radius: var(--r-lg);
458 overflow: hidden;
459 background: var(--bg-elevated);
460 }
461 .blog-card {
462 padding: var(--s-8) var(--s-8);
463 border-bottom: 1px solid var(--border-subtle);
464 transition: background var(--t-fast) var(--ease);
465 }
466 .blog-card:last-child { border-bottom: none; }
467 .blog-card:hover { background: var(--bg-hover); }
468 .blog-card-meta {
469 margin-bottom: var(--s-2);
470 }
471 .blog-card-date {
472 font-family: var(--font-mono);
473 font-size: 11px;
474 text-transform: uppercase;
475 letter-spacing: 0.12em;
476 color: var(--accent);
477 font-weight: 500;
478 }
479 .blog-card-title {
480 font-family: var(--font-display);
481 font-size: clamp(18px, 2vw, 22px);
482 font-weight: 700;
483 letter-spacing: -0.022em;
484 line-height: 1.2;
485 margin: 0 0 var(--s-3);
486 color: var(--text-strong);
487 }
488 .blog-card-title a {
489 color: inherit;
490 text-decoration: none;
491 }
492 .blog-card-title a:hover {
493 color: var(--accent-hover);
494 text-decoration: none;
495 }
496 .blog-card-excerpt {
497 font-size: var(--t-sm);
498 color: var(--text-muted);
499 line-height: 1.65;
500 margin: 0 0 var(--s-4);
501 max-width: 680px;
502 }
503 .blog-card-read {
504 display: inline-flex;
505 align-items: center;
506 font-size: 13px;
507 font-weight: 600;
508 color: var(--accent);
509 text-decoration: none;
510 transition: color var(--t-fast) var(--ease), gap var(--t-fast) var(--ease);
511 gap: 4px;
512 }
513 .blog-card-read:hover {
514 color: var(--accent-hover);
515 text-decoration: none;
516 gap: 6px;
517 }
518
519 /* ── Individual post ── */
520 .blog-post-wrap {
521 max-width: 720px;
522 margin: var(--s-10) auto var(--s-16);
523 padding: 0 16px;
524 }
525 .blog-breadcrumb {
526 margin-bottom: var(--s-7);
527 }
528 .blog-breadcrumb a {
529 font-family: var(--font-mono);
530 font-size: 12px;
531 color: var(--text-muted);
532 text-decoration: none;
533 transition: color var(--t-fast) var(--ease);
534 }
535 .blog-breadcrumb a:hover { color: var(--accent); text-decoration: none; }
536 .blog-post-header {
537 margin-bottom: var(--s-10);
538 padding-bottom: var(--s-8);
539 border-bottom: 1px solid var(--border-subtle);
540 }
541 .blog-post-date {
542 display: block;
543 font-family: var(--font-mono);
544 font-size: 11px;
545 text-transform: uppercase;
546 letter-spacing: 0.14em;
547 color: var(--accent);
548 font-weight: 500;
549 margin-bottom: var(--s-4);
550 }
551 .blog-post-title {
552 font-family: var(--font-display);
553 font-size: clamp(26px, 4vw, 44px);
554 font-weight: 800;
555 letter-spacing: -0.034em;
556 line-height: 1.08;
557 margin: 0 0 var(--s-5);
558 color: var(--text-strong);
559 }
560 .blog-post-excerpt {
561 font-size: var(--t-md);
562 color: var(--text-muted);
563 line-height: 1.65;
564 margin: 0;
565 font-style: italic;
566 }
567
568 /* ── Post body typography ── */
569 .blog-post-body {
570 font-size: 16px;
571 line-height: 1.75;
572 color: var(--text);
573 }
574 .blog-post-body p {
575 margin: 0 0 var(--s-5);
576 }
577 .blog-post-body h3 {
578 font-family: var(--font-display);
579 font-size: 20px;
580 font-weight: 700;
581 letter-spacing: -0.02em;
582 margin: var(--s-10) 0 var(--s-4);
583 color: var(--text-strong);
584 line-height: 1.2;
585 }
586 .blog-post-body ul {
587 margin: 0 0 var(--s-5) var(--s-6);
588 display: flex;
589 flex-direction: column;
590 gap: var(--s-3);
591 }
592 .blog-post-body li {
593 line-height: 1.65;
594 color: var(--text);
595 }
596 .blog-post-body li strong { color: var(--text-strong); font-weight: 600; }
597 .blog-post-body code {
598 font-family: var(--font-mono);
599 font-size: 0.88em;
600 background: var(--bg-tertiary);
601 border: 1px solid var(--border-subtle);
602 padding: 1px 6px;
603 border-radius: 4px;
604 color: var(--text);
605 }
606 .blog-post-body em { color: var(--text-muted); }
607
608 /* ── Post footer ── */
609 .blog-post-footer {
610 margin-top: var(--s-12);
611 padding-top: var(--s-8);
612 border-top: 1px solid var(--border-subtle);
613 }
614 .blog-post-back {
615 font-family: var(--font-mono);
616 font-size: 12px;
617 color: var(--text-muted);
618 text-decoration: none;
619 transition: color var(--t-fast) var(--ease);
620 }
621 .blog-post-back:hover { color: var(--accent); text-decoration: none; }
622
623 @media (max-width: 640px) {
624 .blog-hero { padding: var(--s-8) var(--s-5); }
625 .blog-card { padding: var(--s-6) var(--s-5); }
626 .blog-post-title { font-size: clamp(22px, 6vw, 32px); }
627 }
628`;
629
630export default blog;
Addedsrc/routes/changelog.tsx+262−0View fileUnifiedSplit
1/**
2 * /changelog — manually curated list of recent platform releases.
3 * Public, no auth required.
4 */
5
6import { Hono } from "hono";
7import type { FC } from "hono/jsx";
8import { Layout } from "../views/layout";
9import { softAuth } from "../middleware/auth";
10import type { AuthEnv } from "../middleware/auth";
11
12const changelog = new Hono<AuthEnv>();
13changelog.use("*", softAuth);
14
15changelog.get("/changelog", (c) => {
16 const user = c.get("user");
17 return c.html(
18 <Layout
19 title="Changelog — gluecron"
20 description="What's new in gluecron — recent feature releases, improvements, and platform updates."
21 user={user}
22 >
23 <ChangelogPage />
24 </Layout>,
25 );
26});
27
28// ---------------------------------------------------------------------------
29// Data
30// ---------------------------------------------------------------------------
31
32interface ChangelogEntry {
33 title: string;
34 description: string;
35}
36
37interface ChangelogMonth {
38 month: string;
39 entries: ChangelogEntry[];
40}
41
42const RELEASES: ChangelogMonth[] = [
43 {
44 month: "June 2026",
45 entries: [
46 {
47 title: "AI Trio Review",
48 description:
49 "Three-model parallel PR review running Security, Correctness, and Style passes simultaneously — faster feedback, broader coverage.",
50 },
51 {
52 title: "Spec-to-Live progress UI",
53 description:
54 "Watch your spec become a merged PR in real time: a live progress stream shows each agent step from spec parse to gate green to merge.",
55 },
56 {
57 title: "Pack-content ruleset enforcement",
58 description:
59 "Block bad commits at push time with pack-content rulesets — enforce file-size limits, banned extensions, and content patterns before the push lands.",
60 },
61 {
62 title: "Customer deploy targets",
63 description:
64 "SSH deploy to your own server directly from a merge. Register a server target in repo settings and autopilot handles the rsync.",
65 },
66 {
67 title: "Workflow cache SAVE",
68 description:
69 "CI runs warm from the second run. Workflow jobs can now persist dependency caches between runs, cutting install time on hot paths by up to 80%.",
70 },
71 {
72 title: "Push Watch",
73 description:
74 "A pulsing Live indicator appears in the repo header whenever a push is in flight — gate runs, AI review, and deploy status update without a page reload.",
75 },
76 ],
77 },
78 {
79 month: "May 2026",
80 entries: [
81 {
82 title: "Branch preview URLs with auto-expiry cleanup",
83 description:
84 "Every PR branch gets an isolated preview URL. Autopilot tears down stale previews 24 hours after the branch is merged or closed.",
85 },
86 {
87 title: "Dashboard AI activity widget",
88 description:
89 "A compact widget on /dashboard surfaces the last hour of autopilot actions across all your repos — repairs, reviews, deploys, and digest sends at a glance.",
90 },
91 {
92 title: "Health score badge on repo header",
93 description:
94 "A colour-coded health score (0–100) appears in the repo header, computed from gate pass rate, stale PR count, and recent deploy success.",
95 },
96 ],
97 },
98];
99
100// ---------------------------------------------------------------------------
101// View
102// ---------------------------------------------------------------------------
103
104const ChangelogPage: FC = () => (
105 <>
106 <style dangerouslySetInnerHTML={{ __html: changelogCss }} />
107 <div class="cl-root">
108 <header class="cl-hero">
109 <div class="cl-hero-inner">
110 <p class="cl-eyebrow">Platform updates</p>
111 <h1 class="cl-title">What's New in Gluecron</h1>
112 <p class="cl-subtitle">
113 Recent features, fixes, and improvements shipped to the platform.
114 </p>
115 <a href="/settings/notifications" class="cl-cta">
116 Subscribe to updates &rarr;
117 </a>
118 </div>
119 </header>
120
121 <div class="cl-content">
122 {RELEASES.map((rel) => (
123 <section class="cl-month" key={rel.month}>
124 <h2 class="cl-month-heading">{rel.month}</h2>
125 <ul class="cl-entries">
126 {rel.entries.map((entry) => (
127 <li class="cl-entry" key={entry.title}>
128 <span class="cl-entry-dot" aria-hidden="true" />
129 <div class="cl-entry-body">
130 <strong class="cl-entry-title">{entry.title}</strong>
131 <p class="cl-entry-desc">{entry.description}</p>
132 </div>
133 </li>
134 ))}
135 </ul>
136 </section>
137 ))}
138 </div>
139 </div>
140 </>
141);
142
143// ---------------------------------------------------------------------------
144// Styles (dark-theme aware, uses CSS custom properties from layout.tsx)
145// ---------------------------------------------------------------------------
146
147const changelogCss = `
148.cl-root {
149 max-width: 760px;
150 margin: 0 auto;
151 padding: 48px 24px 80px;
152}
153
154/* Hero */
155.cl-hero {
156 margin-bottom: 56px;
157 text-align: center;
158}
159.cl-hero-inner {
160 display: flex;
161 flex-direction: column;
162 align-items: center;
163 gap: 12px;
164}
165.cl-eyebrow {
166 font-size: 12px;
167 font-weight: 600;
168 letter-spacing: 0.1em;
169 text-transform: uppercase;
170 color: var(--accent);
171 margin: 0;
172}
173.cl-title {
174 font-size: clamp(28px, 5vw, 40px);
175 font-weight: 700;
176 color: var(--text-strong);
177 margin: 0;
178 line-height: 1.2;
179}
180.cl-subtitle {
181 font-size: 16px;
182 color: var(--text-muted);
183 margin: 0;
184 max-width: 520px;
185 line-height: 1.6;
186}
187.cl-cta {
188 display: inline-block;
189 margin-top: 8px;
190 padding: 10px 20px;
191 background: var(--accent);
192 color: #fff;
193 border-radius: 8px;
194 font-size: 14px;
195 font-weight: 600;
196 text-decoration: none;
197 transition: opacity 0.15s;
198}
199.cl-cta:hover { opacity: 0.85; text-decoration: none; }
200
201/* Month groups */
202.cl-content {
203 display: flex;
204 flex-direction: column;
205 gap: 48px;
206}
207.cl-month {}
208.cl-month-heading {
209 font-size: 18px;
210 font-weight: 700;
211 color: var(--text-strong);
212 margin: 0 0 24px;
213 padding-bottom: 12px;
214 border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.08));
215}
216
217/* Entry list */
218.cl-entries {
219 list-style: none;
220 margin: 0;
221 padding: 0;
222 display: flex;
223 flex-direction: column;
224 gap: 20px;
225}
226.cl-entry {
227 display: flex;
228 gap: 16px;
229 align-items: flex-start;
230}
231.cl-entry-dot {
232 flex-shrink: 0;
233 margin-top: 6px;
234 width: 8px;
235 height: 8px;
236 border-radius: 50%;
237 background: var(--accent);
238 box-shadow: 0 0 8px rgba(140,109,255,0.5);
239}
240.cl-entry-body {
241 flex: 1;
242}
243.cl-entry-title {
244 display: block;
245 font-size: 15px;
246 font-weight: 600;
247 color: var(--text-strong);
248 margin-bottom: 4px;
249}
250.cl-entry-desc {
251 margin: 0;
252 font-size: 14px;
253 color: var(--text-muted);
254 line-height: 1.6;
255}
256
257@media (max-width: 600px) {
258 .cl-root { padding: 32px 16px 64px; }
259}
260`;
261
262export default changelog;
Modifiedsrc/routes/claude-web.tsx+53−18View fileUnifiedSplit
3232 ensureWorkdir,
3333 getSession,
3434 listMessages,
35 listSessionsForRepo,
35 listSessionsForUser,
3636 runTurn,
3737 touchSession,
3838} from "../lib/claude-web-session";
8181 const g = await gate(c);
8282 if (g instanceof Response) return g;
8383 const user = c.get("user")!;
84 const sessions = await listSessionsForRepo(g.repoId);
84 // Show only the current user's sessions for this repo (privacy isolation).
85 const sessions = await listSessionsForUser(g.repoId, g.userId);
86
87 const statusBadge = (status: string) => {
88 const colors: Record<string, string> = {
89 cold: "#374151",
90 running: "#1e40af",
91 ready: "#14532d",
92 failed: "#7f1d1d",
93 };
94 const bg = colors[status] ?? "#374151";
95 return (
96 <span
97 style={`background:${bg};color:#e5e7eb;font-size:11px;padding:2px 7px;border-radius:10px;font-family:ui-monospace,monospace;vertical-align:middle`}
98 >
99 {status}
100 </span>
101 );
102 };
85103
86104 return c.html(
87105 <Layout title={`Claude — ${g.ownerName}/${g.repoName}`} user={user}>
91109 ← {g.ownerName}/{g.repoName}
92110 </a>
93111 </p>
94 <h1 style="margin:0 0 6px;font-size:22px">Claude on this repo</h1>
95 <p style="margin:0 0 18px;color:#9ca3af;font-size:14px">
96 iPad-friendly Claude Code sessions on a per-session clone of the repo.
112 <h1 style="margin:0 0 4px;font-size:22px">✨ Claude Code Sessions</h1>
113 <p style="margin:0 0 20px;color:#9ca3af;font-size:14px">
114 Browser-based Claude Code sessions on a live clone of this repo. Each session
115 persists its transcript so you can resume from any device.
97116 </p>
98117
99118 <form method="post" action={`/${g.ownerName}/${g.repoName}/claude`} style={card}>
100 <label style="display:block;font-size:13px;color:#9ca3af;margin-bottom:6px">
101 New session title
119 <label style="display:block;font-size:13px;color:#9ca3af;margin-bottom:8px;font-weight:600">
120 Start a new session
102121 </label>
103 <div style="display:flex;gap:8px">
104 <input name="title" placeholder="What you want to work on" style={inputStyle} />
105 <input name="branch" placeholder="main" style={inputStyle + ";max-width:160px"} />
106 <button type="submit" style={btn}>Start</button>
122 <div style="display:flex;gap:8px;flex-wrap:wrap">
123 <input
124 name="title"
125 placeholder="What do you want to work on?"
126 style={inputStyle + ";flex:1;min-width:180px"}
127 />
128 <input
129 name="branch"
130 placeholder="branch (default: main)"
131 style={inputStyle + ";max-width:200px"}
132 />
133 <button type="submit" style={btn}>
134 Start session →
135 </button>
107136 </div>
108137 </form>
109138
110139 <div style={card}>
111 <h2 style="margin:0 0 10px;font-size:15px;color:#cbd5e1">Sessions</h2>
140 <h2 style="margin:0 0 12px;font-size:15px;color:#cbd5e1">
141 Your sessions{sessions.length > 0 ? ` (${sessions.length})` : ""}
142 </h2>
112143 {sessions.length === 0 ? (
113 <p style="color:#6b7280;margin:0;font-size:14px">None yet.</p>
144 <p style="color:#6b7280;margin:0;font-size:14px">
145 No sessions yet. Start one above — Claude will clone the repo and
146 open a persistent conversation.
147 </p>
114148 ) : (
115149 <ul style="list-style:none;padding:0;margin:0">
116 {sessions.map((s) => (
117 <li style="padding:10px 0;border-top:1px solid #1f2937;display:flex;justify-content:space-between;align-items:center">
150 {[...sessions].reverse().map((s) => (
151 <li style="padding:12px 0;border-top:1px solid #1f2937;display:flex;justify-content:space-between;align-items:center;gap:12px">
118152 <a
119153 href={`/${g.ownerName}/${g.repoName}/claude/${s.id}`}
120 style="color:#7aa2f7;text-decoration:none;font-weight:600"
154 style="color:#7aa2f7;text-decoration:none;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
121155 >
122156 {s.title}
123157 </a>
124 <span style="color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
125 {s.branch} · {s.status}
158 <span style="display:flex;align-items:center;gap:8px;flex-shrink:0;color:#6b7280;font-size:12px;font-family:ui-monospace,monospace">
159 {s.branch}
160 {statusBadge(s.status)}
126161 </span>
127162 </li>
128163 ))}
Modifiedsrc/routes/dashboard.tsx+425−2View fileUnifiedSplit
1515 */
1616
1717import { Hono } from "hono";
18import { eq, desc, and, inArray, ne, sql } from "drizzle-orm";
18import { eq, desc, and, inArray, ne, sql, gte } from "drizzle-orm";
1919import { getCookie, setCookie } from "hono/cookie";
2020import { db } from "../db";
2121import {
2222 repositories,
2323 users,
2424 activityFeed,
25 auditLog,
26 gateRuns,
2527 issues,
2628 pullRequests,
2729} from "../db/schema";
4648 type AiSavingsLifetimeReport,
4749} from "../lib/ai-hours-saved";
4850
51// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
52
53const SECRET_GATE_NAMES_DASH = [
54 "Secret scan",
55 "Secret Scan",
56 "Security scan",
57 "Security Scan",
58];
59
60export type AiActivityItem = {
61 kind: "pr_auto_merged" | "spec_shipped" | "ci_healed" | "secret_repaired";
62 repoName: string;
63 repoOwner: string;
64 /** PR or issue number — null when it's a gate repair with no PR */
65 refNumber: number | null;
66 /** Short title for display (PR/issue title or gate name) */
67 label: string;
68 occurredAt: Date;
69};
70
71export type AiActivityReport = {
72 items: AiActivityItem[];
73 prsAutoMerged: number;
74 specsShipped: number;
75 ciHealed: number;
76 secretsRepaired: number;
77};
78
79/**
80 * Fetch autopilot-driven activity from the last 60 minutes for all repos
81 * owned by the given user. Never throws — on any DB error returns an
82 * empty report so the widget always renders.
83 */
84async function fetchAiActivityLastHour(
85 userId: string,
86 repoIds: string[],
87 repoMap: Map<string, { name: string; ownerUsername: string }>,
88): Promise<AiActivityReport> {
89 const empty: AiActivityReport = {
90 items: [],
91 prsAutoMerged: 0,
92 specsShipped: 0,
93 ciHealed: 0,
94 secretsRepaired: 0,
95 };
96 if (repoIds.length === 0) return empty;
97
98 const since = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
99
100 try {
101 // ── 1. Auto-merged PRs ─────────────────────────────────────
102 const autoMergeRows = await db
103 .select({
104 repositoryId: auditLog.repositoryId,
105 targetId: auditLog.targetId,
106 createdAt: auditLog.createdAt,
107 })
108 .from(auditLog)
109 .where(
110 and(
111 eq(auditLog.action, "auto_merge.merged"),
112 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
113 gte(auditLog.createdAt, since),
114 )
115 )
116 .orderBy(desc(auditLog.createdAt))
117 .limit(20);
118
119 // Fetch PR titles for auto-merged rows
120 const autoMergePrIds = autoMergeRows
121 .map((r) => r.targetId)
122 .filter(Boolean) as string[];
123 const prTitleMap = new Map<string, { number: number; title: string }>();
124 if (autoMergePrIds.length > 0) {
125 const prRows = await db
126 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title })
127 .from(pullRequests)
128 .where(inArray(pullRequests.id, autoMergePrIds as [string, ...string[]]));
129 for (const r of prRows) prTitleMap.set(r.id, { number: r.number, title: r.title });
130 }
131
132 // ── 2. AI-built specs dispatched ───────────────────────────
133 const specRows = await db
134 .select({
135 repositoryId: auditLog.repositoryId,
136 targetId: auditLog.targetId,
137 createdAt: auditLog.createdAt,
138 })
139 .from(auditLog)
140 .where(
141 and(
142 eq(auditLog.action, "ai_build.dispatched"),
143 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
144 gte(auditLog.createdAt, since),
145 )
146 )
147 .orderBy(desc(auditLog.createdAt))
148 .limit(20);
149
150 // Fetch issue numbers/titles for dispatched specs
151 const specIssueIds = specRows
152 .map((r) => r.targetId)
153 .filter(Boolean) as string[];
154 const issueTitleMap = new Map<string, { number: number; title: string }>();
155 if (specIssueIds.length > 0) {
156 const issueRows = await db
157 .select({ id: issues.id, number: issues.number, title: issues.title })
158 .from(issues)
159 .where(inArray(issues.id, specIssueIds as [string, ...string[]]));
160 for (const r of issueRows) issueTitleMap.set(r.id, { number: r.number, title: r.title });
161 }
162
163 // ── 3. Gate auto-repairs (secrets) ─────────────────────────
164 const secretRepairRows = await db
165 .select({
166 repositoryId: gateRuns.repositoryId,
167 gateName: gateRuns.gateName,
168 createdAt: gateRuns.createdAt,
169 })
170 .from(gateRuns)
171 .where(
172 and(
173 eq(gateRuns.status, "repaired"),
174 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
175 gte(gateRuns.createdAt, since),
176 inArray(gateRuns.gateName, SECRET_GATE_NAMES_DASH as [string, ...string[]]),
177 )
178 )
179 .orderBy(desc(gateRuns.createdAt))
180 .limit(20);
181
182 // ── 4. Gate auto-repairs (CI / other) ──────────────────────
183 const ciHealRows = await db
184 .select({
185 repositoryId: gateRuns.repositoryId,
186 gateName: gateRuns.gateName,
187 createdAt: gateRuns.createdAt,
188 })
189 .from(gateRuns)
190 .where(
191 and(
192 eq(gateRuns.status, "repaired"),
193 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
194 gte(gateRuns.createdAt, since),
195 sql`${gateRuns.gateName} NOT IN ('Secret scan','Secret Scan','Security scan','Security Scan')`,
196 )
197 )
198 .orderBy(desc(gateRuns.createdAt))
199 .limit(20);
200
201 // ── Build item list ────────────────────────────────────────
202 const items: AiActivityItem[] = [];
203
204 for (const r of autoMergeRows) {
205 const repo = repoMap.get(r.repositoryId ?? "");
206 if (!repo) continue;
207 const pr = r.targetId ? prTitleMap.get(r.targetId) : undefined;
208 items.push({
209 kind: "pr_auto_merged",
210 repoName: repo.name,
211 repoOwner: repo.ownerUsername,
212 refNumber: pr?.number ?? null,
213 label: pr?.title ?? "Pull request",
214 occurredAt: r.createdAt,
215 });
216 }
217
218 for (const r of specRows) {
219 const repo = repoMap.get(r.repositoryId ?? "");
220 if (!repo) continue;
221 const issue = r.targetId ? issueTitleMap.get(r.targetId) : undefined;
222 items.push({
223 kind: "spec_shipped",
224 repoName: repo.name,
225 repoOwner: repo.ownerUsername,
226 refNumber: issue?.number ?? null,
227 label: issue?.title ?? "Issue",
228 occurredAt: r.createdAt,
229 });
230 }
231
232 for (const r of secretRepairRows) {
233 const repo = repoMap.get(r.repositoryId ?? "");
234 if (!repo) continue;
235 items.push({
236 kind: "secret_repaired",
237 repoName: repo.name,
238 repoOwner: repo.ownerUsername,
239 refNumber: null,
240 label: r.gateName,
241 occurredAt: r.createdAt,
242 });
243 }
244
245 for (const r of ciHealRows) {
246 const repo = repoMap.get(r.repositoryId ?? "");
247 if (!repo) continue;
248 items.push({
249 kind: "ci_healed",
250 repoName: repo.name,
251 repoOwner: repo.ownerUsername,
252 refNumber: null,
253 label: r.gateName,
254 occurredAt: r.createdAt,
255 });
256 }
257
258 // Sort newest first
259 items.sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime());
260
261 return {
262 items,
263 prsAutoMerged: autoMergeRows.length,
264 specsShipped: specRows.length,
265 ciHealed: ciHealRows.length,
266 secretsRepaired: secretRepairRows.length,
267 };
268 } catch (err) {
269 console.error("[dashboard] ai-activity-last-hour degraded:", err);
270 return empty;
271 }
272}
273
49274const dashboard = new Hono<AuthEnv>();
50275
51276dashboard.use("*", softAuth);
130355
131356 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
132357 // parallel; both helpers swallow DB errors so the dashboard always renders.
133 const [savingsWeek, savingsLifetime] = await Promise.all([
358 // Also fetch the last-hour autopilot activity for the new widget.
359 const repoIds = repos.map((r) => r.id);
360 const repoMap = new Map(
361 repos.map((r) => [r.id, { name: r.name, ownerUsername: user.username }])
362 );
363 const [savingsWeek, savingsLifetime, aiActivity] = await Promise.all([
134364 computeAiSavingsForUser(user.id, { windowHours: 168 }),
135365 computeLifetimeAiSavingsForUser(user.id),
366 fetchAiActivityLastHour(user.id, repoIds, repoMap),
136367 ]);
137368
138369 // Get recent activity
460691 {/* ─── L9: AI hours-saved hero widget ─── */}
461692 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
462693
694 {/* ─── AI Activity — Last Hour ─── */}
695 <AiActivityWidget activity={aiActivity} username={user.username} />
696
463697 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
464698 they're discoverable from the dashboard without diving into the
465699 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
9241158
9251159// ─── COMPONENTS ──────────────────────────────────────────────
9261160
1161// ─── AI Activity — Last Hour widget ─────────────────────────────────────────
1162
1163const AI_ACTIVITY_ICON: Record<AiActivityItem["kind"], string> = {
1164 pr_auto_merged: "⮌", // ⬌ (merged)
1165 spec_shipped: "\u{1F4E6}", // 📦
1166 ci_healed: "⚡", // ⚡
1167 secret_repaired: "\u{1F512}", // 🔒
1168};
1169
1170const AI_ACTIVITY_LABEL: Record<AiActivityItem["kind"], string> = {
1171 pr_auto_merged: "PR auto-merged",
1172 spec_shipped: "Spec shipped",
1173 ci_healed: "CI healed",
1174 secret_repaired: "Secret repaired",
1175};
1176
1177const AI_ACTIVITY_COLOR: Record<AiActivityItem["kind"], string> = {
1178 pr_auto_merged: "var(--accent)",
1179 spec_shipped: "var(--green)",
1180 ci_healed: "#58a6ff",
1181 secret_repaired: "var(--yellow)",
1182};
1183
1184const AiActivityWidget = ({
1185 activity,
1186 username,
1187}: {
1188 activity: AiActivityReport;
1189 username: string;
1190}) => {
1191 const total =
1192 activity.prsAutoMerged +
1193 activity.specsShipped +
1194 activity.ciHealed +
1195 activity.secretsRepaired;
1196
1197 const summaryParts: string[] = [];
1198 if (activity.prsAutoMerged > 0)
1199 summaryParts.push(
1200 `${activity.prsAutoMerged} PR${activity.prsAutoMerged === 1 ? "" : "s"} auto-merged`
1201 );
1202 if (activity.specsShipped > 0)
1203 summaryParts.push(
1204 `${activity.specsShipped} spec${activity.specsShipped === 1 ? "" : "s"} shipped`
1205 );
1206 if (activity.ciHealed > 0)
1207 summaryParts.push(
1208 `${activity.ciHealed} CI run${activity.ciHealed === 1 ? "" : "s"} healed`
1209 );
1210 if (activity.secretsRepaired > 0)
1211 summaryParts.push(
1212 `${activity.secretsRepaired} secret${activity.secretsRepaired === 1 ? "" : "s"} repaired`
1213 );
1214
1215 // Show at most 6 items in the expanded list to keep the card compact.
1216 const shownItems = activity.items.slice(0, 6);
1217
1218 return (
1219 <div
1220 class="card"
1221 style="margin-bottom: var(--space-6); padding: 0; overflow: hidden"
1222 >
1223 {/* Card header */}
1224 <div
1225 style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border);background:var(--bg-elevated)"
1226 >
1227 <div style="display:flex;align-items:center;gap:var(--space-2)">
1228 <span style="font-size:15px" aria-hidden="true">{"\u{1F916}"}</span>
1229 <div>
1230 <span style="font-size:14px;font-weight:600;color:var(--text-strong)">
1231 AI Activity
1232 </span>
1233 <span
1234 style="margin-left:6px;font-size:11px;color:var(--text-muted);font-weight:400"
1235 >
1236 last hour
1237 </span>
1238 </div>
1239 </div>
1240 {total > 0 && (
1241 <span
1242 style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.14);color:var(--accent);border:1px solid rgba(140,109,255,0.25)"
1243 >
1244 {total} action{total === 1 ? "" : "s"}
1245 </span>
1246 )}
1247 </div>
1248
1249 {total === 0 ? (
1250 /* Empty state */
1251 <div
1252 style="padding:var(--space-5) var(--space-4);text-align:center;color:var(--text-muted);font-size:13px"
1253 >
1254 <div style="font-size:22px;margin-bottom:6px" aria-hidden="true">
1255 {"\u{1F441}"}
1256 </div>
1257 All quiet — AI is watching.
1258 </div>
1259 ) : (
1260 <>
1261 {/* Summary pills */}
1262 <div
1263 style="display:flex;flex-wrap:wrap;gap:6px;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)"
1264 >
1265 {summaryParts.map((p) => (
1266 <span
1267 class="badge"
1268 style="font-size:12px;padding:3px 9px;background:rgba(140,109,255,0.08);border-color:rgba(140,109,255,0.22);color:var(--text)"
1269 >
1270 {p}
1271 </span>
1272 ))}
1273 </div>
1274
1275 {/* Item list */}
1276 <ul style="list-style:none;margin:0;padding:0">
1277 {shownItems.map((item) => {
1278 // Build a link to the relevant resource if we have a number
1279 let href: string | undefined;
1280 if (item.refNumber !== null) {
1281 const base = `/${item.repoOwner}/${item.repoName}`;
1282 if (item.kind === "pr_auto_merged") {
1283 href = `${base}/pulls/${item.refNumber}`;
1284 } else if (item.kind === "spec_shipped") {
1285 href = `${base}/issues/${item.refNumber}`;
1286 }
1287 }
1288 const color = AI_ACTIVITY_COLOR[item.kind];
1289 const icon = AI_ACTIVITY_ICON[item.kind];
1290 const kindLabel = AI_ACTIVITY_LABEL[item.kind];
1291 return (
1292 <li
1293 style="display:flex;align-items:center;gap:10px;padding:8px var(--space-4);border-bottom:1px solid var(--border);font-size:13px"
1294 >
1295 <span
1296 style={`font-size:15px;width:20px;text-align:center;flex-shrink:0;color:${color}`}
1297 aria-label={kindLabel}
1298 >
1299 {icon}
1300 </span>
1301 <span
1302 style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1303 title={item.label}
1304 >
1305 <span style="color:var(--text-muted);font-size:11px;margin-right:4px">
1306 {kindLabel}
1307 </span>
1308 {href ? (
1309 <a
1310 href={href}
1311 style="font-weight:500;color:var(--text)"
1312 >
1313 {item.label}
1314 </a>
1315 ) : (
1316 <span style="font-weight:500">{item.label}</span>
1317 )}
1318 </span>
1319 <span
1320 style="font-size:11px;color:var(--text-muted);flex-shrink:0;white-space:nowrap"
1321 >
1322 <a
1323 href={`/${item.repoOwner}/${item.repoName}`}
1324 style="color:var(--text-muted)"
1325 >
1326 {item.repoName}
1327 </a>
1328 <span style="margin-left:4px">
1329 {formatRelative(item.occurredAt)}
1330 </span>
1331 </span>
1332 </li>
1333 );
1334 })}
1335 </ul>
1336
1337 {activity.items.length > 6 && (
1338 <div
1339 style="padding:8px var(--space-4);font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)"
1340 >
1341 + {activity.items.length - 6} more action{activity.items.length - 6 === 1 ? "" : "s"} in the last hour
1342 </div>
1343 )}
1344 </>
1345 )}
1346 </div>
1347 );
1348};
1349
9271350/**
9281351 * Block L9 — pure formatter used by the dashboard widget AND tests.
9291352 * Turns the breakdown into the small stat-pill array shown under the
Modifiedsrc/routes/demo.tsx+27−21View fileUnifiedSplit
173173 function rel(iso){try{var ms=Date.now()-new Date(iso).getTime();var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s ago';var m=Math.floor(s/60);if(m<60)return m+'m ago';var h=Math.floor(m/60);if(h<48)return h+'h ago';return Math.floor(h/24)+'d ago';}catch(e){return '';}}
174174 function pollQueued(){fetch('/api/v2/demo/queued').then(function(r){return r.json();}).then(function(d){
175175 var el=document.getElementById('tile-queued-list');if(!el)return;
176 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-page-empty">No queued AI builds — quiet right now.</li>';return;}
176 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-page-empty">Nothing building right now — tag an issue ai:build to watch it start.</li>';return;}
177177 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/issues/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-page-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join('');
178178 }).catch(function(){});}
179179 function pollMerges(){fetch('/api/v2/demo/merges').then(function(r){return r.json();}).then(function(d){
180180 var el=document.getElementById('tile-merges-list');if(!el)return;
181 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-page-empty">No auto-merges in the last 24h.</li>';return;}
181 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-page-empty">No instant auto-merges yet — one fires the moment gates go green.</li>';return;}
182182 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-page-meta">'+esc(i.repo)+' · '+rel(i.mergedAt)+'</span></li>';}).join('');
183183 }).catch(function(){});}
184184 function pollReviews(){fetch('/api/v2/demo/reviews').then(function(r){return r.json();}).then(function(d){
210210`.trim();
211211
212212 return c.html(
213 <Layout title="Live demo" user={user}>
213 <Layout
214 title="Live demo"
215 user={user}
216 description="Watch Gluecron build and ship code in real time. AI writes the PR, reviews it, and merges it automatically."
217 ogTitle="Live demo — Gluecron"
218 ogDescription="Watch Gluecron build and ship code in real time. AI writes the PR, reviews it, and merges it automatically."
219 twitterCard="summary_large_image"
220 >
214221 <style dangerouslySetInnerHTML={{ __html: DEMO_CSS }} />
215222 <div class="demo-page">
216223 {/* ─── Hero ─── */}
271278 </div>
272279 <p class="demo-page-step-body">
273280 Open a new issue on any demo repo and tag it{" "}
274 <code>ai:build</code>. The autopilot picks it up on the
275 next 5-minute sweep.
281 <code>ai:build</code>. The autopilot picks it up in real
282 time — a draft PR appears within 90 seconds.
276283 </p>
277284 <p class="demo-page-step-try">
278285 <span class="demo-page-try-label">Try this</span>
303310 </div>
304311 <p class="demo-page-step-body">
305312 The autopilot reads the issue, edits the repo, opens a
306 branch, and pushes a PR linked back to the issue. You
307 can see every PR Claude has opened in the tile below.
313 branch, and pushes a PR linked back to the issue — all
314 happening right now. Watch it appear in the tile below.
308315 </p>
309316 <p class="demo-page-step-try">
310317 <span class="demo-page-try-label">Try this</span>
326333 <h3 class="demo-page-step-title">AI review lands</h3>
327334 </div>
328335 <p class="demo-page-step-body">
329 Every PR gets a second-AI review pass — typed comments
330 with line numbers, severity, and a one-line summary at
331 the top. No human needed for the routine stuff.
336 Every PR gets a second-AI review pass within ~8 seconds
337 of opening — typed comments with line numbers, severity,
338 and a one-line summary at the top. No human needed.
332339 </p>
333340 <p class="demo-page-step-try">
334341 <span class="demo-page-try-label">Try this</span>
335342 <a class="demo-page-try-link" href="#tile-reviews-h">
336 Read today's reviews ↓
343 See reviews happening now ↓
337344 </a>
338345 </p>
339346 </li>
350357 <h3 class="demo-page-step-title">Auto-merge when green</h3>
351358 </div>
352359 <p class="demo-page-step-body">
353 Once every gate is green, the autopilot merges the PR
354 and closes the originating issue. Branch protection
355 rules still apply — Claude can't merge anything you
356 couldn't.
360 The instant every gate goes green, the autopilot merges
361 the PR and closes the originating issue — no click, no
362 wait. Branch protection rules still apply.
357363 </p>
358364 <p class="demo-page-step-try">
359365 <span class="demo-page-try-label">Try this</span>
360366 <a class="demo-page-try-link" href="#tile-merges-h">
361 Watch the auto-merge tile ↓
367 Watch it merge in real time ↓
362368 </a>
363369 </p>
364370 </li>
370376 <div class="demo-page-tiles">
371377 <section class="demo-page-tile" aria-labelledby="tile-queued-h">
372378 <h2 id="tile-queued-h" class="demo-page-tile-title">
373 Issues queued for AI build
379 Issues being built by AI right now
374380 </h2>
375381 <ul id="tile-queued-list" class="demo-page-list">
376382 {queued.length === 0 ? (
377 <li class="demo-page-empty">No queued AI builds — quiet right now.</li>
383 <li class="demo-page-empty">Nothing building right now — tag an issue ai:build to watch it start.</li>
378384 ) : (
379385 queued.map((i) => (
380386 <li>
392398
393399 <section class="demo-page-tile" aria-labelledby="tile-merges-h">
394400 <h2 id="tile-merges-h" class="demo-page-tile-title">
395 PRs auto-merged in the last 24h
401 PRs auto-merged the instant gates passed
396402 </h2>
397403 <ul id="tile-merges-list" class="demo-page-list">
398404 {merges.length === 0 ? (
399405 <li class="demo-page-empty">
400 No auto-merges in the last 24h.
406 No instant auto-merges yet — one fires the moment gates go green.
401407 </li>
402408 ) : (
403409 merges.map((m) => (
478484 Live activity
479485 </h2>
480486 <p class="demo-page-section-sub">
481 Newest event first. Auto-refreshes every 30s.
487 Happening right now — newest event first, auto-refreshes every 30s.
482488 </p>
483489 </div>
484490 <button
Addedsrc/routes/deploy-targets.tsx+767−0View fileUnifiedSplit
1/**
2 * Customer-facing deploy targets — /settings/deploy-targets
3 *
4 * Lets any authenticated user manage SSH deploy targets for their own repos.
5 * Private keys are encrypted at rest with AES-256-GCM using SERVER_TARGETS_KEY
6 * (same scheme as the admin surface in admin-server-targets.tsx).
7 *
8 * GET /settings/deploy-targets — list user's own targets
9 * POST /settings/deploy-targets — create a new target
10 * POST /settings/deploy-targets/:id/delete — delete (owner-only)
11 * POST /settings/deploy-targets/:id/test — test SSH connectivity
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { serverTargets } from "../db/schema";
18import { Layout } from "../views/layout";
19import type { AuthEnv } from "../middleware/auth";
20import { requireAuth } from "../middleware/auth";
21import {
22 createTarget,
23 deleteTarget,
24 recordPin,
25} from "../lib/server-target-store";
26import { testConnection } from "../lib/server-targets";
27import { getMasterKey } from "../lib/server-targets-crypto";
28
29const deployTargets = new Hono<AuthEnv>();
30
31deployTargets.use("/settings/deploy-targets*", requireAuth);
32
33// ─── Scoped styles ────────────────────────────────────────────────────────────
34
35const styles = `
36 .dt-wrap {
37 max-width: 960px;
38 margin: 0 auto;
39 padding: var(--space-6) var(--space-4);
40 }
41
42 /* ─── Hero ─── */
43 .dt-hero {
44 position: relative;
45 margin-bottom: var(--space-6);
46 padding: var(--space-5) var(--space-6);
47 background: var(--bg-elevated);
48 border: 1px solid var(--border);
49 border-radius: 16px;
50 overflow: hidden;
51 }
52 .dt-hero::before {
53 content: '';
54 position: absolute;
55 top: 0; left: 0; right: 0;
56 height: 2px;
57 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
58 opacity: 0.7;
59 pointer-events: none;
60 }
61 .dt-hero-orb {
62 position: absolute;
63 inset: -20% -10% auto auto;
64 width: 360px; height: 360px;
65 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
66 filter: blur(80px);
67 opacity: 0.65;
68 pointer-events: none;
69 z-index: 0;
70 }
71 .dt-hero-inner {
72 position: relative;
73 z-index: 1;
74 max-width: 640px;
75 }
76 .dt-hero-eyebrow {
77 font-size: 13px;
78 color: var(--text-muted);
79 margin-bottom: var(--space-2);
80 }
81 .dt-hero-title {
82 font-size: clamp(26px, 3.5vw, 36px);
83 font-family: var(--font-display);
84 font-weight: 800;
85 letter-spacing: -0.028em;
86 line-height: 1.05;
87 margin: 0 0 var(--space-2);
88 color: var(--text-strong);
89 }
90 .dt-hero-title .gradient-text {
91 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
92 -webkit-background-clip: text;
93 background-clip: text;
94 -webkit-text-fill-color: transparent;
95 color: transparent;
96 }
97 .dt-hero-sub {
98 font-size: 14px;
99 color: var(--text-muted);
100 margin: 0;
101 line-height: 1.5;
102 }
103
104 /* ─── Subnav ─── */
105 .dt-subnav {
106 display: flex;
107 gap: 4px;
108 flex-wrap: wrap;
109 margin-bottom: var(--space-5);
110 padding: 4px;
111 background: var(--bg-elevated);
112 border: 1px solid var(--border);
113 border-radius: 9999px;
114 width: fit-content;
115 max-width: 100%;
116 overflow-x: auto;
117 }
118 .dt-subnav a {
119 display: inline-flex;
120 align-items: center;
121 gap: 6px;
122 padding: 6px 14px;
123 font-size: 13px;
124 font-weight: 500;
125 color: var(--text-muted);
126 border-radius: 9999px;
127 text-decoration: none;
128 white-space: nowrap;
129 transition: all 120ms ease;
130 }
131 .dt-subnav a:hover {
132 color: var(--text-strong);
133 background: var(--bg-hover);
134 }
135 .dt-subnav a.is-active {
136 color: var(--text-strong);
137 background: rgba(140,109,255,0.16);
138 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
139 }
140
141 /* ─── Banners ─── */
142 .dt-banner {
143 display: flex;
144 align-items: center;
145 gap: 10px;
146 padding: 12px 16px;
147 border-radius: 12px;
148 font-size: 13.5px;
149 margin-bottom: var(--space-4);
150 line-height: 1.5;
151 }
152 .dt-banner-success {
153 background: rgba(52,211,153,0.08);
154 color: #6ee7b7;
155 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
156 }
157 .dt-banner-error {
158 background: rgba(248,113,113,0.08);
159 color: #fca5a5;
160 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
161 }
162 .dt-banner-icon {
163 width: 18px; height: 18px;
164 border-radius: 9999px;
165 flex-shrink: 0;
166 display: inline-flex;
167 align-items: center;
168 justify-content: center;
169 font-size: 12px;
170 font-weight: 700;
171 }
172 .dt-banner-success .dt-banner-icon { background: rgba(52,211,153,0.18); color: #34d399; }
173 .dt-banner-error .dt-banner-icon { background: rgba(248,113,113,0.18); color: #f87171; }
174
175 /* ─── Section cards ─── */
176 .dt-section {
177 background: var(--bg-elevated);
178 border: 1px solid var(--border);
179 border-radius: 14px;
180 margin-bottom: var(--space-5);
181 overflow: hidden;
182 }
183 .dt-section-head {
184 padding: var(--space-4) var(--space-5) var(--space-3);
185 border-bottom: 1px solid var(--border);
186 }
187 .dt-section-eyebrow {
188 font-size: 11px;
189 font-weight: 600;
190 letter-spacing: 0.08em;
191 text-transform: uppercase;
192 color: var(--accent);
193 margin-bottom: 6px;
194 }
195 .dt-section-title {
196 font-family: var(--font-display);
197 font-size: 18px;
198 font-weight: 700;
199 letter-spacing: -0.018em;
200 margin: 0 0 4px;
201 color: var(--text-strong);
202 }
203 .dt-section-desc {
204 font-size: 13.5px;
205 color: var(--text-muted);
206 margin: 0;
207 line-height: 1.5;
208 }
209 .dt-section-body { padding: var(--space-4) var(--space-5); }
210 .dt-section-foot {
211 padding: var(--space-3) var(--space-5);
212 border-top: 1px solid var(--border);
213 background: rgba(255,255,255,0.012);
214 display: flex;
215 justify-content: flex-end;
216 gap: var(--space-2);
217 align-items: center;
218 flex-wrap: wrap;
219 }
220
221 /* ─── Form fields ─── */
222 .dt-field { margin-bottom: var(--space-4); }
223 .dt-field:last-child { margin-bottom: 0; }
224 .dt-field-label {
225 display: block;
226 font-size: 13px;
227 font-weight: 600;
228 color: var(--text-strong);
229 margin-bottom: 6px;
230 letter-spacing: -0.005em;
231 }
232 .dt-field-hint {
233 font-size: 12.5px;
234 color: var(--text-muted);
235 margin-top: 6px;
236 line-height: 1.45;
237 }
238 .dt-input,
239 .dt-textarea {
240 width: 100%;
241 padding: 9px 12px;
242 font-size: 14px;
243 color: var(--text);
244 background: var(--bg);
245 border: 1px solid var(--border-strong);
246 border-radius: 8px;
247 outline: none;
248 transition: border-color 120ms ease, box-shadow 120ms ease;
249 font-family: var(--font-sans);
250 box-sizing: border-box;
251 }
252 .dt-textarea {
253 font-family: var(--font-mono);
254 font-size: 12.5px;
255 line-height: 1.5;
256 resize: vertical;
257 }
258 .dt-input:focus,
259 .dt-textarea:focus {
260 border-color: var(--border-focus);
261 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
262 }
263 .dt-row-2 {
264 display: grid;
265 grid-template-columns: 1fr 120px;
266 gap: var(--space-3);
267 }
268 @media (max-width: 560px) {
269 .dt-row-2 { grid-template-columns: 1fr; }
270 }
271
272 /* ─── Target cards ─── */
273 .dt-target-card {
274 display: flex;
275 justify-content: space-between;
276 align-items: flex-start;
277 gap: var(--space-3);
278 padding: 14px 16px;
279 border: 1px solid var(--border);
280 border-radius: 12px;
281 background: var(--bg-secondary);
282 margin-bottom: 10px;
283 transition: border-color 120ms ease, background 120ms ease;
284 }
285 .dt-target-card:last-child { margin-bottom: 0; }
286 .dt-target-card:hover {
287 border-color: var(--border-strong);
288 background: rgba(255,255,255,0.018);
289 }
290 .dt-target-name {
291 font-size: 14px;
292 font-weight: 600;
293 color: var(--text-strong);
294 margin: 0 0 4px;
295 }
296 .dt-target-host {
297 display: inline-block;
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 background: var(--bg-tertiary);
302 padding: 2px 8px;
303 border-radius: 6px;
304 }
305 .dt-target-meta {
306 margin-top: 6px;
307 font-size: 12.5px;
308 color: var(--text-muted);
309 display: flex;
310 gap: 10px;
311 flex-wrap: wrap;
312 align-items: center;
313 }
314 .dt-status-pill {
315 display: inline-flex;
316 align-items: center;
317 gap: 4px;
318 padding: 2px 8px;
319 border-radius: 9999px;
320 font-size: 11px;
321 font-weight: 600;
322 letter-spacing: 0.02em;
323 }
324 .dt-status-verified {
325 background: rgba(52,211,153,0.12);
326 color: #6ee7b7;
327 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
328 }
329 .dt-status-unverified {
330 background: rgba(234,179,8,0.10);
331 color: #fde047;
332 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
333 }
334 .dt-target-actions {
335 display: flex;
336 gap: 6px;
337 flex-shrink: 0;
338 flex-wrap: wrap;
339 align-items: flex-start;
340 }
341 .dt-empty {
342 padding: var(--space-5);
343 text-align: center;
344 border: 1px dashed var(--border);
345 border-radius: 12px;
346 background: var(--bg-secondary);
347 color: var(--text-muted);
348 font-size: 13.5px;
349 }
350
351 /* ─── Warning banner (missing key) ─── */
352 .dt-warn {
353 padding: 12px 16px;
354 border-radius: 10px;
355 background: rgba(234,179,8,0.08);
356 color: #fde047;
357 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
358 font-size: 13.5px;
359 margin-bottom: var(--space-4);
360 display: flex;
361 gap: 10px;
362 align-items: center;
363 }
364
365 @media (max-width: 640px) {
366 .dt-target-card { flex-direction: column; }
367 .dt-target-actions { flex-direction: row; }
368 }
369`;
370
371// ─── Shared subnav (mirrors settings.tsx pattern) ────────────────────────────
372
373function DeployTargetsSubnav() {
374 return (
375 <nav class="dt-subnav" aria-label="Settings sections">
376 <a href="/settings">Profile</a>
377 <a href="/settings/keys">SSH keys</a>
378 <a href="/settings/agents">Agents</a>
379 <a href="/settings/deploy-targets" class="is-active" aria-current="page">
380 Deploy targets
381 </a>
382 </nav>
383 );
384}
385
386function Banner(props: { kind: "success" | "error"; text: string }) {
387 return (
388 <div class={`dt-banner dt-banner-${props.kind}`} role="status">
389 <span class="dt-banner-icon" aria-hidden="true">
390 {props.kind === "success" ? "✓" : "!"}
391 </span>
392 <span>{props.text}</span>
393 </div>
394 );
395}
396
397// ─── GET /settings/deploy-targets ────────────────────────────────────────────
398
399deployTargets.get("/settings/deploy-targets", async (c) => {
400 const user = c.get("user")!;
401 const ok = c.req.query("ok") ?? undefined;
402 const err = c.req.query("err") ?? undefined;
403
404 // Fetch only targets belonging to this user
405 const targets = await db
406 .select()
407 .from(serverTargets)
408 .where(eq(serverTargets.createdBy, user.id))
409 .orderBy(desc(serverTargets.createdAt));
410
411 const keyConfigured = getMasterKey() !== null;
412
413 return c.html(
414 <Layout title="Deploy targets — settings" user={user}>
415 <style dangerouslySetInnerHTML={{ __html: styles }} />
416 <div class="dt-wrap">
417 {/* ─── Hero ─── */}
418 <div class="dt-hero">
419 <div class="dt-hero-orb" aria-hidden="true" />
420 <div class="dt-hero-inner">
421 <div class="dt-hero-eyebrow">
422 Your account · <span style="color:var(--accent);font-weight:600">{user.username}</span>
423 </div>
424 <h1 class="dt-hero-title">
425 Deploy <span class="gradient-text">targets</span>.
426 </h1>
427 <p class="dt-hero-sub">
428 SSH boxes that Gluecron can deploy to. Private keys are encrypted
429 at rest and never displayed again after saving.
430 </p>
431 </div>
432 </div>
433
434 <DeployTargetsSubnav />
435
436 {!keyConfigured && (
437 <div class="dt-warn" role="alert">
438 <span aria-hidden="true"></span>
439 <span>
440 <strong>SERVER_TARGETS_KEY not set.</strong> Deploy targets require
441 this environment variable (a 64-character hex string / 32-byte AES key)
442 to encrypt private keys. Contact your administrator.
443 </span>
444 </div>
445 )}
446
447 {ok && <Banner kind="success" text={decodeURIComponent(ok)} />}
448 {err && <Banner kind="error" text={decodeURIComponent(err)} />}
449
450 {/* ─── Existing targets ─── */}
451 <section class="dt-section">
452 <div class="dt-section-head">
453 <div class="dt-section-eyebrow">SSH deploy targets</div>
454 <h2 class="dt-section-title">Your targets</h2>
455 <p class="dt-section-desc">
456 Boxes registered to your account. Each target can be linked to a
457 repo + branch so pushes trigger deploys automatically.
458 </p>
459 </div>
460 <div class="dt-section-body">
461 {targets.length === 0 ? (
462 <div class="dt-empty">
463 No deploy targets yet. Add your first box below.
464 </div>
465 ) : (
466 targets.map((t) => (
467 <div class="dt-target-card">
468 <div>
469 <div class="dt-target-name">{t.name}</div>
470 <code class="dt-target-host">
471 {t.sshUser}@{t.host}:{t.port}
472 </code>
473 <div class="dt-target-meta">
474 <span
475 class={
476 "dt-status-pill " +
477 (t.status === "verified"
478 ? "dt-status-verified"
479 : "dt-status-unverified")
480 }
481 >
482 {t.status}
483 </span>
484 {t.deployPath && (
485 <span style="font-family:var(--font-mono);font-size:12px">
486 {t.deployPath}
487 </span>
488 )}
489 {t.createdAt && (
490 <span>
491 Added {t.createdAt.toLocaleDateString()}
492 </span>
493 )}
494 </div>
495 </div>
496 <div class="dt-target-actions">
497 <form
498 method="post"
499 action={`/settings/deploy-targets/${t.id}/test`}
500 style="display:inline"
501 >
502 <button
503 type="submit"
504 class="btn btn-sm"
505 title="Test SSH connection"
506 >
507 Test
508 </button>
509 </form>
510 <form
511 method="post"
512 action={`/settings/deploy-targets/${t.id}/delete`}
513 style="display:inline"
514 onsubmit="return confirm('Delete this deploy target?')"
515 >
516 <button type="submit" class="btn btn-sm btn-danger">
517 Delete
518 </button>
519 </form>
520 </div>
521 </div>
522 ))
523 )}
524 </div>
525 </section>
526
527 {/* ─── Add new target form ─── */}
528 <section class="dt-section">
529 <div class="dt-section-head">
530 <div class="dt-section-eyebrow">Add target</div>
531 <h2 class="dt-section-title">New deploy target</h2>
532 <p class="dt-section-desc">
533 Enter your server's SSH credentials. The private key is encrypted
534 immediately and never stored in plaintext.
535 </p>
536 </div>
537 <form method="post" action="/settings/deploy-targets">
538 <div class="dt-section-body">
539 <div class="dt-field">
540 <label class="dt-field-label" for="dt-name">
541 Name
542 </label>
543 <input
544 class="dt-input"
545 id="dt-name"
546 name="name"
547 required
548 pattern="[a-z0-9-]+"
549 placeholder="my-prod-server"
550 autocomplete="off"
551 />
552 <div class="dt-field-hint">
553 Lowercase letters, numbers and hyphens only. Must be unique
554 across all targets on the platform.
555 </div>
556 </div>
557
558 <div class="dt-row-2">
559 <div class="dt-field">
560 <label class="dt-field-label" for="dt-host">Host</label>
561 <input
562 class="dt-input"
563 id="dt-host"
564 name="host"
565 required
566 placeholder="1.2.3.4 or example.com"
567 autocomplete="off"
568 />
569 </div>
570 <div class="dt-field">
571 <label class="dt-field-label" for="dt-port">Port</label>
572 <input
573 class="dt-input"
574 id="dt-port"
575 name="port"
576 type="number"
577 value="22"
578 min="1"
579 max="65535"
580 />
581 </div>
582 </div>
583
584 <div class="dt-field">
585 <label class="dt-field-label" for="dt-ssh-user">
586 SSH username
587 </label>
588 <input
589 class="dt-input"
590 id="dt-ssh-user"
591 name="ssh_user"
592 required
593 placeholder="deploy"
594 autocomplete="off"
595 />
596 </div>
597
598 <div class="dt-field">
599 <label class="dt-field-label" for="dt-private-key">
600 SSH private key
601 </label>
602 <textarea
603 class="dt-textarea"
604 id="dt-private-key"
605 name="private_key"
606 required
607 rows={8}
608 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
609 />
610 <div class="dt-field-hint">
611 Paste your OpenSSH PEM private key. It is encrypted with
612 AES-256-GCM before being stored and will never be displayed
613 again after saving.
614 </div>
615 </div>
616
617 <div class="dt-field">
618 <label class="dt-field-label" for="dt-deploy-path">
619 Deploy path
620 </label>
621 <input
622 class="dt-input"
623 id="dt-deploy-path"
624 name="deploy_path"
625 placeholder="/var/www/app"
626 value="/var/www/app"
627 />
628 <div class="dt-field-hint">
629 Absolute path on the remote server where your app lives.
630 </div>
631 </div>
632 </div>
633 <div class="dt-section-foot">
634 <button
635 type="submit"
636 class="btn btn-primary"
637 disabled={!keyConfigured}
638 >
639 Add deploy target
640 </button>
641 </div>
642 </form>
643 </section>
644 </div>
645 </Layout>
646 );
647});
648
649// ─── POST /settings/deploy-targets ───────────────────────────────────────────
650
651deployTargets.post("/settings/deploy-targets", async (c) => {
652 const user = c.get("user")!;
653
654 if (getMasterKey() === null) {
655 return c.redirect(
656 "/settings/deploy-targets?err=SERVER_TARGETS_KEY+not+configured"
657 );
658 }
659
660 const form = await c.req.parseBody();
661 const name = String(form.name || "").trim();
662 const host = String(form.host || "").trim();
663 const port = Number(form.port || 22);
664 const sshUser = String(form.ssh_user || "").trim();
665 const privateKey = String(form.private_key || "");
666 const deployPath = String(form.deploy_path || "/var/www/app").trim();
667
668 if (!name || !host || !sshUser || !privateKey) {
669 return c.redirect(
670 "/settings/deploy-targets?err=Name%2C+host%2C+SSH+user+and+private+key+are+required"
671 );
672 }
673 if (!/^[a-z0-9-]+$/.test(name)) {
674 return c.redirect(
675 "/settings/deploy-targets?err=Name+must+be+lowercase+letters%2C+numbers+and+hyphens+only"
676 );
677 }
678 if (!privateKey.includes("PRIVATE KEY")) {
679 return c.redirect(
680 "/settings/deploy-targets?err=Private+key+does+not+look+like+a+valid+OpenSSH+PEM+key"
681 );
682 }
683
684 const out = await createTarget({
685 name,
686 host,
687 port: isNaN(port) ? 22 : port,
688 sshUser,
689 privateKey,
690 deployPath: deployPath || "/var/www/app",
691 deployScript: "bash deploy.sh",
692 watchedRepositoryId: null,
693 watchedBranch: null,
694 createdBy: user.id,
695 });
696
697 if (!out.ok) {
698 return c.redirect(
699 `/settings/deploy-targets?err=${encodeURIComponent(out.error)}`
700 );
701 }
702
703 return c.redirect(
704 `/settings/deploy-targets?ok=Deploy+target+%22${encodeURIComponent(name)}%22+created`
705 );
706});
707
708// ─── POST /settings/deploy-targets/:id/delete ────────────────────────────────
709
710deployTargets.post("/settings/deploy-targets/:id/delete", async (c) => {
711 const user = c.get("user")!;
712 const id = c.req.param("id");
713
714 // Verify ownership before deleting
715 const [target] = await db
716 .select()
717 .from(serverTargets)
718 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
719 .limit(1);
720
721 if (!target) {
722 return c.redirect(
723 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
724 );
725 }
726
727 await deleteTarget(id, user.id);
728 return c.redirect(
729 `/settings/deploy-targets?ok=Target+%22${encodeURIComponent(target.name)}%22+deleted`
730 );
731});
732
733// ─── POST /settings/deploy-targets/:id/test ──────────────────────────────────
734
735deployTargets.post("/settings/deploy-targets/:id/test", async (c) => {
736 const user = c.get("user")!;
737 const id = c.req.param("id");
738
739 // Verify ownership
740 const [target] = await db
741 .select()
742 .from(serverTargets)
743 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
744 .limit(1);
745
746 if (!target) {
747 return c.redirect(
748 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
749 );
750 }
751
752 const result = await testConnection(target);
753 if (result.ok) {
754 await recordPin(id, result.fingerprint, user.id);
755 return c.redirect(
756 `/settings/deploy-targets?ok=Connection+to+%22${encodeURIComponent(target.name)}%22+verified`
757 );
758 }
759
760 return c.redirect(
761 `/settings/deploy-targets?err=${encodeURIComponent(
762 `${result.stage}: ${result.error}`
763 )}`
764 );
765});
766
767export default deployTargets;
Addedsrc/routes/developer-program.tsx+688−0View fileUnifiedSplit
1/**
2 * Developer Program page — /developer-program
3 *
4 * "Build on Gluecron. Earn revenue."
5 * Three sections: Publish an agent · Revenue share · Partner badge.
6 * CTA: partner application form (logs for now; no DB write required).
7 *
8 * Pure server-rendered. Dark theme. No new dependencies.
9 * All CSS scoped under .devprog-* to avoid leaking into app views.
10 */
11
12import { Hono } from "hono";
13import { Layout } from "../views/layout";
14import { softAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16
17const developerProgram = new Hono<AuthEnv>();
18
19developerProgram.use("*", softAuth);
20
21// ─── Page-scoped styles ───────────────────────────────────────────────────────
22const devprogCss = `
23 .devprog-wrap { max-width: 1060px; margin: 0 auto; padding: 0 24px 80px; }
24
25 /* ─── Hero ─── */
26 .devprog-hero {
27 position: relative;
28 margin: 4px 0 48px;
29 padding: 64px 48px 56px;
30 background: var(--bg-elevated);
31 border: 1px solid var(--border);
32 border-radius: 20px;
33 overflow: hidden;
34 text-align: center;
35 }
36 .devprog-hero::before {
37 content: '';
38 position: absolute;
39 top: 0; left: 0; right: 0;
40 height: 2px;
41 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
42 opacity: 0.85;
43 pointer-events: none;
44 }
45 .devprog-hero-bg {
46 position: absolute;
47 inset: -30% -10% auto auto;
48 width: 500px; height: 500px;
49 pointer-events: none;
50 z-index: 0;
51 }
52 .devprog-hero-orb {
53 position: absolute;
54 inset: 0;
55 background: radial-gradient(circle, rgba(140,109,255,0.24), rgba(54,197,214,0.10) 45%, transparent 70%);
56 filter: blur(90px);
57 opacity: 0.7;
58 animation: devprogOrbDrift 14s ease-in-out infinite;
59 }
60 .devprog-hero-orb-2 {
61 position: absolute;
62 inset: auto auto -20% -12%;
63 width: 340px; height: 340px;
64 background: radial-gradient(circle, rgba(54,197,214,0.18), rgba(140,109,255,0.06) 50%, transparent 75%);
65 filter: blur(70px);
66 opacity: 0.5;
67 pointer-events: none;
68 z-index: 0;
69 animation: devprogOrbDrift2 18s ease-in-out infinite;
70 }
71 @keyframes devprogOrbDrift {
72 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
73 50% { transform: scale(1.10) translate(-12px, 10px); opacity: 0.88; }
74 }
75 @keyframes devprogOrbDrift2 {
76 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.40; }
77 50% { transform: scale(1.08) translate(14px, -8px); opacity: 0.65; }
78 }
79 @media (prefers-reduced-motion: reduce) {
80 .devprog-hero-orb, .devprog-hero-orb-2 { animation: none; }
81 }
82 .devprog-hero-inner {
83 position: relative;
84 z-index: 1;
85 display: flex;
86 flex-direction: column;
87 align-items: center;
88 gap: 20px;
89 }
90 .devprog-hero-eyebrow {
91 display: inline-flex;
92 align-items: center;
93 gap: 8px;
94 font-family: var(--font-mono);
95 font-size: 11px;
96 font-weight: 600;
97 letter-spacing: 0.13em;
98 text-transform: uppercase;
99 color: var(--accent);
100 }
101 .devprog-hero-eyebrow-dot {
102 display: inline-block;
103 width: 6px; height: 6px;
104 border-radius: 50%;
105 background: var(--accent);
106 box-shadow: 0 0 8px rgba(140,109,255,0.7);
107 }
108 .devprog-hero-title {
109 font-family: var(--font-display);
110 font-size: clamp(36px, 5.5vw, 68px);
111 font-weight: 800;
112 letter-spacing: -0.036em;
113 line-height: 0.96;
114 color: var(--text-strong);
115 max-width: 780px;
116 margin: 0;
117 }
118 .devprog-hero-title .gradient-text {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .devprog-hero-sub {
126 font-size: 17px;
127 color: var(--text-muted);
128 line-height: 1.6;
129 max-width: 560px;
130 margin: 0;
131 }
132 .devprog-hero-ctas {
133 display: flex;
134 gap: 12px;
135 flex-wrap: wrap;
136 justify-content: center;
137 margin-top: 8px;
138 }
139
140 /* ─── Section cards (the three value props) ─── */
141 .devprog-sections {
142 display: grid;
143 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
144 gap: 20px;
145 margin-bottom: 48px;
146 }
147 .devprog-card {
148 position: relative;
149 background: var(--bg-elevated);
150 border: 1px solid var(--border);
151 border-radius: 16px;
152 padding: 32px 28px;
153 display: flex;
154 flex-direction: column;
155 gap: 14px;
156 overflow: hidden;
157 transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
158 }
159 .devprog-card::before {
160 content: '';
161 position: absolute;
162 top: 0; left: 0; right: 0;
163 height: 2px;
164 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
165 opacity: 0;
166 transition: opacity 180ms ease;
167 pointer-events: none;
168 }
169 .devprog-card:hover {
170 border-color: rgba(140,109,255,0.40);
171 box-shadow: 0 14px 32px -16px rgba(0,0,0,0.55), 0 0 24px -8px rgba(140,109,255,0.18);
172 transform: translateY(-2px);
173 }
174 .devprog-card:hover::before { opacity: 0.85; }
175 .devprog-card-icon {
176 width: 44px; height: 44px;
177 border-radius: 12px;
178 background: var(--accent-gradient-soft, rgba(140,109,255,0.12));
179 border: 1px solid rgba(140,109,255,0.22);
180 display: flex;
181 align-items: center;
182 justify-content: center;
183 color: var(--accent);
184 flex-shrink: 0;
185 }
186 .devprog-card-eyebrow {
187 font-family: var(--font-mono);
188 font-size: 10.5px;
189 font-weight: 600;
190 letter-spacing: 0.11em;
191 text-transform: uppercase;
192 color: var(--accent);
193 }
194 .devprog-card-title {
195 font-family: var(--font-display);
196 font-size: 20px;
197 font-weight: 700;
198 letter-spacing: -0.018em;
199 color: var(--text-strong);
200 margin: 0;
201 line-height: 1.2;
202 }
203 .devprog-card-body {
204 font-size: 14.5px;
205 color: var(--text-muted);
206 line-height: 1.6;
207 margin: 0;
208 flex: 1;
209 }
210 .devprog-card-link {
211 display: inline-flex;
212 align-items: center;
213 gap: 5px;
214 font-size: 13.5px;
215 font-weight: 600;
216 color: var(--text-link);
217 text-decoration: none;
218 transition: color 120ms ease, gap 120ms ease;
219 margin-top: 4px;
220 }
221 .devprog-card-link:hover {
222 color: var(--accent-hover);
223 gap: 8px;
224 text-decoration: none;
225 }
226
227 /* ─── Revenue highlight strip ─── */
228 .devprog-revenue-strip {
229 display: flex;
230 gap: 0;
231 border: 1px solid var(--border);
232 border-radius: 14px;
233 overflow: hidden;
234 margin-bottom: 48px;
235 background: var(--bg-elevated);
236 }
237 .devprog-revenue-pct {
238 display: flex;
239 flex-direction: column;
240 align-items: center;
241 justify-content: center;
242 gap: 4px;
243 padding: 28px 36px;
244 flex: 0 0 auto;
245 border-right: 1px solid var(--border);
246 }
247 .devprog-revenue-num {
248 font-family: var(--font-display);
249 font-size: 52px;
250 font-weight: 800;
251 letter-spacing: -0.04em;
252 line-height: 1;
253 background-image: linear-gradient(135deg, #a48bff 0%, #36c5d6 100%);
254 -webkit-background-clip: text;
255 background-clip: text;
256 -webkit-text-fill-color: transparent;
257 color: transparent;
258 }
259 .devprog-revenue-label {
260 font-size: 12px;
261 color: var(--text-muted);
262 font-weight: 500;
263 text-align: center;
264 }
265 .devprog-revenue-body {
266 display: flex;
267 flex-direction: column;
268 justify-content: center;
269 padding: 28px 32px;
270 gap: 8px;
271 }
272 .devprog-revenue-title {
273 font-family: var(--font-display);
274 font-size: 18px;
275 font-weight: 700;
276 letter-spacing: -0.018em;
277 color: var(--text-strong);
278 margin: 0;
279 }
280 .devprog-revenue-desc {
281 font-size: 14.5px;
282 color: var(--text-muted);
283 line-height: 1.55;
284 margin: 0;
285 max-width: 520px;
286 }
287
288 /* ─── Application form card ─── */
289 .devprog-apply {
290 background: var(--bg-elevated);
291 border: 1px solid var(--border);
292 border-radius: 18px;
293 padding: 44px 48px;
294 position: relative;
295 overflow: hidden;
296 }
297 .devprog-apply::before {
298 content: '';
299 position: absolute;
300 top: 0; left: 0; right: 0;
301 height: 2px;
302 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
303 opacity: 0.75;
304 pointer-events: none;
305 }
306 .devprog-apply-inner { max-width: 640px; }
307 .devprog-apply-eyebrow {
308 font-family: var(--font-mono);
309 font-size: 11px;
310 font-weight: 600;
311 letter-spacing: 0.12em;
312 text-transform: uppercase;
313 color: var(--accent);
314 margin-bottom: 12px;
315 }
316 .devprog-apply-title {
317 font-family: var(--font-display);
318 font-size: clamp(24px, 3.5vw, 36px);
319 font-weight: 800;
320 letter-spacing: -0.028em;
321 line-height: 1.1;
322 color: var(--text-strong);
323 margin: 0 0 10px;
324 }
325 .devprog-apply-sub {
326 font-size: 15px;
327 color: var(--text-muted);
328 line-height: 1.6;
329 margin: 0 0 28px;
330 }
331 .devprog-form { display: flex; flex-direction: column; gap: 16px; }
332 .devprog-field { display: flex; flex-direction: column; gap: 6px; }
333 .devprog-label {
334 font-size: 13px;
335 font-weight: 600;
336 color: var(--text-strong);
337 letter-spacing: -0.008em;
338 }
339 .devprog-input,
340 .devprog-textarea {
341 background: var(--bg-secondary);
342 border: 1px solid var(--border);
343 border-radius: var(--r);
344 padding: 9px 12px;
345 font-family: var(--font-sans);
346 font-size: 14px;
347 color: var(--text);
348 transition: border-color 120ms ease, box-shadow 120ms ease;
349 width: 100%;
350 }
351 .devprog-input::placeholder,
352 .devprog-textarea::placeholder { color: var(--text-faint); }
353 .devprog-input:focus,
354 .devprog-textarea:focus {
355 outline: none;
356 border-color: var(--border-focus);
357 box-shadow: var(--ring);
358 }
359 .devprog-textarea { resize: vertical; min-height: 90px; }
360 .devprog-form-actions {
361 display: flex;
362 align-items: center;
363 gap: 16px;
364 margin-top: 8px;
365 flex-wrap: wrap;
366 }
367 .devprog-form-note {
368 font-size: 12.5px;
369 color: var(--text-faint);
370 line-height: 1.5;
371 }
372 .devprog-success {
373 display: flex;
374 align-items: flex-start;
375 gap: 14px;
376 background: rgba(52,211,153,0.06);
377 border: 1px solid rgba(52,211,153,0.28);
378 border-radius: 12px;
379 padding: 18px 20px;
380 font-size: 14.5px;
381 color: var(--text-strong);
382 line-height: 1.55;
383 }
384 .devprog-success-icon {
385 width: 24px; height: 24px;
386 border-radius: 50%;
387 background: rgba(52,211,153,0.15);
388 border: 1px solid rgba(52,211,153,0.35);
389 display: flex; align-items: center; justify-content: center;
390 font-size: 14px;
391 flex-shrink: 0;
392 color: #34d399;
393 }
394
395 /* ─── Docs link strip ─── */
396 .devprog-docs-strip {
397 display: flex;
398 align-items: center;
399 gap: 12px;
400 padding: 18px 24px;
401 background: var(--bg-elevated);
402 border: 1px solid var(--border);
403 border-radius: 12px;
404 margin-top: 28px;
405 font-size: 14px;
406 }
407 .devprog-docs-strip-icon { color: var(--accent); flex-shrink: 0; }
408 .devprog-docs-strip-text { flex: 1; color: var(--text-muted); }
409 .devprog-docs-strip-link {
410 font-weight: 600;
411 color: var(--text-link);
412 white-space: nowrap;
413 }
414
415 @media (max-width: 640px) {
416 .devprog-hero { padding: 40px 24px 36px; }
417 .devprog-apply { padding: 32px 24px; }
418 .devprog-revenue-strip { flex-direction: column; }
419 .devprog-revenue-pct { border-right: none; border-bottom: 1px solid var(--border); }
420 }
421`;
422
423// ─── Route: GET /developer-program ───────────────────────────────────────────
424developerProgram.get("/developer-program", (c) => {
425 const user = c.get("user");
426 const applied = c.req.query("applied") === "1";
427
428 return c.html(
429 <Layout
430 title="Developer Program"
431 user={user}
432 description="Build AI agents on Gluecron, list them in the marketplace, and keep 70% of every sale. Apply for the partner program today."
433 >
434 <style dangerouslySetInnerHTML={{ __html: devprogCss }} />
435 <div class="devprog-wrap">
436
437 {/* ── Hero ── */}
438 <section class="devprog-hero" aria-labelledby="devprog-hero-h">
439 <div class="devprog-hero-bg" aria-hidden="true">
440 <div class="devprog-hero-orb" />
441 <div class="devprog-hero-orb-2" />
442 </div>
443 <div class="devprog-hero-inner">
444 <div class="devprog-hero-eyebrow">
445 <span class="devprog-hero-eyebrow-dot" aria-hidden="true" />
446 Gluecron Developer Program
447 </div>
448 <h1 id="devprog-hero-h" class="devprog-hero-title">
449 Build on Gluecron.{" "}
450 <span class="gradient-text">Earn revenue.</span>
451 </h1>
452 <p class="devprog-hero-sub">
453 List your AI agent in the Gluecron marketplace. Set your price. Ship
454 to thousands of developer teams already using Gluecron for their daily
455 workflow.
456 </p>
457 <div class="devprog-hero-ctas">
458 <a href="#apply" class="btn btn-primary btn-xl">
459 Apply for partner status
460 <span aria-hidden="true">{" →"}</span>
461 </a>
462 <a href="/help#agents" class="btn btn-secondary btn-xl">
463 Read the docs
464 </a>
465 </div>
466 </div>
467 </section>
468
469 {/* ── Three sections ── */}
470 <div class="devprog-sections">
471 {/* 1 — Publish an agent */}
472 <div class="devprog-card">
473 <div class="devprog-card-icon" aria-hidden="true">
474 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
475 <path d="M12 2l2.39 5.95L20 9l-4.5 3.9L17 19l-5-3.2L7 19l1.5-6.1L4 9l5.61-1.05L12 2z" />
476 </svg>
477 </div>
478 <div class="devprog-card-eyebrow">Step 1</div>
479 <h2 class="devprog-card-title">Publish an agent</h2>
480 <p class="devprog-card-body">
481 List your AI agent in the Gluecron marketplace. Set your price.
482 Gluecron takes 30% — you keep 70%. Your agent is discoverable
483 by every developer on the platform the moment it's approved.
484 </p>
485 <a href="/marketplace/agents/new" class="devprog-card-link">
486 Publish your first agent
487 <span aria-hidden="true"></span>
488 </a>
489 </div>
490
491 {/* 2 — Revenue share */}
492 <div class="devprog-card">
493 <div class="devprog-card-icon" aria-hidden="true">
494 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
495 <line x1="12" y1="1" x2="12" y2="23" />
496 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
497 </svg>
498 </div>
499 <div class="devprog-card-eyebrow">How you earn</div>
500 <h2 class="devprog-card-title">Revenue share</h2>
501 <p class="devprog-card-body">
502 The 30% platform cut is already in the schema. When your agent is
503 installed by a team, you earn automatically — no invoicing, no
504 chasing payments. Payouts go out monthly to your connected account.
505 </p>
506 <a href="/help#agents" class="devprog-card-link">
507 See payout docs
508 <span aria-hidden="true"></span>
509 </a>
510 </div>
511
512 {/* 3 — Partner badge */}
513 <div class="devprog-card">
514 <div class="devprog-card-icon" aria-hidden="true">
515 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
516 <path d="M12 2.5l8 3.5v6c0 5-3.5 8.5-8 9.5-4.5-1-8-4.5-8-9.5v-6z" />
517 <path d="M9 12l2 2 4-4" />
518 </svg>
519 </div>
520 <div class="devprog-card-eyebrow">Verified status</div>
521 <h2 class="devprog-card-title">gluecron-partner badge</h2>
522 <p class="devprog-card-body">
523 Verified partners get a badge on their profile and priority
524 placement in the marketplace. The badge signals to buyers that
525 your agent has been reviewed by the Gluecron team and meets our
526 quality and security bar.
527 </p>
528 <a href="#apply" class="devprog-card-link">
529 Apply now
530 <span aria-hidden="true"></span>
531 </a>
532 </div>
533 </div>
534
535 {/* ── Revenue highlight strip ── */}
536 <div class="devprog-revenue-strip" aria-label="Revenue split: you keep 70%">
537 <div class="devprog-revenue-pct">
538 <div class="devprog-revenue-num">70%</div>
539 <div class="devprog-revenue-label">you keep</div>
540 </div>
541 <div class="devprog-revenue-body">
542 <h2 class="devprog-revenue-title">The math is simple.</h2>
543 <p class="devprog-revenue-desc">
544 Gluecron takes 30% to cover infrastructure, payments, and
545 marketplace distribution. You keep 70% of every subscription or
546 one-time purchase — no hidden fees, no surprise deductions. The
547 split is encoded in the platform schema and applied automatically
548 at payout time.
549 </p>
550 </div>
551 </div>
552
553 {/* ── Application form ── */}
554 <section id="apply" class="devprog-apply" aria-labelledby="devprog-apply-h">
555 <div class="devprog-apply-inner">
556 <div class="devprog-apply-eyebrow">Partner application</div>
557 <h2 id="devprog-apply-h" class="devprog-apply-title">
558 Apply for partner status
559 </h2>
560 <p class="devprog-apply-sub">
561 Tell us about the agent you're building. We review every
562 application within 5 business days and reply to the email on your
563 Gluecron account.
564 </p>
565
566 {applied ? (
567 <div class="devprog-success" role="alert">
568 <div class="devprog-success-icon" aria-hidden="true"></div>
569 <div>
570 <strong>Application received.</strong> We'll review your
571 submission and reply within 5 business days. Keep building!
572 </div>
573 </div>
574 ) : (
575 <form
576 method="post"
577 action="/developer-program"
578 class="devprog-form"
579 >
580 <div class="devprog-field">
581 <label class="devprog-label" for="dp-agent-name">
582 Agent name
583 </label>
584 <input
585 id="dp-agent-name"
586 name="agent_name"
587 type="text"
588 class="devprog-input"
589 placeholder="e.g. PR Summarizer Pro"
590 required
591 maxlength={120}
592 />
593 </div>
594 <div class="devprog-field">
595 <label class="devprog-label" for="dp-agent-desc">
596 What does it do?
597 </label>
598 <textarea
599 id="dp-agent-desc"
600 name="agent_description"
601 class="devprog-textarea"
602 placeholder="Describe your agent in 2–4 sentences. What problem does it solve? Who is it for?"
603 required
604 maxlength={800}
605 />
606 </div>
607 <div class="devprog-field">
608 <label class="devprog-label" for="dp-pricing">
609 Pricing model
610 </label>
611 <input
612 id="dp-pricing"
613 name="pricing_model"
614 type="text"
615 class="devprog-input"
616 placeholder='e.g. "$9/mo per seat" or "one-time $49"'
617 maxlength={120}
618 />
619 </div>
620 <div class="devprog-field">
621 <label class="devprog-label" for="dp-repo">
622 Gluecron repo or demo URL
623 </label>
624 <input
625 id="dp-repo"
626 name="repo_url"
627 type="url"
628 class="devprog-input"
629 placeholder="https://gluecron.com/you/your-agent"
630 />
631 </div>
632 <div class="devprog-form-actions">
633 <button type="submit" class="btn btn-primary">
634 Submit application
635 </button>
636 <p class="devprog-form-note">
637 We'll reply to the email on your Gluecron account.
638 Applications are reviewed manually — no bots.
639 </p>
640 </div>
641 </form>
642 )}
643
644 {/* Docs link */}
645 <div class="devprog-docs-strip" role="complementary">
646 <svg class="devprog-docs-strip-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
647 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
648 <polyline points="14 2 14 8 20 8" />
649 <line x1="16" y1="13" x2="8" y2="13" />
650 <line x1="16" y1="17" x2="8" y2="17" />
651 <polyline points="10 9 9 9 8 9" />
652 </svg>
653 <span class="devprog-docs-strip-text">
654 Want to start building before applying?
655 </span>
656 <a href="/help#agents" class="devprog-docs-strip-link">
657 Read the agent publishing docs →
658 </a>
659 </div>
660 </div>
661 </section>
662
663 </div>
664 </Layout>
665 );
666});
667
668// ─── Route: POST /developer-program ──────────────────────────────────────────
669// Logs the application for now. Future: insert into partner_applications table.
670developerProgram.post("/developer-program", async (c) => {
671 const user = c.get("user");
672 const body = await c.req.parseBody();
673
674 // Log the application so it's visible in server output / observability
675 console.log("[developer-program] partner application received", {
676 userId: user?.id ?? null,
677 username: user?.username ?? null,
678 agentName: body["agent_name"],
679 pricingModel: body["pricing_model"],
680 repoUrl: body["repo_url"],
681 // description intentionally omitted from log to keep it brief
682 at: new Date().toISOString(),
683 });
684
685 return c.redirect("/developer-program?applied=1");
686});
687
688export default developerProgram;
Modifiedsrc/routes/discussions.tsx+679−200View fileUnifiedSplit
Large file (1,157 lines). Load full file
Addedsrc/routes/docs.tsx+1595−0View fileUnifiedSplit
Large file (1,595 lines). Load full file
Modifiedsrc/routes/editor.tsx+565−10View fileUnifiedSplit
355355`;
356356}
357357
358/**
359 * AI inline suggestion + explain + fix script.
360 *
361 * Ghost-text suggestions: debounced 800ms after typing stops, sends
362 * the content before the cursor to /api/ai/suggest, shows the result
363 * as dimmed italic text after the cursor inside the textarea.
364 * Tab = accept, Escape = dismiss.
365 *
366 * Explain: sends selected text (or full content) to /api/ai/explain
367 * and shows a panel below the editor.
368 *
369 * Fix: sends error from ?error= query param + code to /api/ai/fix and
370 * shows a panel with an Apply button.
371 *
372 * Only wires up if window.__aiEditorEnabled === true (set by the server
373 * when ANTHROPIC_API_KEY is configured).
374 */
375function AI_EDITOR_SCRIPT(args: {
376 lang: string;
377 gateError: string; // JSON-safe error string, may be empty
378}): string {
379 const safe = (v: string) =>
380 JSON.stringify(v)
381 .split("<").join("\\u003C")
382 .split(">").join("\\u003E")
383 .split("&").join("\\u0026");
384
385 const lang = safe(args.lang);
386 const gateError = safe(args.gateError);
387
388 return `
389(function() {
390 try {
391 if (!window.__aiEditorEnabled) return;
392
393 var lang = ${lang};
394 var gateError = ${gateError};
395
396 // ── Find DOM elements ──────────────────────────────────────────────
397 // Works for both new-file (...-new) and edit (...-edit) pages.
398 var ta = document.querySelector('textarea[name="content"]');
399 if (!ta) return;
400
401 var taField = ta.closest('.editor-field');
402 if (!taField) return;
403
404 // Ensure the textarea is wrapped in .editor-wrapper for positioning
405 if (!ta.parentElement || !ta.parentElement.classList.contains('editor-wrapper')) {
406 var wrapper = document.createElement('div');
407 wrapper.className = 'editor-wrapper';
408 wrapper.style.position = 'relative';
409 ta.parentNode.insertBefore(wrapper, ta);
410 wrapper.appendChild(ta);
411 }
412
413 var editorWrapper = ta.parentElement;
414
415 // Loading spinner
416 var spinner = document.createElement('div');
417 spinner.className = 'ai-loading';
418 spinner.innerHTML = '<div class="ai-spinner"></div><span>AI</span>';
419 editorWrapper.appendChild(spinner);
420
421 // Suggestion hint line
422 var hint = document.createElement('div');
423 hint.className = 'ai-suggestion-hint';
424 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
425 taField.appendChild(hint);
426
427 // Explain panel
428 var explainPanel = document.createElement('div');
429 explainPanel.className = 'ai-explain-panel';
430 explainPanel.innerHTML = '<div class="ai-explain-panel-header"><span class="ai-explain-panel-title">✨ Code Explanation</span><button class="ai-explain-panel-close" type="button" title="Close">✕</button></div><div class="ai-explain-panel-body"></div>';
431 taField.appendChild(explainPanel);
432
433 // Fix panel (only when there's a gate error)
434 var fixPanel = null;
435 var fixCode = '';
436 if (gateError) {
437 fixPanel = document.createElement('div');
438 fixPanel.className = 'ai-fix-panel';
439 fixPanel.innerHTML = '<div class="ai-fix-panel-header"><span class="ai-fix-panel-title">⚡ AI Fix Suggestion</span></div><div class="ai-fix-panel-explanation"></div><button class="ai-fix-panel-apply" type="button">Apply fix</button>';
440 taField.appendChild(fixPanel);
441 }
442
443 // ── State ──────────────────────────────────────────────────────────
444 var currentSuggestion = '';
445 var suggestionPos = 0; // cursor position when suggestion was fetched
446 var debounceTimer = null;
447 var abortController = null;
448
449 // ── Ghost text rendering via textarea mirror technique ─────────────
450 // We render ghost text as a ::after-like element via a positioned div
451 // that mirrors the textarea's scroll position and font metrics.
452 // This is simpler than a full overlay mirror.
453 var ghost = document.createElement('div');
454 ghost.style.cssText = [
455 'position:absolute',
456 'left:0','top:0','right:0','bottom:0',
457 'pointer-events:none',
458 'overflow:hidden',
459 'white-space:pre-wrap',
460 'word-wrap:break-word',
461 'box-sizing:border-box',
462 'padding:12px 14px',
463 'font-family:var(--font-mono)',
464 'font-size:13px',
465 'line-height:1.55',
466 'color:transparent',
467 'z-index:2',
468 ].join(';');
469 editorWrapper.appendChild(ghost);
470
471 function renderGhost() {
472 if (!currentSuggestion) {
473 ghost.innerHTML = '';
474 hint.classList.remove('visible');
475 return;
476 }
477 var before = ta.value.slice(0, suggestionPos);
478 // Escape HTML
479 var esc = function(s) {
480 return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
481 };
482 ghost.innerHTML = esc(before) +
483 '<span style="color:#666;font-style:italic">' + esc(currentSuggestion) + '</span>';
484 hint.classList.add('visible');
485 }
486
487 function clearSuggestion() {
488 currentSuggestion = '';
489 suggestionPos = 0;
490 renderGhost();
491 }
492
493 // ── Sync ghost scroll with textarea ───────────────────────────────
494 ta.addEventListener('scroll', function() {
495 ghost.scrollTop = ta.scrollTop;
496 ghost.scrollLeft = ta.scrollLeft;
497 });
498
499 // ── Fetch suggestion ──────────────────────────────────────────────
500 function fetchSuggestion() {
501 if (abortController) abortController.abort();
502 abortController = new AbortController();
503
504 var cursor = ta.selectionStart;
505 var code = ta.value;
506 var before = code.slice(0, cursor);
507
508 // Skip if the before-cursor content is too short or ends in whitespace-only
509 if (before.trim().length < 3) return;
510
511 spinner.classList.add('visible');
512
513 fetch('/api/ai/suggest', {
514 method: 'POST',
515 headers: { 'content-type': 'application/json' },
516 credentials: 'same-origin',
517 signal: abortController.signal,
518 body: JSON.stringify({ code: before, language: lang, cursor: before.length }),
519 })
520 .then(function(r) { return r.json(); })
521 .then(function(j) {
522 spinner.classList.remove('visible');
523 if (j && typeof j.suggestion === 'string' && j.suggestion.trim()) {
524 currentSuggestion = j.suggestion;
525 suggestionPos = ta.selectionStart;
526 renderGhost();
527 }
528 if (j && j.error && j.error.includes('quota')) {
529 // Show quota message briefly in hint
530 hint.textContent = j.error;
531 hint.classList.add('visible');
532 setTimeout(function() {
533 hint.textContent = 'Tab to accept suggestion · Esc to dismiss';
534 hint.classList.remove('visible');
535 }, 4000);
536 }
537 })
538 .catch(function(e) {
539 spinner.classList.remove('visible');
540 if (e && e.name !== 'AbortError') {
541 console.debug('[ai-editor] suggest error', e);
542 }
543 });
544 }
545
546 // ── Debounce typing ───────────────────────────────────────────────
547 ta.addEventListener('input', function() {
548 clearSuggestion();
549 if (debounceTimer) clearTimeout(debounceTimer);
550 debounceTimer = setTimeout(fetchSuggestion, 800);
551 });
552
553 // ── Key handling: Tab = accept, Escape = dismiss ──────────────────
554 ta.addEventListener('keydown', function(ev) {
555 if (currentSuggestion) {
556 if (ev.key === 'Tab') {
557 ev.preventDefault();
558 // Insert suggestion at cursor
559 var start = ta.selectionStart;
560 var end = ta.selectionEnd;
561 var before = ta.value.slice(0, start);
562 var after = ta.value.slice(end);
563 ta.value = before + currentSuggestion + after;
564 var newPos = start + currentSuggestion.length;
565 ta.selectionStart = ta.selectionEnd = newPos;
566 // Trigger input event so the form stays in sync
567 ta.dispatchEvent(new Event('input', { bubbles: true }));
568 clearSuggestion();
569 return;
570 }
571 if (ev.key === 'Escape') {
572 ev.preventDefault();
573 clearSuggestion();
574 return;
575 }
576 // Any other key: discard suggestion
577 clearSuggestion();
578 }
579 });
580
581 // Dismiss on click (cursor moved)
582 ta.addEventListener('click', clearSuggestion);
583
584 // ── Explain panel ──────────────────────────────────────────────────
585 var explainBody = explainPanel.querySelector('.ai-explain-panel-body');
586 var explainClose = explainPanel.querySelector('.ai-explain-panel-close');
587 if (explainClose) {
588 explainClose.addEventListener('click', function() {
589 explainPanel.classList.remove('visible');
590 });
591 }
592
593 var explainBtn = document.getElementById('ai-explain-btn');
594 if (explainBtn) {
595 explainBtn.addEventListener('click', function(ev) {
596 ev.preventDefault();
597 var selected = ta.value.slice(ta.selectionStart, ta.selectionEnd);
598 var code = selected.trim() || ta.value;
599 if (!code.trim()) return;
600 explainBtn.disabled = true;
601 explainBody.textContent = 'Asking Claude…';
602 explainPanel.classList.add('visible');
603 fetch('/api/ai/explain', {
604 method: 'POST',
605 headers: { 'content-type': 'application/json' },
606 credentials: 'same-origin',
607 body: JSON.stringify({ code: code.slice(0, 4000), language: lang }),
608 })
609 .then(function(r) { return r.json(); })
610 .then(function(j) {
611 explainBtn.disabled = false;
612 if (j && j.explanation) {
613 explainBody.textContent = j.explanation;
614 } else {
615 explainBody.textContent = (j && j.error) || 'No explanation returned.';
616 }
617 })
618 .catch(function() {
619 explainBtn.disabled = false;
620 explainBody.textContent = 'Network error — please try again.';
621 });
622 });
623 }
624
625 // ── Fix panel ─────────────────────────────────────────────────────
626 if (fixPanel && gateError) {
627 var fixBtn = document.getElementById('ai-fix-btn');
628 var fixExplanation = fixPanel.querySelector('.ai-fix-panel-explanation');
629 var fixApply = fixPanel.querySelector('.ai-fix-panel-apply');
630
631 if (fixBtn) {
632 fixBtn.addEventListener('click', function(ev) {
633 ev.preventDefault();
634 fixBtn.disabled = true;
635 fixExplanation.textContent = 'Analyzing error and generating fix…';
636 fixPanel.classList.add('visible');
637 fetch('/api/ai/fix', {
638 method: 'POST',
639 headers: { 'content-type': 'application/json' },
640 credentials: 'same-origin',
641 body: JSON.stringify({ code: ta.value.slice(0, 4000), error: gateError, language: lang }),
642 })
643 .then(function(r) { return r.json(); })
644 .then(function(j) {
645 fixBtn.disabled = false;
646 if (j && j.fix) {
647 fixCode = j.fix;
648 fixExplanation.textContent = j.explanation || 'Fix ready.';
649 fixApply.style.display = 'inline-flex';
650 } else {
651 fixExplanation.textContent = (j && j.error) || 'Could not generate fix.';
652 fixApply.style.display = 'none';
653 }
654 })
655 .catch(function() {
656 fixBtn.disabled = false;
657 fixExplanation.textContent = 'Network error — please try again.';
658 });
659 });
660
661 // Auto-trigger if gate error is present and editor just loaded
662 setTimeout(function() { fixBtn.click(); }, 600);
663 }
664
665 if (fixApply) {
666 fixApply.addEventListener('click', function() {
667 if (!fixCode) return;
668 if (confirm('Apply the AI fix? This will replace the current editor content.')) {
669 ta.value = fixCode;
670 ta.dispatchEvent(new Event('input', { bubbles: true }));
671 fixPanel.classList.remove('visible');
672 }
673 });
674 }
675 }
676
677 } catch(e) {
678 console.debug('[ai-editor] init error', e);
679 }
680})();
681`;
682}
683
358684// ─── Scoped CSS (.editor-*) ─────────────────────────────────────────────────
359685// Every selector is prefixed `.editor-*` so this surface can't bleed into
360686// the repo header / nav above. Tokens reused from layout (--bg-elevated,
626952 .editor-field .cm-editor .cm-scroller {
627953 font-family: var(--font-mono);
628954 }
955
956 /* ─── AI inline suggestion overlay ─── */
957 .editor-wrapper {
958 position: relative;
959 }
960 .ai-suggestion {
961 position: absolute;
962 color: #555;
963 font-style: italic;
964 pointer-events: none;
965 white-space: pre;
966 font-family: var(--font-mono);
967 font-size: 13px;
968 line-height: 1.55;
969 }
970 .ai-loading {
971 position: absolute;
972 right: 10px;
973 top: 10px;
974 font-size: 11px;
975 color: #888;
976 display: none;
977 align-items: center;
978 gap: 5px;
979 pointer-events: none;
980 z-index: 10;
981 }
982 .ai-loading.visible { display: flex; }
983 .ai-spinner {
984 width: 10px;
985 height: 10px;
986 border: 1.5px solid rgba(164,139,255,0.3);
987 border-top-color: #a48bff;
988 border-radius: 50%;
989 animation: ai-spin 0.7s linear infinite;
990 }
991 @keyframes ai-spin {
992 to { transform: rotate(360deg); }
993 }
994
995 /* ─── AI explain panel ─── */
996 .ai-explain-panel {
997 background: #1a1a2e;
998 border: 1px solid #2a2a4a;
999 border-radius: 8px;
1000 padding: 14px 16px;
1001 margin-top: 10px;
1002 display: none;
1003 position: relative;
1004 }
1005 .ai-explain-panel.visible { display: block; }
1006 .ai-explain-panel-header {
1007 display: flex;
1008 align-items: center;
1009 justify-content: space-between;
1010 margin-bottom: 8px;
1011 }
1012 .ai-explain-panel-title {
1013 font-size: 11px;
1014 font-weight: 700;
1015 text-transform: uppercase;
1016 letter-spacing: 0.1em;
1017 color: #a48bff;
1018 }
1019 .ai-explain-panel-close {
1020 background: none;
1021 border: none;
1022 color: var(--text-faint);
1023 cursor: pointer;
1024 font-size: 14px;
1025 line-height: 1;
1026 padding: 2px 5px;
1027 border-radius: 4px;
1028 }
1029 .ai-explain-panel-close:hover { color: var(--text); background: rgba(255,255,255,0.06); }
1030 .ai-explain-panel-body {
1031 font-size: 13px;
1032 color: var(--text-muted);
1033 line-height: 1.6;
1034 white-space: pre-wrap;
1035 word-break: break-word;
1036 }
1037
1038 /* ─── AI fix panel ─── */
1039 .ai-fix-panel {
1040 background: #1a1a2e;
1041 border: 1px solid #2a2a4a;
1042 border-left: 3px solid #f59e0b;
1043 border-radius: 8px;
1044 padding: 14px 16px;
1045 margin-top: 10px;
1046 display: none;
1047 }
1048 .ai-fix-panel.visible { display: block; }
1049 .ai-fix-panel-header {
1050 display: flex;
1051 align-items: center;
1052 justify-content: space-between;
1053 margin-bottom: 8px;
1054 }
1055 .ai-fix-panel-title {
1056 font-size: 11px;
1057 font-weight: 700;
1058 text-transform: uppercase;
1059 letter-spacing: 0.1em;
1060 color: #f59e0b;
1061 }
1062 .ai-fix-panel-explanation {
1063 font-size: 12.5px;
1064 color: var(--text-muted);
1065 margin-bottom: 10px;
1066 line-height: 1.5;
1067 }
1068 .ai-fix-panel-apply {
1069 display: inline-flex;
1070 align-items: center;
1071 gap: 5px;
1072 padding: 6px 12px;
1073 background: rgba(245,158,11,0.12);
1074 border: 1px solid rgba(245,158,11,0.35);
1075 border-radius: 6px;
1076 color: #f59e0b;
1077 font-size: 12px;
1078 font-weight: 600;
1079 cursor: pointer;
1080 font: inherit;
1081 transition: background 100ms ease;
1082 }
1083 .ai-fix-panel-apply:hover { background: rgba(245,158,11,0.22); }
1084
1085 /* ─── AI badge ─── */
1086 .ai-powered-badge {
1087 display: inline-flex;
1088 align-items: center;
1089 gap: 4px;
1090 font-size: 11px;
1091 color: #a48bff;
1092 background: rgba(140,109,255,0.08);
1093 border: 1px solid rgba(140,109,255,0.20);
1094 border-radius: 999px;
1095 padding: 3px 8px;
1096 font-weight: 600;
1097 letter-spacing: 0.03em;
1098 white-space: nowrap;
1099 }
1100
1101 /* ─── AI suggestion hint ─── */
1102 .ai-suggestion-hint {
1103 font-size: 11px;
1104 color: var(--text-faint);
1105 margin-top: 5px;
1106 display: none;
1107 }
1108 .ai-suggestion-hint.visible { display: block; }
6291109`;
6301110
6311111function IconBranch() {
6441124 const { owner, repo } = c.req.param();
6451125 const user = c.get("user")!;
6461126 const refAndPath = c.req.param("ref");
1127 const aiEnabled = isAiAvailable();
6471128
6481129 // Parse ref — use first segment
6491130 const slashIdx = refAndPath.indexOf("/");
6821163 <span class="editor-path-sep">/</span>
6831164 <span class="editor-path-name">new file…</span>
6841165 </div>
685 <span class="editor-branch-pill" title="Target branch">
686 <IconBranch />
687 {ref}
688 </span>
1166 <div style="display:flex;align-items:center;gap:8px">
1167 {aiEnabled && (
1168 <span class="ai-powered-badge" title="Claude-powered inline suggestions">
1169 ✨ AI-powered editor
1170 </span>
1171 )}
1172 <span class="editor-branch-pill" title="Target branch">
1173 <IconBranch />
1174 {ref}
1175 </span>
1176 </div>
6891177 </div>
6901178
6911179 <form
7421230 <button type="submit" class="editor-btn editor-btn-primary">
7431231 Commit new file
7441232 </button>
1233 {aiEnabled && (
1234 <button
1235 type="button"
1236 id="ai-explain-btn"
1237 class="editor-btn editor-btn-ai"
1238 title="Explain selected code (or full file) with Claude"
1239 >
1240 Explain code
1241 </button>
1242 )}
7451243 <a href={`/${owner}/${repo}`} class="editor-btn editor-btn-ghost">
7461244 Cancel
7471245 </a>
7561254 }),
7571255 }}
7581256 />
1257 {aiEnabled && (
1258 <script
1259 dangerouslySetInnerHTML={{
1260 __html:
1261 `window.__aiEditorEnabled = true;\n` +
1262 AI_EDITOR_SCRIPT({ lang: "plaintext", gateError: "" }),
1263 }}
1264 />
1265 )}
7591266 </form>
7601267 </div>
7611268 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
8651372 const { owner, repo } = c.req.param();
8661373 const user = c.get("user")!;
8671374 const refAndPath = c.req.param("ref");
1375 const aiEnabled = isAiAvailable();
8681376
8691377 // Parse ref/path
8701378 const slashIdx = refAndPath.indexOf("/");
8841392
8851393 const fileName = filePath.split("/").pop() || filePath;
8861394 const dirParts = filePath.split("/").slice(0, -1);
1395 const lang = detectEditorLang(filePath);
1396
1397 // GateTest error from query param (e.g. redirect after failed push gate)
1398 const gateError = String(c.req.query("error") ?? "").slice(0, 500);
8871399
8881400 return c.html(
8891401 <Layout title={`Editing ${filePath}${owner}/${repo}`} user={user}>
9331445 <span class="editor-path-sep">/</span>
9341446 <span class="editor-path-name">{fileName}</span>
9351447 </div>
936 <span class="editor-branch-pill" title="Target branch">
937 <IconBranch />
938 {ref}
939 </span>
1448 <div style="display:flex;align-items:center;gap:8px">
1449 {aiEnabled && (
1450 <span class="ai-powered-badge" title="Claude-powered inline suggestions, explain, and fix">
1451 ✨ AI-powered editor
1452 </span>
1453 )}
1454 <span class="editor-branch-pill" title="Target branch">
1455 <IconBranch />
1456 {ref}
1457 </span>
1458 </div>
9401459 </div>
9411460
1461 {gateError && (
1462 <div style="margin-bottom:var(--space-3);padding:10px 14px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.35);border-radius:10px;font-size:13px;color:#f59e0b;line-height:1.5">
1463 <strong>GateTest error:</strong>{" "}{gateError}
1464 </div>
1465 )}
1466
9421467 <form
9431468 method="post"
9441469 action={`/${owner}/${repo}/edit/${ref}/${filePath}`}
9551480 name="content"
9561481 rows={25}
9571482 spellcheck={false}
958 data-lang={detectEditorLang(filePath)}
1483 data-lang={lang}
9591484 >{blob.content}</textarea>
9601485 </div>
9611486 <div class="editor-field" style="margin-bottom:0">
9821507 >
9831508 {"✨"} Suggest with AI
9841509 </button>
1510 {aiEnabled && (
1511 <button
1512 type="button"
1513 id="ai-explain-btn"
1514 class="editor-btn editor-btn-ai"
1515 title="Explain selected code (or full file) with Claude"
1516 >
1517 Explain code
1518 </button>
1519 )}
1520 {aiEnabled && gateError && (
1521 <button
1522 type="button"
1523 id="ai-fix-btn"
1524 class="editor-btn editor-btn-ai"
1525 title="Ask Claude to fix the GateTest error"
1526 style="border-color:rgba(245,158,11,0.45);color:#f59e0b"
1527 >
1528 ⚡ Fix error
1529 </button>
1530 )}
9851531 <a
9861532 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
9871533 class="editor-btn editor-btn-ghost"
10051551 __html: CODEMIRROR_INIT_SCRIPT({
10061552 textareaId: "editor-content-edit",
10071553 wrapperId: "editor-cm-edit",
1008 lang: detectEditorLang(filePath),
1554 lang,
10091555 }),
10101556 }}
10111557 />
1558 {aiEnabled && (
1559 <script
1560 dangerouslySetInnerHTML={{
1561 __html:
1562 `window.__aiEditorEnabled = true;\n` +
1563 AI_EDITOR_SCRIPT({ lang, gateError }),
1564 }}
1565 />
1566 )}
10121567 </form>
10131568 </div>
10141569 <style dangerouslySetInnerHTML={{ __html: editorStyles }} />
Addedsrc/routes/enterprise.tsx+711−0View fileUnifiedSplit
1/**
2 * Enterprise sales page — GET /enterprise
3 *
4 * Targets engineering leaders and procurement teams evaluating Gluecron for
5 * large-scale or compliance-sensitive deployments. Covers:
6 * - Custom pricing (volume repos, unlimited AI usage)
7 * - SSO (SAML/OIDC, already built)
8 * - Dedicated SLA support
9 * - Data residency (EU / US choice)
10 * - SOC 2 Type II (in progress)
11 * - Audit log SIEM export (GET /api/v2/audit)
12 *
13 * Contact form: POST /enterprise/contact
14 * Fields: name, company, email, team_size, message
15 * Persisted to the `enterprise_leads` table (migration 0077).
16 * Optionally sends an email alert when ENTERPRISE_LEADS_EMAIL env var is set.
17 *
18 * Visual style: same dark-theme design language as /pricing and /about.
19 * All classes prefixed `.ent-` to avoid bleed.
20 */
21
22import { Hono } from "hono";
23import type { FC } from "hono/jsx";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { db } from "../db";
28import { enterpriseLeads } from "../db/schema";
29
30const enterprise = new Hono<AuthEnv>();
31enterprise.use("*", softAuth);
32
33// ─── GET /enterprise ────────────────────────────────────────────────────────
34
35enterprise.get("/enterprise", async (c) => {
36 const user = c.get("user");
37 const submitted = c.req.query("submitted") === "1";
38 return c.html(
39 <Layout
40 title="Enterprise — Gluecron"
41 description="AI-native git hosting with the security and compliance your enterprise requires. SSO, SOC 2, audit log SIEM export, dedicated SLA."
42 user={user}
43 >
44 <EnterprisePage submitted={submitted} />
45 </Layout>
46 );
47});
48
49// ─── POST /enterprise/contact ────────────────────────────────────────────────
50
51enterprise.post("/enterprise/contact", async (c) => {
52 const body = await c.req.parseBody();
53
54 const name = String(body.name ?? "").trim().slice(0, 200);
55 const company = String(body.company ?? "").trim().slice(0, 200);
56 const email = String(body.email ?? "").trim().slice(0, 200);
57 const teamSize = String(body.team_size ?? "").trim().slice(0, 50);
58 const message = String(body.message ?? "").trim().slice(0, 4000);
59
60 if (!name || !company || !email || !teamSize) {
61 return c.redirect("/enterprise?error=missing_fields");
62 }
63
64 // Basic email sanity check
65 if (!email.includes("@")) {
66 return c.redirect("/enterprise?error=invalid_email");
67 }
68
69 const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
70 ?? c.req.header("cf-connecting-ip")
71 ?? null;
72
73 try {
74 await db.insert(enterpriseLeads).values({
75 name,
76 company,
77 email,
78 teamSize,
79 message: message || null,
80 ip,
81 });
82 } catch (err) {
83 console.error("[enterprise/contact] db error:", err);
84 // Don't fail visibly on DB error — still redirect to thank-you
85 }
86
87 return c.redirect("/enterprise?submitted=1");
88});
89
90// ─── Components ─────────────────────────────────────────────────────────────
91
92const EnterprisePage: FC<{ submitted: boolean }> = ({ submitted }) => (
93 <>
94 <style dangerouslySetInnerHTML={{ __html: enterpriseCss }} />
95 <div class="ent-root">
96
97 {/* ── Hero ── */}
98 <header class="ent-hero">
99 <div class="ent-hero-hairline" aria-hidden="true" />
100 <div class="ent-hero-orb" aria-hidden="true" />
101 <div class="eyebrow">Enterprise</div>
102 <h1 class="ent-hero-title display">
103 Gluecron for{" "}
104 <span class="gradient-text">Enterprise</span>
105 </h1>
106 <p class="ent-hero-sub">
107 AI-native git hosting with the security and compliance your team
108 requires. Custom pricing, SSO, SOC&nbsp;2, audit log SIEM export,
109 and dedicated support — all on the same platform your developers
110 already love.
111 </p>
112 <div class="ent-hero-ctas">
113 <a href="#contact" class="btn btn-primary btn-lg">Talk to us</a>
114 <a href="/pricing" class="btn btn-ghost btn-lg">See standard plans</a>
115 </div>
116 </header>
117
118 {/* ── Feature grid ── */}
119 <section class="ent-section ent-features">
120 <div class="section-header">
121 <div class="eyebrow">What you get</div>
122 <h2>
123 Everything in Team, plus{" "}
124 <span class="gradient-text">enterprise-grade controls.</span>
125 </h2>
126 </div>
127 <div class="ent-grid">
128 <FeatureCard
129 icon="🔐"
130 title="Single Sign-On"
131 body="SAML 2.0 and OIDC are already built in. Connect your IdP (Okta, Azure AD, Google Workspace, OneLogin) and enforce SSO for your entire organisation in minutes. SCIM provisioning coming Q3."
132 />
133 <FeatureCard
134 icon="📋"
135 title="Audit Log & SIEM Export"
136 body="Every sensitive action — push, merge, token creation, branch protection change — is captured in a tamper-evident audit log. Stream to Splunk, Datadog, or any SIEM via GET /api/v2/audit."
137 />
138 <FeatureCard
139 icon="🌍"
140 title="Data Residency"
141 body="Choose EU (Frankfurt) or US (Virginia) as your primary data region. Your code, metadata, and AI inferences never leave your chosen region. Compliance-ready from day one."
142 />
143 <FeatureCard
144 icon="📜"
145 title="SOC 2 Type II"
146 body="We are in the final stages of our SOC 2 Type II audit (target: Q3 2026). Existing customers receive the report under NDA on request. HIPAA BAA available on Enterprise plans."
147 />
148 <FeatureCard
149 icon="🤝"
150 title="Dedicated SLA"
151 body="99.9% uptime SLA, 1-hour response for P1 incidents, and a named account engineer. We sign your vendor DPA and join your Slack channel so there is no ticket queue between you and a fix."
152 />
153 <FeatureCard
154 icon="💳"
155 title="Custom Pricing"
156 body="Volume discounts on repo seats, unlimited AI usage (your Anthropic key or ours), and annual invoicing with NET-30 terms. We will meet your procurement requirements — no credit card walls."
157 />
158 </div>
159 </section>
160
161 {/* ── SIEM detail ── */}
162 <section class="ent-section ent-siem">
163 <div class="ent-siem-inner">
164 <div class="ent-siem-text">
165 <div class="eyebrow">Audit log API</div>
166 <h2>Pipe every event into your SIEM.</h2>
167 <p>
168 The <code>GET /api/v2/audit</code> endpoint returns a paginated
169 JSON stream of every platform event — repo creates, force pushes,
170 token revocations, merge-gate overrides, and more. Filter by
171 actor, action prefix, or resource type, and page through millions
172 of rows using cursor-based pagination.
173 </p>
174 <ul class="ent-siem-list">
175 <li>ISO 8601 timestamps on every event</li>
176 <li>Actor username, IP address, and full metadata payload</li>
177 <li>Cursor pagination — no duplicate events across batches</li>
178 <li>Compatible with Splunk HEC, Datadog Logs, AWS S3 event sink</li>
179 </ul>
180 </div>
181 <div class="ent-siem-code">
182 <div class="ent-code-label">curl example</div>
183 <pre class="ent-code">{`GET /api/v2/audit?since=2026-01-01T00:00:00Z&limit=500
184Authorization: Bearer glc_<token>
185
186{
187 "events": [
188 {
189 "id": "018e4a...",
190 "action": "repo.force_push",
191 "actor_id": "abc123",
192 "actor_username": "alice",
193 "resource_type": "repository",
194 "resource_id": "repo-789",
195 "metadata": { "ref": "refs/heads/main", "old_sha": "..." },
196 "created_at": "2026-06-01T14:23:05.000Z",
197 "ip_address": "203.0.113.42"
198 }
199 ],
200 "nextCursor": "018e4b...",
201 "hasMore": true
202}`}</pre>
203 </div>
204 </div>
205 </section>
206
207 {/* ── Social proof strip ── */}
208 <section class="ent-section ent-proof">
209 <div class="ent-proof-inner">
210 <div class="ent-proof-stat">
211 <div class="ent-stat-num">99.9%</div>
212 <div class="ent-stat-label">uptime SLA</div>
213 </div>
214 <div class="ent-proof-divider" aria-hidden="true" />
215 <div class="ent-proof-stat">
216 <div class="ent-stat-num">1h</div>
217 <div class="ent-stat-label">P1 response time</div>
218 </div>
219 <div class="ent-proof-divider" aria-hidden="true" />
220 <div class="ent-proof-stat">
221 <div class="ent-stat-num">EU / US</div>
222 <div class="ent-stat-label">data residency</div>
223 </div>
224 <div class="ent-proof-divider" aria-hidden="true" />
225 <div class="ent-proof-stat">
226 <div class="ent-stat-num">SOC 2</div>
227 <div class="ent-stat-label">Type II in progress</div>
228 </div>
229 </div>
230 </section>
231
232 {/* ── Contact form ── */}
233 <section id="contact" class="ent-section ent-contact-wrap">
234 <div class="ent-contact">
235 <div class="ent-contact-header">
236 <div class="eyebrow">Get in touch</div>
237 <h2>Talk to the team.</h2>
238 <p>
239 Tell us about your team and what you need. We will reply within
240 one business day with a custom proposal.
241 </p>
242 </div>
243
244 {submitted ? (
245 <div class="ent-submitted" role="status">
246 <div class="ent-submitted-icon" aria-hidden="true"></div>
247 <h3>We got it — thanks!</h3>
248 <p>
249 Expect a reply from our team within one business day. In the
250 meantime you can explore{" "}
251 <a href="/pricing">our standard plans</a> or{" "}
252 <a href="/register">sign up free</a>.
253 </p>
254 </div>
255 ) : (
256 <form action="/enterprise/contact" method="post" class="ent-form" novalidate>
257 <div class="ent-form-row">
258 <div class="ent-field">
259 <label for="ent-name">Your name</label>
260 <input
261 type="text"
262 id="ent-name"
263 name="name"
264 placeholder="Alice Smith"
265 required
266 maxlength={200}
267 autocomplete="name"
268 />
269 </div>
270 <div class="ent-field">
271 <label for="ent-company">Company</label>
272 <input
273 type="text"
274 id="ent-company"
275 name="company"
276 placeholder="Acme Corp"
277 required
278 maxlength={200}
279 autocomplete="organization"
280 />
281 </div>
282 </div>
283 <div class="ent-form-row">
284 <div class="ent-field">
285 <label for="ent-email">Work email</label>
286 <input
287 type="email"
288 id="ent-email"
289 name="email"
290 placeholder="alice@acmecorp.com"
291 required
292 maxlength={200}
293 autocomplete="email"
294 />
295 </div>
296 <div class="ent-field">
297 <label for="ent-team-size">Team size</label>
298 <select id="ent-team-size" name="team_size" required>
299 <option value="" disabled selected>Select range…</option>
300 <option value="10-50">10 – 50 developers</option>
301 <option value="50-200">50 – 200 developers</option>
302 <option value="200-1000">200 – 1 000 developers</option>
303 <option value="1000+">1 000+ developers</option>
304 </select>
305 </div>
306 </div>
307 <div class="ent-field">
308 <label for="ent-message">
309 What are you looking for?{" "}
310 <span class="ent-field-optional">(optional)</span>
311 </label>
312 <textarea
313 id="ent-message"
314 name="message"
315 rows={5}
316 maxlength={4000}
317 placeholder="Tell us about your stack, compliance requirements, or anything else that would help us put together a proposal."
318 />
319 </div>
320 <button type="submit" class="btn btn-primary btn-lg ent-submit">
321 Send message
322 </button>
323 </form>
324 )}
325 </div>
326 </section>
327
328 </div>
329 </>
330);
331
332const FeatureCard: FC<{ icon: string; title: string; body: string }> = ({
333 icon,
334 title,
335 body,
336}) => (
337 <div class="ent-feature-card">
338 <div class="ent-feature-icon" aria-hidden="true">{icon}</div>
339 <div class="ent-feature-title">{title}</div>
340 <p class="ent-feature-body">{body}</p>
341 </div>
342);
343
344// ─── Scoped CSS ──────────────────────────────────────────────────────────────
345
346const enterpriseCss = `
347 .ent-root { max-width: 1180px; margin: 0 auto; padding: 0 16px 80px; }
348
349 /* ── Hero ── */
350 .ent-hero {
351 text-align: center;
352 padding: var(--s-16) 0 var(--s-12);
353 position: relative;
354 max-width: 820px;
355 margin: 0 auto;
356 }
357 .ent-hero-hairline {
358 position: absolute;
359 top: 0; left: 8%; right: 8%;
360 height: 2px;
361 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
362 opacity: 0.65;
363 pointer-events: none;
364 border-radius: 2px;
365 }
366 .ent-hero-orb {
367 position: absolute;
368 top: 6%; left: 50%;
369 transform: translateX(-50%);
370 width: 480px; height: 480px;
371 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
372 filter: blur(90px);
373 opacity: 0.65;
374 pointer-events: none;
375 z-index: -1;
376 animation: entHeroOrb 18s ease-in-out infinite;
377 }
378 @keyframes entHeroOrb {
379 0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.5; }
380 50% { transform: translateX(-50%) scale(1.1); opacity: 0.8; }
381 }
382 @media (prefers-reduced-motion: reduce) { .ent-hero-orb { animation: none; } }
383 .ent-hero .eyebrow { justify-content: center; margin: 0 auto var(--s-4); }
384 .ent-hero-title {
385 font-size: clamp(36px, 6vw, 68px);
386 line-height: 1.04;
387 letter-spacing: -0.038em;
388 margin: 0 0 var(--s-5);
389 }
390 .ent-hero-sub {
391 font-size: clamp(15px, 1.6vw, 18px);
392 color: var(--text-muted);
393 max-width: 640px;
394 margin: 0 auto;
395 line-height: 1.6;
396 }
397 .ent-hero-ctas {
398 display: flex;
399 gap: 12px;
400 justify-content: center;
401 flex-wrap: wrap;
402 margin-top: var(--s-8);
403 }
404
405 /* ── Sections ── */
406 .ent-section { margin: var(--s-14) auto; }
407
408 /* ── Feature grid ── */
409 .ent-grid {
410 display: grid;
411 grid-template-columns: repeat(3, 1fr);
412 gap: 16px;
413 margin-top: var(--s-8);
414 }
415 @media (max-width: 900px) { .ent-grid { grid-template-columns: repeat(2, 1fr); } }
416 @media (max-width: 560px) { .ent-grid { grid-template-columns: 1fr; } }
417
418 .ent-feature-card {
419 background: var(--bg-elevated);
420 border: 1px solid var(--border);
421 border-radius: var(--r-lg);
422 padding: var(--s-6);
423 display: flex;
424 flex-direction: column;
425 gap: var(--s-3);
426 transition: border-color var(--t-fast) var(--ease), transform var(--t-base) var(--ease-out-quart);
427 }
428 .ent-feature-card:hover {
429 border-color: rgba(140,109,255,0.35);
430 transform: translateY(-2px);
431 }
432 .ent-feature-icon {
433 font-size: 28px;
434 line-height: 1;
435 }
436 .ent-feature-title {
437 font-family: var(--font-display);
438 font-size: 16px;
439 font-weight: 600;
440 color: var(--text-strong);
441 letter-spacing: -0.01em;
442 }
443 .ent-feature-body {
444 font-size: var(--t-sm);
445 color: var(--text-muted);
446 line-height: 1.6;
447 margin: 0;
448 }
449
450 /* ── SIEM detail ── */
451 .ent-siem {
452 background: var(--bg-elevated);
453 border: 1px solid var(--border);
454 border-radius: var(--r-2xl);
455 overflow: hidden;
456 }
457 .ent-siem-inner {
458 display: grid;
459 grid-template-columns: 1fr 1fr;
460 gap: 0;
461 align-items: start;
462 }
463 @media (max-width: 860px) { .ent-siem-inner { grid-template-columns: 1fr; } }
464 .ent-siem-text {
465 padding: var(--s-10) var(--s-8);
466 display: flex;
467 flex-direction: column;
468 gap: var(--s-4);
469 }
470 .ent-siem-text .eyebrow { justify-content: flex-start; }
471 .ent-siem-text h2 {
472 font-size: clamp(22px, 2.8vw, 32px);
473 letter-spacing: -0.025em;
474 font-weight: 600;
475 color: var(--text-strong);
476 margin: 0;
477 line-height: 1.2;
478 }
479 .ent-siem-text p {
480 font-size: var(--t-sm);
481 color: var(--text-muted);
482 line-height: 1.65;
483 margin: 0;
484 }
485 .ent-siem-text code {
486 font-family: var(--font-mono);
487 font-size: 12px;
488 background: var(--bg-secondary);
489 border: 1px solid var(--border-subtle);
490 padding: 2px 6px;
491 border-radius: 4px;
492 color: var(--accent);
493 white-space: nowrap;
494 }
495 .ent-siem-list {
496 list-style: none;
497 padding: 0;
498 margin: 0;
499 display: flex;
500 flex-direction: column;
501 gap: 8px;
502 }
503 .ent-siem-list li {
504 font-size: var(--t-sm);
505 color: var(--text);
506 display: flex;
507 gap: 10px;
508 line-height: 1.5;
509 }
510 .ent-siem-list li::before {
511 content: '✓';
512 color: var(--accent);
513 font-weight: 700;
514 flex-shrink: 0;
515 }
516 .ent-siem-code {
517 background:
518 linear-gradient(160deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04) 60%, transparent),
519 var(--bg-secondary);
520 border-left: 1px solid var(--border);
521 padding: var(--s-10) var(--s-6);
522 display: flex;
523 flex-direction: column;
524 gap: var(--s-3);
525 }
526 @media (max-width: 860px) {
527 .ent-siem-code { border-left: none; border-top: 1px solid var(--border); }
528 }
529 .ent-code-label {
530 font-family: var(--font-mono);
531 font-size: 10px;
532 text-transform: uppercase;
533 letter-spacing: 0.12em;
534 color: var(--text-faint);
535 }
536 .ent-code {
537 font-family: var(--font-mono);
538 font-size: 12px;
539 line-height: 1.6;
540 color: var(--text);
541 white-space: pre-wrap;
542 word-break: break-all;
543 margin: 0;
544 background: none;
545 border: none;
546 padding: 0;
547 }
548
549 /* ── Social proof ── */
550 .ent-proof {
551 border: 1px solid var(--border);
552 border-radius: var(--r-lg);
553 background: var(--bg-elevated);
554 padding: var(--s-8) var(--s-6);
555 }
556 .ent-proof-inner {
557 display: flex;
558 align-items: center;
559 justify-content: space-around;
560 flex-wrap: wrap;
561 gap: var(--s-6);
562 }
563 .ent-proof-stat { text-align: center; }
564 .ent-stat-num {
565 font-family: var(--font-display);
566 font-size: 32px;
567 font-weight: 700;
568 letter-spacing: -0.03em;
569 color: var(--text-strong);
570 line-height: 1;
571 }
572 .ent-stat-label {
573 font-size: 12px;
574 color: var(--text-muted);
575 margin-top: 4px;
576 font-family: var(--font-mono);
577 text-transform: uppercase;
578 letter-spacing: 0.08em;
579 }
580 .ent-proof-divider {
581 width: 1px;
582 height: 40px;
583 background: var(--border);
584 }
585 @media (max-width: 600px) { .ent-proof-divider { display: none; } }
586
587 /* ── Contact form ── */
588 .ent-contact-wrap { margin: var(--s-16) auto; }
589 .ent-contact {
590 max-width: 760px;
591 margin: 0 auto;
592 background: var(--bg-elevated);
593 border: 1px solid var(--border-strong);
594 border-radius: var(--r-2xl);
595 padding: var(--s-10) var(--s-8);
596 position: relative;
597 background:
598 radial-gradient(60% 100% at 50% 0%, rgba(140,109,255,0.10), transparent 60%),
599 var(--bg-elevated);
600 }
601 .ent-contact-header {
602 text-align: center;
603 margin-bottom: var(--s-8);
604 display: flex;
605 flex-direction: column;
606 gap: var(--s-3);
607 }
608 .ent-contact-header .eyebrow { justify-content: center; }
609 .ent-contact-header h2 {
610 font-size: clamp(24px, 3vw, 36px);
611 letter-spacing: -0.025em;
612 font-weight: 600;
613 color: var(--text-strong);
614 margin: 0;
615 }
616 .ent-contact-header p {
617 font-size: var(--t-sm);
618 color: var(--text-muted);
619 margin: 0;
620 line-height: 1.6;
621 max-width: 480px;
622 margin: 0 auto;
623 }
624 .ent-form {
625 display: flex;
626 flex-direction: column;
627 gap: var(--s-5);
628 }
629 .ent-form-row {
630 display: grid;
631 grid-template-columns: 1fr 1fr;
632 gap: var(--s-4);
633 }
634 @media (max-width: 580px) { .ent-form-row { grid-template-columns: 1fr; } }
635 .ent-field {
636 display: flex;
637 flex-direction: column;
638 gap: var(--s-2);
639 }
640 .ent-field label {
641 font-size: 13px;
642 font-weight: 500;
643 color: var(--text);
644 }
645 .ent-field-optional {
646 font-weight: 400;
647 color: var(--text-muted);
648 font-size: 12px;
649 }
650 .ent-field input,
651 .ent-field select,
652 .ent-field textarea {
653 font-family: inherit;
654 font-size: var(--t-sm);
655 color: var(--text);
656 background: var(--bg-secondary);
657 border: 1px solid var(--border);
658 border-radius: var(--r);
659 padding: 10px 14px;
660 outline: none;
661 transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
662 resize: vertical;
663 }
664 .ent-field input::placeholder,
665 .ent-field textarea::placeholder { color: var(--text-faint); }
666 .ent-field input:focus,
667 .ent-field select:focus,
668 .ent-field textarea:focus {
669 border-color: rgba(140,109,255,0.55);
670 box-shadow: 0 0 0 3px rgba(140,109,255,0.12);
671 }
672 .ent-submit { width: 100%; justify-content: center; margin-top: var(--s-2); }
673
674 /* ── Submitted state ── */
675 .ent-submitted {
676 text-align: center;
677 padding: var(--s-8) var(--s-4);
678 display: flex;
679 flex-direction: column;
680 align-items: center;
681 gap: var(--s-4);
682 }
683 .ent-submitted-icon {
684 width: 56px; height: 56px;
685 border-radius: 50%;
686 background: rgba(52,211,153,0.15);
687 border: 2px solid rgba(52,211,153,0.4);
688 display: flex;
689 align-items: center;
690 justify-content: center;
691 font-size: 24px;
692 color: var(--green);
693 margin: 0 auto;
694 }
695 .ent-submitted h3 {
696 font-size: 22px;
697 font-weight: 600;
698 color: var(--text-strong);
699 margin: 0;
700 }
701 .ent-submitted p {
702 color: var(--text-muted);
703 font-size: var(--t-sm);
704 max-width: 400px;
705 margin: 0;
706 line-height: 1.6;
707 }
708 .ent-submitted a { color: var(--accent); }
709`;
710
711export default enterprise;
Addedsrc/routes/explain.tsx+923−0View fileUnifiedSplit
1/**
2 * "Explain This Repo" — AI-powered codebase analysis dashboard.
3 *
4 * Routes:
5 * GET /:owner/:repo/explain — landing / cached result
6 * POST /:owner/:repo/explain — trigger analysis, redirect to job page
7 * GET /:owner/:repo/explain/:jobId — progress polling / result page
8 * GET /:owner/:repo/explain/:jobId/raw — JSON result
9 *
10 * Analysis runs asynchronously in the background. The result page
11 * polls with a meta-refresh every 3 seconds while status=running.
12 * Completed results are cached in `repo_explain_cache` (DB) and served
13 * directly on subsequent visits.
14 */
15
16import { Hono } from "hono";
17import { html } from "hono/html";
18import { eq, and } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { renderMarkdown } from "../lib/markdown";
26import {
27 explainJobs,
28 startExplainJob,
29 getCachedExplainResult,
30 resolveRepoForExplain,
31} from "../lib/repo-explainer";
32import type { ExplainJobResult, ExplainJob } from "../lib/repo-explainer";
33
34const explainRoutes = new Hono<AuthEnv>();
35
36// ---------------------------------------------------------------------------
37// Scoped CSS
38// ---------------------------------------------------------------------------
39
40const STYLES = `
41 .explain-wrap { max-width: 1040px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
42
43 /* ── Hero ── */
44 .explain-hero {
45 position: relative;
46 margin-bottom: var(--space-5);
47 padding: var(--space-5) var(--space-6);
48 background: var(--bg-elevated);
49 border: 1px solid var(--border);
50 border-radius: 16px;
51 overflow: hidden;
52 }
53 .explain-hero::before {
54 content: '';
55 position: absolute;
56 top: 0; left: 0; right: 0;
57 height: 2px;
58 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .explain-hero-orb {
63 position: absolute;
64 inset: -20% -10% auto auto;
65 width: 460px; height: 460px;
66 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
67 filter: blur(90px);
68 opacity: 0.7;
69 pointer-events: none;
70 z-index: 0;
71 }
72 .explain-hero-inner { position: relative; z-index: 1; }
73 .explain-eyebrow {
74 font-size: 12px;
75 color: var(--text-muted);
76 margin-bottom: var(--space-2);
77 letter-spacing: 0.02em;
78 display: inline-flex; align-items: center; gap: 8px;
79 }
80 .explain-eyebrow .pill {
81 display: inline-flex; align-items: center; justify-content: center;
82 width: 18px; height: 18px; border-radius: 6px;
83 background: rgba(140,109,255,0.14);
84 color: #b69dff;
85 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
86 }
87 .explain-title {
88 font-size: clamp(28px, 4vw, 40px);
89 font-family: var(--font-display);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0 0 var(--space-2);
94 color: var(--text-strong);
95 }
96 .explain-title-grad {
97 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
98 -webkit-background-clip: text;
99 background-clip: text;
100 -webkit-text-fill-color: transparent;
101 color: transparent;
102 }
103 .explain-sub {
104 font-size: 15px; color: var(--text-muted);
105 margin: 0 0 var(--space-4);
106 line-height: 1.55; max-width: 620px;
107 }
108 .explain-trigger-btn {
109 display: inline-flex; align-items: center; gap: 8px;
110 padding: 11px 22px;
111 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
112 color: #fff;
113 border: 1px solid transparent;
114 border-radius: 10px;
115 font-size: 14px; font-weight: 600;
116 text-decoration: none; cursor: pointer;
117 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
118 font-family: inherit;
119 transition: transform 120ms ease, box-shadow 120ms ease;
120 }
121 .explain-trigger-btn:hover {
122 transform: translateY(-1px);
123 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
124 }
125 .explain-trigger-btn svg { display: block; }
126
127 /* ── Progress / running state ── */
128 .explain-progress {
129 display: flex; align-items: flex-start; gap: var(--space-4);
130 padding: var(--space-5) var(--space-6);
131 background: var(--bg-elevated);
132 border: 1px solid var(--border);
133 border-radius: 14px;
134 margin-bottom: var(--space-5);
135 }
136 .explain-spinner {
137 width: 36px; height: 36px; flex-shrink: 0;
138 border: 3px solid rgba(140,109,255,0.18);
139 border-top-color: #8c6dff;
140 border-radius: 50%;
141 animation: explain-spin 0.8s linear infinite;
142 }
143 @keyframes explain-spin {
144 to { transform: rotate(360deg); }
145 }
146 .explain-progress-text h3 {
147 margin: 0 0 4px;
148 font-size: 15px; font-weight: 700; color: var(--text-strong);
149 }
150 .explain-progress-text p {
151 margin: 0; font-size: 13px; color: var(--text-muted); line-height: 1.5;
152 }
153
154 /* ── Error state ── */
155 .explain-error {
156 padding: var(--space-5);
157 background: rgba(239,68,68,0.07);
158 border: 1px solid rgba(239,68,68,0.25);
159 border-radius: 14px;
160 color: #ef4444;
161 margin-bottom: var(--space-5);
162 }
163 .explain-error h3 { margin: 0 0 6px; font-size: 15px; }
164 .explain-error p { margin: 0; font-size: 13px; opacity: 0.9; }
165
166 /* ── Result dashboard ── */
167 .explain-dashboard {
168 display: grid;
169 grid-template-columns: 1fr 1fr;
170 gap: var(--space-4);
171 margin-bottom: var(--space-5);
172 }
173 @media (max-width: 680px) {
174 .explain-dashboard { grid-template-columns: 1fr; }
175 }
176
177 .explain-card {
178 background: var(--bg-elevated);
179 border: 1px solid var(--border);
180 border-radius: 14px;
181 overflow: hidden;
182 }
183 .explain-card-full { grid-column: 1 / -1; }
184 .explain-card-head {
185 display: flex; align-items: center; gap: 10px;
186 padding: 12px 16px;
187 background: var(--bg-tertiary);
188 border-bottom: 1px solid var(--border);
189 }
190 .explain-card-icon {
191 display: flex; align-items: center; justify-content: center;
192 width: 22px; height: 22px; border-radius: 6px;
193 background: rgba(140,109,255,0.12);
194 color: #b69dff;
195 flex-shrink: 0;
196 }
197 .explain-card-title {
198 font-size: 13px; font-weight: 700;
199 color: var(--text-strong);
200 margin: 0;
201 letter-spacing: -0.01em;
202 }
203 .explain-card-body { padding: 16px; }
204
205 /* Summary */
206 .explain-summary-text {
207 font-size: 14.5px; line-height: 1.65; color: var(--text);
208 margin: 0;
209 }
210
211 /* Health score */
212 .explain-health-score {
213 display: inline-flex; align-items: center; gap: 8px;
214 padding: 8px 14px;
215 border-radius: 10px;
216 font-size: 15px; font-weight: 700;
217 margin-bottom: 10px;
218 }
219 .explain-health-score.elite { background: rgba(52,211,153,0.12); color: #10b981; border: 1px solid rgba(52,211,153,0.28); }
220 .explain-health-score.strong { background: rgba(96,165,250,0.12); color: #3b82f6; border: 1px solid rgba(96,165,250,0.28); }
221 .explain-health-score.improving { background: rgba(251,191,36,0.12); color: #f59e0b; border: 1px solid rgba(251,191,36,0.28); }
222 .explain-health-score.needs-attention { background: rgba(239,68,68,0.12); color: #ef4444; border: 1px solid rgba(239,68,68,0.28); }
223 .explain-health-dot {
224 width: 8px; height: 8px; border-radius: 50%; background: currentColor;
225 }
226 .explain-health-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; margin: 0; }
227
228 /* Tech stack chips */
229 .explain-chips {
230 display: flex; flex-wrap: wrap; gap: 8px;
231 }
232 .explain-chip {
233 display: inline-flex; align-items: center;
234 padding: 4px 10px;
235 background: var(--bg-tertiary);
236 border: 1px solid var(--border);
237 border-radius: 9999px;
238 font-size: 12px; font-weight: 600;
239 color: var(--text);
240 }
241
242 /* Architecture — rendered markdown inside dark card */
243 .explain-arch-body {
244 font-size: 13.5px; line-height: 1.65; color: var(--text);
245 }
246 .explain-arch-body .markdown-body {
247 color: var(--text);
248 background: transparent;
249 font-size: 13.5px;
250 }
251 .explain-arch-body .markdown-body h1,
252 .explain-arch-body .markdown-body h2,
253 .explain-arch-body .markdown-body h3 {
254 color: var(--text-strong);
255 border-bottom-color: var(--border);
256 }
257 .explain-arch-body .markdown-body a { color: var(--link); }
258 .explain-arch-body .markdown-body code {
259 background: var(--bg-tertiary);
260 color: var(--text);
261 padding: 1px 5px;
262 border-radius: 4px;
263 font-family: var(--font-mono);
264 font-size: 12px;
265 }
266 .explain-arch-body .markdown-body pre {
267 background: var(--bg);
268 border: 1px solid var(--border);
269 border-radius: 8px;
270 padding: 12px 14px;
271 overflow-x: auto;
272 }
273 .explain-arch-body .markdown-body pre code {
274 background: transparent; color: inherit; padding: 0;
275 }
276
277 /* Entry points table */
278 .explain-ep-table {
279 width: 100%; border-collapse: collapse;
280 font-size: 13px;
281 }
282 .explain-ep-table th {
283 text-align: left; padding: 6px 10px;
284 font-size: 11px; font-weight: 700;
285 color: var(--text-muted);
286 text-transform: uppercase; letter-spacing: 0.04em;
287 border-bottom: 1px solid var(--border);
288 }
289 .explain-ep-table td {
290 padding: 8px 10px;
291 border-bottom: 1px solid var(--border);
292 vertical-align: top;
293 color: var(--text);
294 }
295 .explain-ep-table tr:last-child td { border-bottom: none; }
296 .explain-ep-table td:first-child {
297 font-family: var(--font-mono); font-size: 12px;
298 color: var(--text-strong);
299 white-space: nowrap;
300 }
301 .explain-ep-table a { color: var(--link); text-decoration: none; }
302 .explain-ep-table a:hover { text-decoration: underline; }
303
304 /* Getting started — rendered markdown */
305 .explain-gs-body .markdown-body {
306 color: var(--text);
307 background: transparent;
308 font-size: 13.5px;
309 line-height: 1.65;
310 }
311 .explain-gs-body .markdown-body code {
312 background: var(--bg-tertiary);
313 color: var(--text);
314 padding: 1px 5px;
315 border-radius: 4px;
316 font-family: var(--font-mono);
317 font-size: 12px;
318 }
319 .explain-gs-body .markdown-body pre {
320 background: var(--bg);
321 border: 1px solid var(--border);
322 border-radius: 8px;
323 padding: 12px 14px;
324 overflow-x: auto;
325 }
326 .explain-gs-body .markdown-body pre code {
327 background: transparent; color: inherit; padding: 0;
328 }
329
330 /* Suggested issues cards */
331 .explain-issues { display: flex; flex-direction: column; gap: 10px; }
332 .explain-issue-card {
333 padding: 12px 14px;
334 background: var(--bg);
335 border: 1px solid var(--border);
336 border-radius: 10px;
337 }
338 .explain-issue-card h4 {
339 margin: 0 0 4px;
340 font-size: 13.5px; font-weight: 700; color: var(--text-strong);
341 }
342 .explain-issue-card p {
343 margin: 0 0 10px;
344 font-size: 12.5px; color: var(--text-muted); line-height: 1.55;
345 }
346 .explain-issue-btn {
347 display: inline-flex; align-items: center; gap: 6px;
348 padding: 5px 12px;
349 background: var(--bg-elevated);
350 border: 1px solid var(--border);
351 border-radius: 8px;
352 font-size: 12px; font-weight: 600;
353 color: var(--text);
354 text-decoration: none;
355 cursor: pointer; font-family: inherit;
356 transition: background 100ms ease, border-color 100ms ease;
357 }
358 .explain-issue-btn:hover {
359 background: var(--bg-tertiary);
360 border-color: var(--border-strong, var(--border));
361 }
362
363 /* Share + actions bar */
364 .explain-actions-bar {
365 display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
366 margin-bottom: var(--space-5);
367 }
368 .explain-share-btn {
369 display: inline-flex; align-items: center; gap: 6px;
370 padding: 8px 14px;
371 background: var(--bg-elevated);
372 border: 1px solid var(--border);
373 border-radius: 10px;
374 font-size: 13px; font-weight: 600;
375 color: var(--text); text-decoration: none;
376 cursor: pointer; font-family: inherit;
377 transition: background 100ms ease;
378 }
379 .explain-share-btn:hover { background: var(--bg-tertiary); }
380
381 /* Powered-by */
382 .explain-poweredby {
383 margin-top: var(--space-5);
384 text-align: center;
385 color: var(--text-muted);
386 font-size: 11.5px;
387 }
388 .explain-poweredby-pill {
389 display: inline-flex; align-items: center; gap: 6px;
390 padding: 4px 10px;
391 border-radius: 9999px;
392 background: rgba(140,109,255,0.08);
393 border: 1px solid rgba(140,109,255,0.22);
394 color: var(--text-muted);
395 font-size: 11px; letter-spacing: 0.04em;
396 text-transform: uppercase; font-weight: 600;
397 }
398 .explain-poweredby-pill .dot {
399 width: 6px; height: 6px; border-radius: 50%;
400 background: linear-gradient(135deg, #8c6dff, #36c5d6);
401 }
402
403 /* Cached badge */
404 .explain-cached-pill {
405 display: inline-flex; align-items: center; gap: 5px;
406 padding: 2px 8px; border-radius: 9999px;
407 font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em;
408 text-transform: uppercase;
409 background: rgba(52,211,153,0.12); color: #6ee7b7;
410 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
411 }
412 .explain-cached-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
413`;
414
415// ---------------------------------------------------------------------------
416// Shared repo resolution
417// ---------------------------------------------------------------------------
418
419async function resolveRepo(owner: string, repo: string) {
420 const [ownerRow] = await db
421 .select({ id: users.id, username: users.username })
422 .from(users)
423 .where(eq(users.username, owner))
424 .limit(1);
425 if (!ownerRow) return null;
426
427 const [repoRow] = await db
428 .select({ id: repositories.id, ownerId: repositories.ownerId })
429 .from(repositories)
430 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo)))
431 .limit(1);
432 if (!repoRow) return null;
433
434 return { repoId: repoRow.id, ownerId: repoRow.ownerId, ownerUsername: ownerRow.username };
435}
436
437// ---------------------------------------------------------------------------
438// Shared page scaffolding helpers
439// ---------------------------------------------------------------------------
440
441function HealthBadge({ score }: { score: string }) {
442 const cls = {
443 "Elite": "elite",
444 "Strong": "strong",
445 "Improving": "improving",
446 "Needs Attention": "needs-attention",
447 }[score] ?? "improving";
448 const emoji = {
449 "Elite": "★",
450 "Strong": "●",
451 "Improving": "◐",
452 "Needs Attention": "○",
453 }[score] ?? "●";
454 return (
455 <span class={`explain-health-score ${cls}`}>
456 <span class="explain-health-dot" aria-hidden="true" />
457 {emoji} {score}
458 </span>
459 );
460}
461
462function TechChips({ stack }: { stack: string[] }) {
463 return (
464 <div class="explain-chips">
465 {stack.map((t) => <span class="explain-chip">{t}</span>)}
466 </div>
467 );
468}
469
470function ResultDashboard({
471 result,
472 owner,
473 repo,
474 cached,
475}: {
476 result: ExplainJobResult;
477 owner: string;
478 repo: string;
479 cached?: boolean;
480}) {
481 const issueNewBase = `/${owner}/${repo}/issues/new`;
482 return (
483 <>
484 <div class="explain-actions-bar">
485 {cached && (
486 <span class="explain-cached-pill">
487 <span class="dot" />
488 cached
489 </span>
490 )}
491 <a
492 href={`/share/${owner}`}
493 class="explain-share-btn"
494 title="Share this analysis"
495 >
496 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
497 <circle cx="18" cy="5" r="3" />
498 <circle cx="6" cy="12" r="3" />
499 <circle cx="18" cy="19" r="3" />
500 <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
501 <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
502 </svg>
503 Share analysis
504 </a>
505 </div>
506
507 <div class="explain-dashboard">
508 {/* Summary */}
509 <div class="explain-card explain-card-full">
510 <div class="explain-card-head">
511 <span class="explain-card-icon" aria-hidden="true">
512 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
513 <path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
514 </svg>
515 </span>
516 <p class="explain-card-title">Summary</p>
517 </div>
518 <div class="explain-card-body">
519 <p class="explain-summary-text">{result.summary}</p>
520 </div>
521 </div>
522
523 {/* Health Score */}
524 <div class="explain-card">
525 <div class="explain-card-head">
526 <span class="explain-card-icon" aria-hidden="true">
527 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
528 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
529 </svg>
530 </span>
531 <p class="explain-card-title">Health Score</p>
532 </div>
533 <div class="explain-card-body">
534 <HealthBadge score={result.healthScore} />
535 <p class="explain-health-desc">
536 Based on code quality signals visible in the repository — tests, documentation, type coverage, and CI configuration.
537 </p>
538 </div>
539 </div>
540
541 {/* Tech Stack */}
542 <div class="explain-card">
543 <div class="explain-card-head">
544 <span class="explain-card-icon" aria-hidden="true">
545 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
546 <polygon points="12 2 2 7 12 12 22 7 12 2" />
547 <polyline points="2 17 12 22 22 17" />
548 <polyline points="2 12 12 17 22 12" />
549 </svg>
550 </span>
551 <p class="explain-card-title">Tech Stack</p>
552 </div>
553 <div class="explain-card-body">
554 {result.techStack.length > 0
555 ? <TechChips stack={result.techStack} />
556 : <p style="color:var(--text-muted);font-size:13px;margin:0">No tech stack detected.</p>
557 }
558 </div>
559 </div>
560
561 {/* Architecture */}
562 <div class="explain-card explain-card-full">
563 <div class="explain-card-head">
564 <span class="explain-card-icon" aria-hidden="true">
565 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
566 <rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
567 <rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
568 </svg>
569 </span>
570 <p class="explain-card-title">Architecture</p>
571 </div>
572 <div class="explain-card-body">
573 <div class="explain-arch-body">
574 <div class="markdown-body">
575 {html([renderMarkdown(result.architecture)] as unknown as TemplateStringsArray)}
576 </div>
577 </div>
578 </div>
579 </div>
580
581 {/* Entry Points */}
582 <div class="explain-card explain-card-full">
583 <div class="explain-card-head">
584 <span class="explain-card-icon" aria-hidden="true">
585 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
586 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
587 <polyline points="14 2 14 8 20 8" />
588 </svg>
589 </span>
590 <p class="explain-card-title">Key Entry Points</p>
591 </div>
592 <div class="explain-card-body">
593 {result.entryPoints.length > 0 ? (
594 <table class="explain-ep-table">
595 <thead>
596 <tr>
597 <th>File</th>
598 <th>Role</th>
599 </tr>
600 </thead>
601 <tbody>
602 {result.entryPoints.map((ep) => (
603 <tr>
604 <td>
605 <a href={`/${owner}/${repo}/blob/main/${ep.file}`}>
606 {ep.file}
607 </a>
608 </td>
609 <td>{ep.role}</td>
610 </tr>
611 ))}
612 </tbody>
613 </table>
614 ) : (
615 <p style="color:var(--text-muted);font-size:13px;margin:0">No entry points detected.</p>
616 )}
617 </div>
618 </div>
619
620 {/* Getting Started */}
621 <div class="explain-card explain-card-full">
622 <div class="explain-card-head">
623 <span class="explain-card-icon" aria-hidden="true">
624 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
625 <polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
626 </svg>
627 </span>
628 <p class="explain-card-title">Getting Started</p>
629 </div>
630 <div class="explain-card-body">
631 <div class="explain-gs-body">
632 <div class="markdown-body">
633 {html([renderMarkdown(result.gettingStarted)] as unknown as TemplateStringsArray)}
634 </div>
635 </div>
636 </div>
637 </div>
638
639 {/* Suggested Issues */}
640 <div class="explain-card explain-card-full">
641 <div class="explain-card-head">
642 <span class="explain-card-icon" aria-hidden="true">
643 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
644 <circle cx="12" cy="12" r="10" />
645 <line x1="12" y1="8" x2="12" y2="12" />
646 <line x1="12" y1="16" x2="12.01" y2="16" />
647 </svg>
648 </span>
649 <p class="explain-card-title">Suggested First Tasks</p>
650 </div>
651 <div class="explain-card-body">
652 {result.suggestedIssues.length > 0 ? (
653 <div class="explain-issues">
654 {result.suggestedIssues.map((issue) => {
655 const params = new URLSearchParams({
656 title: issue.title,
657 body: issue.description,
658 });
659 return (
660 <div class="explain-issue-card">
661 <h4>{issue.title}</h4>
662 <p>{issue.description}</p>
663 <a
664 href={`${issueNewBase}?${params.toString()}`}
665 class="explain-issue-btn"
666 >
667 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
668 <circle cx="12" cy="12" r="10" />
669 <line x1="12" y1="8" x2="12" y2="16" />
670 <line x1="8" y1="12" x2="16" y2="12" />
671 </svg>
672 Open Issue
673 </a>
674 </div>
675 );
676 })}
677 </div>
678 ) : (
679 <p style="color:var(--text-muted);font-size:13px;margin:0">No suggestions generated.</p>
680 )}
681 </div>
682 </div>
683 </div>
684 </>
685 );
686}
687
688// ---------------------------------------------------------------------------
689// Route: GET /:owner/:repo/explain — landing page or cached result
690// ---------------------------------------------------------------------------
691
692explainRoutes.get("/:owner/:repo/explain", softAuth, async (c) => {
693 const { owner, repo } = c.req.param();
694 const user = c.get("user");
695
696 const resolved = await resolveRepo(owner, repo);
697 if (!resolved) {
698 return c.html(
699 <Layout title="Not Found" user={user}>
700 <div class="container" style="padding: var(--space-6);">
701 <h2>Repository not found</h2>
702 </div>
703 </Layout>,
704 404
705 );
706 }
707
708 const cached = await getCachedExplainResult(resolved.repoId);
709 const canTrigger = !!user;
710
711 return c.html(
712 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
713 <RepoHeader owner={owner} repo={repo} />
714 <div class="explain-wrap">
715 <section class="explain-hero">
716 <div class="explain-hero-orb" aria-hidden="true" />
717 <div class="explain-hero-inner">
718 <div class="explain-eyebrow">
719 <span class="pill" aria-hidden="true">
720 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
721 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
722 </svg>
723 </span>
724 AI · gluecron · explain
725 </div>
726 <h1 class="explain-title">
727 <span class="explain-title-grad">Explain</span>{" "}
728 <span style="color:var(--text-strong)">{owner}/{repo}</span>
729 </h1>
730 <p class="explain-sub">
731 One click — AI reads the entire codebase and generates an architecture overview,
732 tech stack analysis, key entry points, onboarding guide, and suggested first tasks.
733 </p>
734
735 {!cached && (
736 canTrigger ? (
737 <form method="post" action={`/${owner}/${repo}/explain`} style="display:inline">
738 <button type="submit" class="explain-trigger-btn">
739 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
740 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
741 </svg>
742 Explain This Repo
743 </button>
744 </form>
745 ) : (
746 <a href="/login" class="explain-trigger-btn">
747 Sign in to explain this repo
748 </a>
749 )
750 )}
751
752 {cached && (
753 <form method="post" action={`/${owner}/${repo}/explain`} style="display:inline">
754 <button type="submit" class="explain-trigger-btn" style="background:var(--bg-elevated);color:var(--text);border:1px solid var(--border);box-shadow:none">
755 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
756 <polyline points="23 4 23 10 17 10" />
757 <polyline points="1 20 1 14 7 14" />
758 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
759 </svg>
760 Regenerate
761 </button>
762 </form>
763 )}
764 </div>
765 </section>
766
767 {cached ? (
768 <ResultDashboard result={cached} owner={owner} repo={repo} cached />
769 ) : (
770 <div style="padding:var(--space-6);text-align:center;color:var(--text-muted);font-size:14px;">
771 No analysis yet. Click "Explain This Repo" to generate one.
772 </div>
773 )}
774
775 <div class="explain-poweredby">
776 <span class="explain-poweredby-pill">
777 <span class="dot" aria-hidden="true" />
778 Powered by Claude
779 </span>
780 </div>
781 </div>
782 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
783 </Layout>
784 );
785});
786
787// ---------------------------------------------------------------------------
788// Route: POST /:owner/:repo/explain — trigger analysis
789// ---------------------------------------------------------------------------
790
791explainRoutes.post("/:owner/:repo/explain", softAuth, async (c) => {
792 const { owner, repo } = c.req.param();
793 const user = c.get("user");
794
795 if (!user) {
796 return c.redirect(`/login`);
797 }
798
799 const resolved = await resolveRepo(owner, repo);
800 if (!resolved) return c.notFound();
801
802 const jobId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
803 startExplainJob(jobId, owner, repo, resolved.repoId);
804
805 return c.redirect(`/${owner}/${repo}/explain/${jobId}`);
806});
807
808// ---------------------------------------------------------------------------
809// Route: GET /:owner/:repo/explain/:jobId/raw — JSON result
810// ---------------------------------------------------------------------------
811
812explainRoutes.get("/:owner/:repo/explain/:jobId/raw", async (c) => {
813 const { jobId } = c.req.param();
814 const job = explainJobs.get(jobId);
815 if (!job) return c.json({ error: "Job not found" }, 404);
816 return c.json(job);
817});
818
819// ---------------------------------------------------------------------------
820// Route: GET /:owner/:repo/explain/:jobId — progress / result page
821// ---------------------------------------------------------------------------
822
823explainRoutes.get("/:owner/:repo/explain/:jobId", softAuth, async (c) => {
824 const { owner, repo, jobId } = c.req.param();
825 const user = c.get("user");
826
827 const job = explainJobs.get(jobId);
828 if (!job) {
829 return c.html(
830 <Layout title="Job not found" user={user}>
831 <div class="container" style="padding:var(--space-6)">
832 <h2>Analysis job not found</h2>
833 <p>
834 The job may have expired. <a href={`/${owner}/${repo}/explain`}>Start a new analysis</a>.
835 </p>
836 </div>
837 </Layout>,
838 404
839 );
840 }
841
842 const isRunning = job.status === "running";
843 const isFailed = job.status === "failed";
844 const isDone = job.status === "done";
845
846 return c.html(
847 <Layout title={`Explain — ${owner}/${repo}`} user={user}>
848 {/* Auto-refresh while running */}
849 {isRunning && (
850 <meta http-equiv="refresh" content={`3;url=/${owner}/${repo}/explain/${jobId}`} />
851 )}
852 <RepoHeader owner={owner} repo={repo} />
853 <div class="explain-wrap">
854 <section class="explain-hero">
855 <div class="explain-hero-orb" aria-hidden="true" />
856 <div class="explain-hero-inner">
857 <div class="explain-eyebrow">
858 <span class="pill" aria-hidden="true">
859 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
860 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 1 1 7.072 0l-.548.547A3.374 3.374 0 0 0 14 18.469V19a2 2 0 1 1-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
861 </svg>
862 </span>
863 AI · gluecron · explain
864 </div>
865 <h1 class="explain-title">
866 <span class="explain-title-grad">Explain</span>{" "}
867 <span style="color:var(--text-strong)">{owner}/{repo}</span>
868 </h1>
869 </div>
870 </section>
871
872 {isRunning && (
873 <div class="explain-progress">
874 <div class="explain-spinner" aria-label="Analyzing…" />
875 <div class="explain-progress-text">
876 <h3>Analyzing codebase…</h3>
877 <p>
878 Claude is reading the file tree and key source files.
879 This usually takes 10–30 seconds. This page refreshes automatically.
880 </p>
881 </div>
882 </div>
883 )}
884
885 {isFailed && (
886 <div class="explain-error">
887 <h3>Analysis failed</h3>
888 <p>{job.error ?? "An unexpected error occurred. Please try again."}</p>
889 </div>
890 )}
891
892 {isDone && job.result && (
893 <ResultDashboard result={job.result} owner={owner} repo={repo} />
894 )}
895
896 <div style="margin-top:var(--space-4);font-size:13px;color:var(--text-muted);">
897 <a href={`/${owner}/${repo}/explain`} style="color:var(--link)">
898 ← Back to explain page
899 </a>
900 {" · "}
901 <a
902 href={`/${owner}/${repo}/explain/${jobId}/raw`}
903 style="color:var(--link)"
904 target="_blank"
905 rel="noopener noreferrer"
906 >
907 View raw JSON
908 </a>
909 </div>
910
911 <div class="explain-poweredby">
912 <span class="explain-poweredby-pill">
913 <span class="dot" aria-hidden="true" />
914 Powered by Claude
915 </span>
916 </div>
917 </div>
918 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
919 </Layout>
920 );
921});
922
923export default explainRoutes;
Modifiedsrc/routes/explore.tsx+86−2View fileUnifiedSplit
605605 .explore-foot { flex-direction: column; align-items: stretch; padding: 16px 18px; }
606606 .explore-foot-actions { width: 100%; }
607607 .explore-foot-actions .btn { flex: 1; min-width: 0; }
608 .explore-gh-callout { flex-direction: column; gap: 12px; }
609 .explore-gh-callout-cta { width: 100%; justify-content: center; }
608610 }
611
612 /* "Coming from GitHub?" callout — bottom-of-page migration prompt */
613 .explore-gh-callout {
614 display: flex;
615 align-items: center;
616 gap: 16px;
617 margin-top: 32px;
618 padding: 20px 24px;
619 background: var(--bg-elevated);
620 border: 1px solid var(--border);
621 border-radius: 14px;
622 position: relative;
623 overflow: hidden;
624 }
625 .explore-gh-callout::before {
626 content: '';
627 position: absolute;
628 top: 0; left: 0; right: 0;
629 height: 2px;
630 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
631 opacity: 0.7;
632 pointer-events: none;
633 }
634 .explore-gh-callout-icon {
635 width: 40px; height: 40px;
636 border-radius: 10px;
637 background: rgba(140,109,255,0.10);
638 border: 1px solid rgba(140,109,255,0.20);
639 display: flex; align-items: center; justify-content: center;
640 color: var(--accent);
641 flex-shrink: 0;
642 }
643 .explore-gh-callout-body { flex: 1; min-width: 0; }
644 .explore-gh-callout-title {
645 font-size: 15px;
646 font-weight: 700;
647 color: var(--text-strong);
648 margin: 0 0 3px;
649 letter-spacing: -0.012em;
650 }
651 .explore-gh-callout-sub {
652 font-size: 13.5px;
653 color: var(--text-muted);
654 margin: 0;
655 line-height: 1.5;
656 }
657 .explore-gh-callout-cta { flex-shrink: 0; white-space: nowrap; }
609658 `,
610659 }}
611660 />
648697 // before the real list lands.
649698 if (c.req.query("skeleton") === "1") {
650699 return c.html(
651 <Layout title="Explore" user={user}>
700 <Layout
701 title="Explore"
702 user={user}
703 description="Discover open-source projects hosted on Gluecron — the AI-native GitHub alternative."
704 ogTitle="Explore — Gluecron"
705 ogDescription="Discover open-source projects hosted on Gluecron — the AI-native GitHub alternative."
706 twitterCard="summary"
707 >
652708 <ExploreStyle />
653709 <div class="explore-wrap">
654710 <section class="explore-hero" aria-hidden="true">
794850 : `Sorted by repository creation date`;
795851
796852 return c.html(
797 <Layout title="Explore" user={user}>
853 <Layout
854 title="Explore"
855 user={user}
856 description="Discover open-source projects hosted on Gluecron — the AI-native GitHub alternative."
857 ogTitle="Explore — Gluecron"
858 ogDescription="Discover open-source projects hosted on Gluecron — the AI-native GitHub alternative."
859 twitterCard="summary"
860 >
798861 <ExploreStyle />
799862 <div class="explore-wrap">
800863 <section class="explore-hero">
11561219 )}
11571220 </>
11581221 )}
1222 {/* "Coming from GitHub?" callout — shown to all visitors since
1223 Explore is the discovery surface most likely visited by evaluators
1224 comparing Gluecron to GitHub. Non-intrusive card at the bottom. */}
1225 <div class="explore-gh-callout" aria-label="Coming from GitHub?">
1226 <div class="explore-gh-callout-icon" aria-hidden="true">
1227 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
1228 <polyline points="4 17 10 11 4 5" />
1229 <line x1="12" y1="19" x2="20" y2="19" />
1230 </svg>
1231 </div>
1232 <div class="explore-gh-callout-body">
1233 <p class="explore-gh-callout-title">Coming from GitHub?</p>
1234 <p class="explore-gh-callout-sub">
1235 Migrate your repos, issues, and full history in 60 seconds — no
1236 manual setup, no SaaS migration project required.
1237 </p>
1238 </div>
1239 <a href="/import" class="btn btn-primary explore-gh-callout-cta">
1240 Migrate from GitHub →
1241 </a>
1242 </div>
11591243 </div>
11601244 </Layout>
11611245 );
Modifiedsrc/routes/git.ts+49−11View fileUnifiedSplit
1616import {
1717 evaluatePushPolicy,
1818 formatPolicyError,
19 installPackInspectionHookForRepo,
1920} from "../lib/push-policy";
2021import { resolvePusher } from "../lib/git-push-auth";
2122import { audit } from "../lib/notify";
9293 const bodyBuffer = await c.req.arrayBuffer();
9394 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
9495
96 // Resolve the pusher early so we can pass it to both the policy gate and
97 // the post-receive hook (e.g. for AI auto-issue attribution).
98 let pusherUserId = "";
99 try {
100 const pusher = await resolvePusher(c.req.header("authorization"));
101 pusherUserId = pusher?.userId || "";
102 } catch {
103 // Fail open — policy gate and post-receive handle missing pushers fine.
104 }
105
95106 // Pre-receive policy: protected tags + ruleset name patterns. Fail-open
96107 // on any DB hiccup (the helper returns {allowed:true} in that case).
108 // We also retain the repoRow so the pack-inspection hook below can reuse it.
109 let cachedRepoId: string | null = null;
97110 if (refs.length > 0) {
98111 try {
99112 const repoRow = await loadRepoRow(owner, repo);
100113 if (repoRow) {
101 const pusher = await resolvePusher(c.req.header("authorization"));
114 cachedRepoId = repoRow.id;
102115 const decision = await evaluatePushPolicy({
103116 repositoryId: repoRow.id,
104117 refs,
105 pusherUserId: pusher?.userId || null,
118 pusherUserId: pusherUserId || null,
106119 });
107120 if (!decision.allowed) {
108121 // Audit the rejection so owners can see blocked-push attempts
109122 // even though the request never reached the post-receive hook.
110123 // Fire-and-forget — never block the 403.
111124 audit({
112 userId: pusher?.userId || null,
125 userId: pusherUserId || null,
113126 repositoryId: repoRow.id,
114127 action: "push.rejected",
115128 targetType: "repository",
122135 metadata: {
123136 violations: decision.violations,
124137 refs: refs.map((r) => r.refName),
125 pusherSource: pusher?.source || "anonymous",
138 pusherSource: pusherUserId ? "user" : "anonymous",
126139 },
127140 }).catch((err) => {
128141 console.warn(
141154 }
142155 }
143156
144 const response = await serviceRpc(
145 owner,
146 repoRaw,
147 "git-receive-pack",
148 bodyBuffer
149 );
157 // Pack-content inspection: install a pre-receive hook that enforces
158 // commit_message_pattern, blocked_file_paths, and max_file_size rules.
159 // git-receive-pack runs the hook before promoting quarantined objects, so
160 // a hook exit-1 leaves the repo unchanged. We clean up the temp dir
161 // unconditionally after serviceRpc returns.
162 let hookEnv: Record<string, string> | undefined;
163 let hookCleanup: (() => Promise<void>) | undefined;
164 if (refs.length > 0 && cachedRepoId) {
165 try {
166 const hook = await installPackInspectionHookForRepo(cachedRepoId);
167 if (hook) {
168 hookEnv = hook.env;
169 hookCleanup = hook.cleanup;
170 }
171 } catch {
172 // fail-open
173 }
174 }
175
176 let response: Response;
177 try {
178 response = await serviceRpc(
179 owner,
180 repoRaw,
181 "git-receive-pack",
182 bodyBuffer,
183 hookEnv
184 );
185 } finally {
186 hookCleanup?.().catch(() => {});
187 }
150188
151189 // Invalidate cached git data for this repo immediately
152190 invalidateRepoCache(owner, repo);
153191
154192 // Fire post-receive hooks asynchronously (don't block response).
155193 if (refs.length > 0) {
156 onPostReceive(owner, repo, refs).catch((err) =>
194 onPostReceive(owner, repo, refs, pusherUserId).catch((err) =>
157195 console.error("[post-receive] hook error:", err)
158196 );
159197 }
Modifiedsrc/routes/help.tsx+9−2View fileUnifiedSplit
248248 const user = c.get("user");
249249
250250 return c.html(
251 <Layout title="Help — gluecron" user={user}>
251 <Layout
252 title="Help — gluecron"
253 user={user}
254 description="Gluecron documentation — getting started, workflow YAML, MCP server, API reference."
255 ogTitle="Help — Gluecron"
256 ogDescription="Gluecron documentation — getting started, workflow YAML, MCP server, API reference."
257 twitterCard="summary"
258 >
252259 <style dangerouslySetInnerHTML={{ __html: helpStyles }} />
253260 <div class="help-wrap">
254261 {/* ─── Hero ─── */}
671678 Every <code>.gluecron/specs/*.md</code> file across your
672679 repos shows up here. Add a spec, push it, then either run
673680 the spec-to-PR generator from the page or label the file
674 and let autopilot do it overnight.
681 and let autopilot run it automatically.
675682 </div>
676683 </div>
677684 </section>
Modifiedsrc/routes/import-bulk.tsx+7−6View fileUnifiedSplit
567567 <div class="import-bulk-hero-inner">
568568 <div class="import-bulk-hero-eyebrow">
569569 <span class="import-bulk-hero-eyebrow-dot" aria-hidden="true" />
570 Bulk migration
570 GitHub migration
571571 </div>
572572 <h1 class="import-bulk-hero-title">
573 Import{" "}
574 <span class="gradient-text">many at once</span>.
573 Migrate your GitHub org{" "}
574 <span class="gradient-text">in 60 seconds</span>.
575575 </h1>
576576 <p class="import-bulk-hero-sub">
577 Paste a GitHub org + personal access token. Gluecron clones every
578 repo into your namespace as a mirror — sequentially, with per-repo
579 status so one failure can't abort the batch.
577 All repos, issues, and history — paste a GitHub org + personal access
578 token and Gluecron imports everything into your namespace
579 sequentially, with per-repo status so one failure can't abort the
580 batch.
580581 </p>
581582 </div>
582583 </div>
Modifiedsrc/routes/issues.tsx+15−0View fileUnifiedSplit
5656 formatRelative,
5757} from "../views/ui";
5858import { getDefaultBranch, resolveRef, updateRef } from "../git/repository";
59import { BOT_USERNAME } from "../lib/bot-user";
5960
6061const issueRoutes = new Hono<AuthEnv>();
6162
564565 box-shadow: 0 0 6px rgba(255,255,255,0.7);
565566 }
566567
568 .issues-bot-badge {
569 display: inline-flex; align-items: center; gap: 3px;
570 padding: 1px 7px;
571 font-size: 10px;
572 font-weight: 600;
573 color: var(--fg-muted);
574 background: var(--bg-elevated);
575 border: 1px solid var(--border);
576 border-radius: 9999px;
577 }
578
567579 /* Composer */
568580 .issues-composer {
569581 margin-top: 22px;
14981510 <article class={`issues-comment${isAi ? " is-ai" : ""}${isPending ? " modq-comment-pending" : ""}`}>
14991511 <header class="issues-comment-header">
15001512 <strong>{commentAuthor.username}</strong>
1513 {commentAuthor.username === BOT_USERNAME && (
1514 <span class="issues-bot-badge">&#x1F916; bot</span>
1515 )}
15011516 {isAi ? (
15021517 <span class="issues-ai-badge" title="Generated by Gluecron AI Triage">
15031518 AI Review
Modifiedsrc/routes/marketing.tsx+5−0View fileUnifiedSplit
8484 "Priority AI queue",
8585 "Custom domains",
8686 "Advanced analytics",
87 "EU data residency (Frankfurt)",
8788 "Email support",
8889 ]}
8990 cta="Go Pro"
195196 q="How do I migrate off Gluecron?"
196197 a="Same way you migrate off GitHub: git remote set-url and push. We're git-compatible to the byte. No vendor lock, no migration tax."
197198 />
199 <FaqItem
200 q="Is EU data residency available?"
201 a="Yes. Pro plan and above can choose the EU (Frankfurt) data region when creating a repository. All repository data — git objects, issues, PRs — is stored and processed in the EU region. The region is set at creation time and cannot be changed afterwards. You can configure it in the New Repository form or see the current region on your repository Settings page."
202 />
198203 </div>
199204 </section>
200205
Addedsrc/routes/migrate.tsx+1018−0View fileUnifiedSplit
Large file (1,018 lines). Load full file
Addedsrc/routes/oci-registry.ts+855−0View fileUnifiedSplit
1/**
2 * OCI Distribution Spec v1.0 — Container Registry
3 *
4 * Implements the standard Docker / OCI image push-pull protocol so teams
5 * can push and pull images directly against Gluecron without GitHub Packages
6 * or an external registry.
7 *
8 * URL surface (all under /v2/):
9 * GET /v2/ — version check (requires auth)
10 * HEAD /v2/:name/blobs/:digest — check blob existence
11 * GET /v2/:name/blobs/:digest — download blob
12 * POST /v2/:name/blobs/uploads/ — start chunked upload
13 * PATCH /v2/:name/blobs/uploads/:uuid — stream blob chunk
14 * PUT /v2/:name/blobs/uploads/:uuid — complete upload
15 * DELETE /v2/:name/blobs/:digest — delete blob
16 * GET /v2/:name/manifests/:ref — get manifest (tag or digest)
17 * PUT /v2/:name/manifests/:ref — push manifest (create tag)
18 * DELETE /v2/:name/manifests/:ref — delete manifest/tag
19 * GET /v2/:name/tags/list — list tags for a repo
20 * GET /v2/_catalog — list all repositories
21 *
22 * Auth: Docker clients send `Authorization: Basic base64(user:token)`.
23 * We validate against the api_tokens table (SHA-256 of the raw token,
24 * same as every other Gluecron API surface). 401 responses carry
25 * `WWW-Authenticate: Basic realm="Gluecron Container Registry"` and
26 * the OCI error JSON body format.
27 *
28 * Storage: blobs on disk at ${OCI_STORE_PATH}/blobs/sha256/<hex>,
29 * manifests at ${OCI_STORE_PATH}/manifests/<name>/<ref>.
30 * In-progress uploads accumulate in ${OCI_STORE_PATH}/uploads/<uuid>.
31 */
32
33import { Hono } from "hono";
34import { createHash, randomUUID } from "crypto";
35import { eq, and, desc } from "drizzle-orm";
36import { mkdir } from "node:fs/promises";
37import { join } from "node:path";
38import { db } from "../db";
39import { apiTokens, users, ociRepositories, ociTags } from "../db/schema";
40import { config } from "../lib/config";
41import type { AuthEnv } from "../middleware/auth";
42
43// ---------------------------------------------------------------------------
44// OCI error helpers
45// ---------------------------------------------------------------------------
46
47type OciErrorCode =
48 | "UNAUTHORIZED"
49 | "DENIED"
50 | "UNSUPPORTED"
51 | "BLOB_UNKNOWN"
52 | "BLOB_UPLOAD_INVALID"
53 | "BLOB_UPLOAD_UNKNOWN"
54 | "DIGEST_INVALID"
55 | "MANIFEST_BLOB_UNKNOWN"
56 | "MANIFEST_INVALID"
57 | "MANIFEST_UNKNOWN"
58 | "NAME_INVALID"
59 | "NAME_UNKNOWN"
60 | "SIZE_INVALID"
61 | "TAG_INVALID";
62
63function ociError(
64 code: OciErrorCode,
65 message: string,
66 detail?: unknown
67): { errors: Array<{ code: string; message: string; detail?: unknown }> } {
68 const err: { code: string; message: string; detail?: unknown } = {
69 code,
70 message,
71 };
72 if (detail !== undefined) err.detail = detail;
73 return { errors: [err] };
74}
75
76// ---------------------------------------------------------------------------
77// Auth
78// ---------------------------------------------------------------------------
79
80function sha256hex(value: string): string {
81 return createHash("sha256").update(value).digest("hex");
82}
83
84type AuthResult =
85 | { ok: true; user: { id: string; username: string } }
86 | { ok: false };
87
88/**
89 * Docker clients send `Authorization: Basic base64("user:token")`.
90 * We ignore the username part and validate the token (password) against
91 * the api_tokens table just like Bearer token auth elsewhere.
92 */
93async function authenticateBasic(authHeader: string | undefined): Promise<AuthResult> {
94 if (!authHeader) return { ok: false };
95
96 let encoded: string;
97 if (authHeader.startsWith("Basic ")) {
98 encoded = authHeader.slice(6).trim();
99 } else if (authHeader.startsWith("Bearer ")) {
100 // Docker Desktop sometimes sends Bearer after the initial 401 WWW-Auth exchange
101 const raw = authHeader.slice(7).trim();
102 if (!raw) return { ok: false };
103 const tokenHash = sha256hex(raw);
104 try {
105 const [tokenRow] = await db
106 .select({ userId: apiTokens.userId, expiresAt: apiTokens.expiresAt, id: apiTokens.id })
107 .from(apiTokens)
108 .where(eq(apiTokens.tokenHash, tokenHash))
109 .limit(1);
110 if (!tokenRow) return { ok: false };
111 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) return { ok: false };
112 const [user] = await db
113 .select({ id: users.id, username: users.username })
114 .from(users)
115 .where(eq(users.id, tokenRow.userId))
116 .limit(1);
117 if (!user) return { ok: false };
118 db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, tokenRow.id)).catch(() => {});
119 return { ok: true, user };
120 } catch {
121 return { ok: false };
122 }
123 } else {
124 return { ok: false };
125 }
126
127 let decoded: string;
128 try {
129 decoded = Buffer.from(encoded, "base64").toString("utf-8");
130 } catch {
131 return { ok: false };
132 }
133
134 // "user:token" — Docker spec allows colons in password, split on first colon only
135 const colonIdx = decoded.indexOf(":");
136 if (colonIdx < 0) return { ok: false };
137 const rawToken = decoded.slice(colonIdx + 1);
138 if (!rawToken) return { ok: false };
139
140 const tokenHash = sha256hex(rawToken);
141 try {
142 const [tokenRow] = await db
143 .select({ userId: apiTokens.userId, expiresAt: apiTokens.expiresAt, id: apiTokens.id })
144 .from(apiTokens)
145 .where(eq(apiTokens.tokenHash, tokenHash))
146 .limit(1);
147 if (!tokenRow) return { ok: false };
148 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) return { ok: false };
149 const [user] = await db
150 .select({ id: users.id, username: users.username })
151 .from(users)
152 .where(eq(users.id, tokenRow.userId))
153 .limit(1);
154 if (!user) return { ok: false };
155 // Best-effort touch
156 db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, tokenRow.id)).catch(() => {});
157 return { ok: true, user };
158 } catch {
159 return { ok: false };
160 }
161}
162
163const UNAUTHORIZED_HEADERS = {
164 "WWW-Authenticate": 'Basic realm="Gluecron Container Registry"',
165 "Content-Type": "application/json",
166};
167
168// ---------------------------------------------------------------------------
169// Storage helpers
170// ---------------------------------------------------------------------------
171
172/** Resolve (and lazily create) storage directories. */
173function storePath(...segments: string[]): string {
174 return join(config.ociStorePath, ...segments);
175}
176
177async function ensureDir(path: string): Promise<void> {
178 await mkdir(path, { recursive: true });
179}
180
181/** Absolute path to a finished blob file. */
182function blobPath(digest: string): string {
183 // digest = "sha256:<hex>" or just "<hex>"
184 const hex = digest.startsWith("sha256:") ? digest.slice(7) : digest;
185 return storePath("blobs", "sha256", hex);
186}
187
188/** Absolute path to a manifest file for a given image name + ref. */
189function manifestPath(name: string, ref: string): string {
190 return storePath("manifests", name, ref);
191}
192
193/** Absolute path to an in-progress upload chunk file. */
194function uploadPath(uuid: string): string {
195 return storePath("uploads", uuid);
196}
197
198/** Compute sha256 digest of a file on disk. */
199async function digestOfFile(path: string): Promise<string> {
200 const file = Bun.file(path);
201 const buf = await file.arrayBuffer();
202 const bytes = new Uint8Array(buf);
203 const h = new Bun.CryptoHasher("sha256");
204 h.update(bytes);
205 return `sha256:${h.digest("hex")}`;
206}
207
208/** Validate that a "sha256:<hex64>" digest string is well-formed. */
209function isValidDigest(digest: string): boolean {
210 return /^sha256:[0-9a-f]{64}$/.test(digest);
211}
212
213// ---------------------------------------------------------------------------
214// DB helpers — oci_repositories + oci_tags
215// ---------------------------------------------------------------------------
216
217/** Find or create an oci_repositories row for owner/image. */
218async function findOrCreateOciRepo(
219 ownerId: string,
220 name: string,
221 visibility: "public" | "private" = "private"
222): Promise<string> {
223 const [existing] = await db
224 .select({ id: ociRepositories.id })
225 .from(ociRepositories)
226 .where(and(eq(ociRepositories.ownerId, ownerId), eq(ociRepositories.name, name)))
227 .limit(1);
228 if (existing) return existing.id;
229
230 const [inserted] = await db
231 .insert(ociRepositories)
232 .values({ ownerId, name, visibility })
233 .returning({ id: ociRepositories.id });
234 return inserted.id;
235}
236
237/** Upsert a tag → digest mapping. */
238async function upsertTag(repositoryId: string, tag: string, manifestDigest: string): Promise<void> {
239 const [existing] = await db
240 .select({ id: ociTags.id })
241 .from(ociTags)
242 .where(and(eq(ociTags.repositoryId, repositoryId), eq(ociTags.tag, tag)))
243 .limit(1);
244
245 if (existing) {
246 await db
247 .update(ociTags)
248 .set({ manifestDigest, updatedAt: new Date() })
249 .where(eq(ociTags.id, existing.id));
250 } else {
251 await db.insert(ociTags).values({ repositoryId, tag, manifestDigest });
252 }
253}
254
255// ---------------------------------------------------------------------------
256// Route setup
257// ---------------------------------------------------------------------------
258
259const registry = new Hono<AuthEnv>();
260
261// The OCI spec uses `:name` which can contain slashes (e.g. "owner/image").
262// Hono's wildcard param ({name:*}) doesn't play well with path segments, so
263// we parse the image name from c.req.path directly in each handler.
264
265// ---------------------------------------------------------------------------
266// GET /v2/ — Version check
267// ---------------------------------------------------------------------------
268registry.get("/v2/", async (c) => {
269 const auth = await authenticateBasic(c.req.header("authorization"));
270 if (!auth.ok) {
271 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
272 }
273 return c.json({}, 200, { "Docker-Distribution-API-Version": "registry/2.0" });
274});
275
276// ---------------------------------------------------------------------------
277// GET /v2/_catalog — list all repositories the caller can see
278// ---------------------------------------------------------------------------
279registry.get("/v2/_catalog", async (c) => {
280 const auth = await authenticateBasic(c.req.header("authorization"));
281 if (!auth.ok) {
282 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
283 }
284
285 try {
286 const rows = await db
287 .select({ name: ociRepositories.name })
288 .from(ociRepositories)
289 .where(eq(ociRepositories.ownerId, auth.user.id))
290 .orderBy(ociRepositories.name);
291
292 return c.json({ repositories: rows.map((r) => r.name) });
293 } catch (err) {
294 console.error("[oci] catalog:", err);
295 return c.json(ociError("UNSUPPORTED", "service error"), 500);
296 }
297});
298
299// ---------------------------------------------------------------------------
300// Blob endpoints — /v2/:name/blobs/...
301// We parse :name as a wildcard from the URL manually.
302// ---------------------------------------------------------------------------
303
304/** Extract image name + remainder from path like /v2/<name>/blobs/... */
305function parseV2Path(
306 path: string,
307 segment: string
308): { name: string; rest: string } | null {
309 // /v2/<name>/blobs/... or /v2/<name>/manifests/... etc.
310 const prefix = "/v2/";
311 if (!path.startsWith(prefix)) return null;
312 const after = path.slice(prefix.length);
313 const segIdx = after.indexOf(`/${segment}/`);
314 if (segIdx < 0) {
315 // check for exact match at end (e.g., /v2/<name>/tags/list)
316 const segEnd = after.indexOf(`/${segment}`);
317 if (segEnd < 0) return null;
318 const name = after.slice(0, segEnd);
319 const rest = after.slice(segEnd + segment.length + 1);
320 return { name, rest };
321 }
322 const name = after.slice(0, segIdx);
323 const rest = after.slice(segIdx + segment.length + 2);
324 return { name, rest };
325}
326
327// HEAD /v2/:name/blobs/:digest
328registry.on(["HEAD", "GET"], "/v2/*", async (c) => {
329 const path = c.req.path;
330
331 // ── HEAD/GET /v2/:name/blobs/:digest ──────────────────────────────────────
332 if (/\/blobs\/sha256:[0-9a-f]{64}$/.test(path)) {
333 const auth = await authenticateBasic(c.req.header("authorization"));
334 if (!auth.ok) {
335 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
336 }
337
338 const digestMatch = path.match(/\/blobs\/(sha256:[0-9a-f]{64})$/);
339 if (!digestMatch) {
340 return c.json(ociError("DIGEST_INVALID", "invalid digest"), 400);
341 }
342 const digest = digestMatch[1];
343 const bp = blobPath(digest);
344 const file = Bun.file(bp);
345 const exists = await file.exists();
346 if (!exists) {
347 return c.json(ociError("BLOB_UNKNOWN", "blob unknown to registry"), 404, {
348 "Docker-Content-Digest": digest,
349 });
350 }
351
352 const headers: Record<string, string> = {
353 "Docker-Content-Digest": digest,
354 "Content-Length": String(file.size),
355 "Content-Type": "application/octet-stream",
356 };
357
358 if (c.req.method === "HEAD") {
359 return new Response(null, { status: 200, headers });
360 }
361
362 // GET — stream the blob
363 return new Response(file.stream(), { status: 200, headers });
364 }
365
366 // ── GET /v2/:name/tags/list ───────────────────────────────────────────────
367 if (path.endsWith("/tags/list")) {
368 const auth = await authenticateBasic(c.req.header("authorization"));
369 if (!auth.ok) {
370 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
371 }
372
373 const parsed = parseV2Path(path, "tags");
374 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
375 const name = parsed.name;
376
377 try {
378 const [repo] = await db
379 .select({ id: ociRepositories.id })
380 .from(ociRepositories)
381 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
382 .limit(1);
383
384 if (!repo) {
385 return c.json({ name, tags: [] });
386 }
387
388 const tagRows = await db
389 .select({ tag: ociTags.tag })
390 .from(ociTags)
391 .where(eq(ociTags.repositoryId, repo.id))
392 .orderBy(ociTags.tag);
393
394 return c.json({ name, tags: tagRows.map((t) => t.tag) });
395 } catch (err) {
396 console.error("[oci] tags/list:", err);
397 return c.json(ociError("UNSUPPORTED", "service error"), 500);
398 }
399 }
400
401 // ── GET /v2/:name/manifests/:ref ──────────────────────────────────────────
402 if (path.includes("/manifests/")) {
403 const auth = await authenticateBasic(c.req.header("authorization"));
404 if (!auth.ok) {
405 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
406 }
407
408 const parsed = parseV2Path(path, "manifests");
409 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
410 const name = parsed.name;
411 const ref = parsed.rest;
412
413 // ref may be a tag name or a digest (sha256:...)
414 try {
415 let resolvedPath: string | null = null;
416 let resolvedDigest: string | null = null;
417
418 if (isValidDigest(ref)) {
419 // Direct digest reference — look up the blob path
420 const bp = blobPath(ref);
421 const file = Bun.file(bp);
422 if (!(await file.exists())) {
423 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
424 }
425 resolvedPath = manifestPath(name, ref);
426 // Fall back: the manifest might be stored by digest directly
427 const mf = Bun.file(resolvedPath);
428 if (!(await mf.exists())) {
429 resolvedPath = bp; // manifests stored as blobs too
430 }
431 resolvedDigest = ref;
432 } else {
433 // Tag name — look up in DB
434 const [repo] = await db
435 .select({ id: ociRepositories.id })
436 .from(ociRepositories)
437 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
438 .limit(1);
439
440 if (!repo) {
441 return c.json(ociError("NAME_UNKNOWN", "repository name not known to registry"), 404);
442 }
443
444 const [tagRow] = await db
445 .select({ manifestDigest: ociTags.manifestDigest })
446 .from(ociTags)
447 .where(and(eq(ociTags.repositoryId, repo.id), eq(ociTags.tag, ref)))
448 .limit(1);
449
450 if (!tagRow) {
451 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
452 }
453
454 resolvedDigest = tagRow.manifestDigest;
455 resolvedPath = manifestPath(name, ref);
456 const mf = Bun.file(resolvedPath);
457 if (!(await mf.exists())) {
458 // Fall back to digest path
459 resolvedPath = blobPath(resolvedDigest);
460 }
461 }
462
463 if (!resolvedPath || !resolvedDigest) {
464 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
465 }
466
467 const file = Bun.file(resolvedPath);
468 if (!(await file.exists())) {
469 return c.json(ociError("MANIFEST_UNKNOWN", "manifest unknown"), 404);
470 }
471
472 const content = await file.text();
473 let contentType = "application/vnd.oci.image.manifest.v1+json";
474 try {
475 const parsed2 = JSON.parse(content);
476 if (parsed2.mediaType) contentType = parsed2.mediaType;
477 else if (parsed2.schemaVersion === 2 && parsed2.config) {
478 contentType = "application/vnd.docker.distribution.manifest.v2+json";
479 }
480 } catch { /* leave default */ }
481
482 if (c.req.method === "HEAD") {
483 return new Response(null, {
484 status: 200,
485 headers: {
486 "Docker-Content-Digest": resolvedDigest,
487 "Content-Length": String(Buffer.byteLength(content)),
488 "Content-Type": contentType,
489 },
490 });
491 }
492
493 return new Response(content, {
494 status: 200,
495 headers: {
496 "Docker-Content-Digest": resolvedDigest,
497 "Content-Type": contentType,
498 },
499 });
500 } catch (err) {
501 console.error("[oci] get manifest:", err);
502 return c.json(ociError("UNSUPPORTED", "service error"), 500);
503 }
504 }
505
506 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
507});
508
509// ---------------------------------------------------------------------------
510// POST /v2/:name/blobs/uploads/ — start a new upload session
511// ---------------------------------------------------------------------------
512registry.post("/v2/*", async (c) => {
513 const path = c.req.path;
514
515 if (!path.includes("/blobs/uploads")) {
516 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
517 }
518
519 const auth = await authenticateBasic(c.req.header("authorization"));
520 if (!auth.ok) {
521 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
522 }
523
524 // Parse image name
525 const parsed = parseV2Path(path, "blobs");
526 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid image name"), 400);
527 const name = parsed.name;
528
529 // Check for single-request monolithic upload: POST with ?digest=sha256:...
530 const digestParam = c.req.query("digest");
531 if (digestParam) {
532 if (!isValidDigest(digestParam)) {
533 return c.json(ociError("DIGEST_INVALID", "invalid digest format"), 400);
534 }
535 // Single-step upload (used for small layers)
536 try {
537 const body = await c.req.arrayBuffer();
538 const bytes = new Uint8Array(body);
539 const h = new Bun.CryptoHasher("sha256");
540 h.update(bytes);
541 const computed = `sha256:${h.digest("hex")}`;
542 if (computed !== digestParam) {
543 return c.json(ociError("DIGEST_INVALID", "digest mismatch"), 400);
544 }
545 await ensureDir(storePath("blobs", "sha256"));
546 const bp = blobPath(digestParam);
547 const existing = Bun.file(bp);
548 if (!(await existing.exists())) {
549 await Bun.write(bp, bytes);
550 }
551 // Ensure OCI repo record exists
552 await findOrCreateOciRepo(auth.user.id, name);
553 const baseUrl = new URL(c.req.url).origin;
554 return new Response(null, {
555 status: 201,
556 headers: {
557 Location: `${baseUrl}/v2/${name}/blobs/${digestParam}`,
558 "Docker-Content-Digest": digestParam,
559 "Content-Length": "0",
560 },
561 });
562 } catch (err) {
563 console.error("[oci] monolithic upload:", err);
564 return c.json(ociError("UNSUPPORTED", "upload failed"), 500);
565 }
566 }
567
568 // Chunked upload — allocate a UUID
569 const uuid = randomUUID();
570 try {
571 await ensureDir(storePath("uploads"));
572 // Create an empty placeholder so PATCH has something to append to
573 await Bun.write(uploadPath(uuid), new Uint8Array(0));
574 await ensureDir(storePath("blobs", "sha256"));
575 await findOrCreateOciRepo(auth.user.id, name);
576 const baseUrl = new URL(c.req.url).origin;
577 return new Response(null, {
578 status: 202,
579 headers: {
580 Location: `${baseUrl}/v2/${name}/blobs/uploads/${uuid}`,
581 "Docker-Upload-UUID": uuid,
582 Range: "0-0",
583 "Content-Length": "0",
584 },
585 });
586 } catch (err) {
587 console.error("[oci] start upload:", err);
588 return c.json(ociError("UNSUPPORTED", "failed to start upload"), 500);
589 }
590});
591
592// ---------------------------------------------------------------------------
593// PATCH /v2/:name/blobs/uploads/:uuid — stream chunk data
594// ---------------------------------------------------------------------------
595registry.patch("/v2/*", async (c) => {
596 const path = c.req.path;
597 const auth = await authenticateBasic(c.req.header("authorization"));
598 if (!auth.ok) {
599 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
600 }
601
602 // Extract uuid from path: /v2/<name>/blobs/uploads/<uuid>
603 const uuidMatch = path.match(/\/blobs\/uploads\/([^/]+)$/);
604 if (!uuidMatch) {
605 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload not found"), 404);
606 }
607 const uuid = uuidMatch[1];
608
609 const parsed = parseV2Path(path, "blobs");
610 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
611 const name = parsed.name;
612
613 try {
614 const uploadFile = Bun.file(uploadPath(uuid));
615 if (!(await uploadFile.exists())) {
616 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload session not found"), 404);
617 }
618
619 const chunk = await c.req.arrayBuffer();
620 const chunkBytes = new Uint8Array(chunk);
621
622 // Append the chunk to the upload accumulator using Bun's writer
623 const existingBytes = new Uint8Array(await uploadFile.arrayBuffer());
624 const combined = new Uint8Array(existingBytes.length + chunkBytes.length);
625 combined.set(existingBytes, 0);
626 combined.set(chunkBytes, existingBytes.length);
627 await Bun.write(uploadPath(uuid), combined);
628
629 const newSize = combined.length;
630 const baseUrl = new URL(c.req.url).origin;
631 return new Response(null, {
632 status: 202,
633 headers: {
634 Location: `${baseUrl}/v2/${name}/blobs/uploads/${uuid}`,
635 "Docker-Upload-UUID": uuid,
636 Range: `0-${Math.max(0, newSize - 1)}`,
637 "Content-Length": "0",
638 },
639 });
640 } catch (err) {
641 console.error("[oci] patch upload:", err);
642 return c.json(ociError("BLOB_UPLOAD_INVALID", "chunk write failed"), 500);
643 }
644});
645
646// ---------------------------------------------------------------------------
647// PUT /v2/:name/blobs/uploads/:uuid?digest=sha256:... — complete upload
648// PUT /v2/:name/manifests/:ref — push manifest
649// ---------------------------------------------------------------------------
650registry.put("/v2/*", async (c) => {
651 const path = c.req.path;
652 const auth = await authenticateBasic(c.req.header("authorization"));
653 if (!auth.ok) {
654 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
655 }
656
657 // ── PUT manifest ──────────────────────────────────────────────────────────
658 if (path.includes("/manifests/")) {
659 const parsed = parseV2Path(path, "manifests");
660 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
661 const name = parsed.name;
662 const ref = parsed.rest;
663
664 try {
665 const body = await c.req.text();
666 const bytes = Buffer.from(body);
667
668 // Compute the digest of the manifest
669 const h = new Bun.CryptoHasher("sha256");
670 h.update(bytes);
671 const digest = `sha256:${h.digest("hex")}`;
672
673 // Store manifest under both the ref (tag/digest) and the canonical digest path
674 await ensureDir(storePath("manifests", name));
675 await ensureDir(storePath("blobs", "sha256"));
676
677 const mPath = manifestPath(name, ref);
678 await Bun.write(mPath, bytes);
679
680 // Also write under the digest ref so GET by digest works
681 if (ref !== digest) {
682 const mDigestPath = manifestPath(name, digest);
683 await Bun.write(mDigestPath, bytes);
684 }
685
686 // Store the raw bytes in the blob store too (some clients fetch by digest)
687 const bp = blobPath(digest);
688 const existing = Bun.file(bp);
689 if (!(await existing.exists())) {
690 await Bun.write(bp, bytes);
691 }
692
693 // If ref is a tag (not a digest), record in DB
694 const ociRepoId = await findOrCreateOciRepo(auth.user.id, name);
695 if (!isValidDigest(ref)) {
696 await upsertTag(ociRepoId, ref, digest);
697 }
698
699 const contentType =
700 c.req.header("content-type") ||
701 "application/vnd.oci.image.manifest.v1+json";
702
703 return new Response(null, {
704 status: 201,
705 headers: {
706 "Docker-Content-Digest": digest,
707 Location: `/v2/${name}/manifests/${ref}`,
708 "Content-Type": contentType,
709 "Content-Length": "0",
710 },
711 });
712 } catch (err) {
713 console.error("[oci] put manifest:", err);
714 return c.json(ociError("MANIFEST_INVALID", "failed to store manifest"), 500);
715 }
716 }
717
718 // ── PUT /v2/:name/blobs/uploads/:uuid — complete chunked upload ───────────
719 const uuidMatch = path.match(/\/blobs\/uploads\/([^/?]+)/);
720 if (!uuidMatch) {
721 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
722 }
723 const uuid = uuidMatch[1];
724 const digestParam = c.req.query("digest");
725
726 if (!digestParam || !isValidDigest(digestParam)) {
727 return c.json(ociError("DIGEST_INVALID", "missing or invalid digest query param"), 400);
728 }
729
730 try {
731 const uploadFile = Bun.file(uploadPath(uuid));
732 if (!(await uploadFile.exists())) {
733 return c.json(ociError("BLOB_UPLOAD_UNKNOWN", "upload session not found"), 404);
734 }
735
736 // There may be a final chunk in the PUT body
737 const finalChunk = await c.req.arrayBuffer();
738 let allBytes: Uint8Array;
739 const existingBytes = new Uint8Array(await uploadFile.arrayBuffer());
740 if (finalChunk.byteLength > 0) {
741 const chunkBytes = new Uint8Array(finalChunk);
742 allBytes = new Uint8Array(existingBytes.length + chunkBytes.length);
743 allBytes.set(existingBytes, 0);
744 allBytes.set(chunkBytes, existingBytes.length);
745 } else {
746 allBytes = existingBytes;
747 }
748
749 // Verify digest
750 const h = new Bun.CryptoHasher("sha256");
751 h.update(allBytes);
752 const computed = `sha256:${h.digest("hex")}`;
753 if (computed !== digestParam) {
754 return c.json(ociError("DIGEST_INVALID", "digest mismatch"), 400);
755 }
756
757 // Move from uploads to blobs
758 await ensureDir(storePath("blobs", "sha256"));
759 const bp = blobPath(digestParam);
760 const existingBlob = Bun.file(bp);
761 if (!(await existingBlob.exists())) {
762 await Bun.write(bp, allBytes);
763 }
764
765 // Clean up upload temp file
766 const uploadFilePath = uploadPath(uuid);
767 try {
768 await Bun.file(uploadFilePath);
769 // Overwrite with empty so it's not left hanging; true deletion needs fs.unlink
770 const { unlink } = await import("node:fs/promises");
771 await unlink(uploadFilePath).catch(() => {});
772 } catch { /* ignore */ }
773
774 const parsed = parseV2Path(path, "blobs");
775 const name = parsed?.name ?? "unknown";
776
777 return new Response(null, {
778 status: 201,
779 headers: {
780 "Docker-Content-Digest": digestParam,
781 Location: `/v2/${name}/blobs/${digestParam}`,
782 "Content-Length": "0",
783 },
784 });
785 } catch (err) {
786 console.error("[oci] complete upload:", err);
787 return c.json(ociError("BLOB_UPLOAD_INVALID", "failed to finalize upload"), 500);
788 }
789});
790
791// ---------------------------------------------------------------------------
792// DELETE /v2/:name/blobs/:digest
793// DELETE /v2/:name/manifests/:ref
794// ---------------------------------------------------------------------------
795registry.delete("/v2/*", async (c) => {
796 const path = c.req.path;
797 const auth = await authenticateBasic(c.req.header("authorization"));
798 if (!auth.ok) {
799 return c.json(ociError("UNAUTHORIZED", "authentication required"), 401, UNAUTHORIZED_HEADERS);
800 }
801
802 const { unlink } = await import("node:fs/promises");
803
804 // ── DELETE /v2/:name/manifests/:ref ───────────────────────────────────────
805 if (path.includes("/manifests/")) {
806 const parsed = parseV2Path(path, "manifests");
807 if (!parsed) return c.json(ociError("NAME_INVALID", "invalid name"), 400);
808 const name = parsed.name;
809 const ref = parsed.rest;
810
811 try {
812 const mPath = manifestPath(name, ref);
813 await unlink(mPath).catch(() => {});
814
815 // Remove tag from DB if ref is a tag
816 if (!isValidDigest(ref)) {
817 const [repo] = await db
818 .select({ id: ociRepositories.id })
819 .from(ociRepositories)
820 .where(and(eq(ociRepositories.ownerId, auth.user.id), eq(ociRepositories.name, name)))
821 .limit(1);
822 if (repo) {
823 await db
824 .delete(ociTags)
825 .where(and(eq(ociTags.repositoryId, repo.id), eq(ociTags.tag, ref)));
826 }
827 }
828
829 return new Response(null, { status: 202 });
830 } catch (err) {
831 console.error("[oci] delete manifest:", err);
832 return c.json(ociError("MANIFEST_UNKNOWN", "manifest not found"), 404);
833 }
834 }
835
836 // ── DELETE /v2/:name/blobs/:digest ────────────────────────────────────────
837 if (path.includes("/blobs/")) {
838 const digestMatch = path.match(/\/blobs\/(sha256:[0-9a-f]{64})$/);
839 if (!digestMatch) {
840 return c.json(ociError("DIGEST_INVALID", "invalid digest"), 400);
841 }
842 const digest = digestMatch[1];
843 const bp = blobPath(digest);
844 const file = Bun.file(bp);
845 if (!(await file.exists())) {
846 return c.json(ociError("BLOB_UNKNOWN", "blob not found"), 404);
847 }
848 await unlink(bp).catch(() => {});
849 return new Response(null, { status: 202 });
850 }
851
852 return c.json(ociError("UNSUPPORTED", "unsupported endpoint"), 404);
853});
854
855export default registry;
Modifiedsrc/routes/previews.tsx+290−19View fileUnifiedSplit
11/**
2 * Per-branch preview URLs — list view (migration 0062).
2 * Per-branch / per-PR preview URLs (migrations 0062, 0077).
33 *
4 * GET /:owner/:repo/previews
5 *
6 * Lists every live preview row for the repo (one per branch). Status
7 * pills, mono branch names, short SHAs, clickable URLs, expires-in
8 * countdowns. Empty state when no pushes have been made to a
9 * non-default branch yet.
4 * GET /:owner/:repo/previews — list view
5 * GET /:owner/:repo/pull/:prNumber/preview — redirect to live preview for this PR
6 * GET /previews/:owner/:repo/:prBranch/* — serve built static files
7 * POST /api/previews/rebuild/:prId — trigger a rebuild (webhook)
108 *
119 * All page-local CSS is scoped under `.preview-*` so it can't bleed
1210 * into the shared layout (per CLAUDE.md: do NOT modify shared
1311 * layout/components/ui). Mirrors the gradient hairline + orb pattern
1412 * used by environments.tsx / admin-integrations.tsx.
15 *
16 * The corresponding JSON API + force-rebuild endpoints live in
17 * src/routes/api-v2.ts.
1813 */
1914
2015import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
16import { and, eq, desc } from "drizzle-orm";
2217import { db } from "../db";
23import { repositories, users } from "../db/schema";
24import { softAuth } from "../middleware/auth";
18import { repositories, users, pullRequests, prPreviews } from "../db/schema";
19import { softAuth, requireAuth } from "../middleware/auth";
2520import type { AuthEnv } from "../middleware/auth";
2621import { Layout } from "../views/layout";
2722import { RepoHeader, RepoNav } from "../views/components";
3126 listPreviewsForRepo,
3227 previewStatusLabel,
3328} from "../lib/branch-previews";
29import { buildPreview } from "../lib/preview-builder";
30import { join } from "path";
31import { existsSync } from "fs";
32
33/** Minimal MIME type lookup for common web assets. */
34function getMimeType(filePath: string): string {
35 const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
36 const map: Record<string, string> = {
37 html: "text/html; charset=utf-8",
38 htm: "text/html; charset=utf-8",
39 css: "text/css",
40 js: "application/javascript",
41 mjs: "application/javascript",
42 json: "application/json",
43 png: "image/png",
44 jpg: "image/jpeg",
45 jpeg: "image/jpeg",
46 gif: "image/gif",
47 svg: "image/svg+xml",
48 ico: "image/x-icon",
49 woff: "font/woff",
50 woff2: "font/woff2",
51 ttf: "font/ttf",
52 txt: "text/plain",
53 xml: "application/xml",
54 webp: "image/webp",
55 avif: "image/avif",
56 mp4: "video/mp4",
57 webm: "video/webm",
58 pdf: "application/pdf",
59 wasm: "application/wasm",
60 };
61 return map[ext] || "application/octet-stream";
62}
3463
3564const r = new Hono<AuthEnv>();
3665r.use("*", softAuth);
263292 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35);
264293 }
265294 .preview-pill.is-expired {
266 background: rgba(148,163,184,0.10);
267 color: #cbd5e1;
268 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
295 background: rgba(100,116,139,0.10);
296 color: #94a3b8;
297 box-shadow: inset 0 0 0 1px rgba(100,116,139,0.28);
298 }
299
300 /* ─── expired card treatment ─── */
301 .preview-card.is-expired .preview-card-branch { color: var(--text-muted); }
302 .preview-card.is-expired .preview-card-url-expired {
303 color: var(--text-muted);
304 text-decoration: line-through;
305 text-decoration-color: rgba(148,163,184,0.45);
306 }
307 .preview-rebuild-btn {
308 display: inline-flex;
309 align-items: center;
310 gap: 5px;
311 margin-top: var(--space-2);
312 padding: 4px 12px;
313 border-radius: 8px;
314 font-size: 12px;
315 font-weight: 600;
316 font-family: var(--font-mono);
317 color: #a48bff;
318 background: rgba(140,109,255,0.08);
319 border: 1px solid rgba(140,109,255,0.22);
320 cursor: pointer;
321 text-decoration: none;
322 transition: background 120ms ease, border-color 120ms ease;
323 }
324 .preview-rebuild-btn:hover {
325 background: rgba(140,109,255,0.15);
326 border-color: rgba(140,109,255,0.40);
269327 }
270328 .preview-pill-dot {
271329 width: 6px; height: 6px;
454512 {previews.map((p) => {
455513 const shortSha = (p.commitSha || "").slice(0, 7);
456514 const expiresLabel = formatExpiresIn(p.expiresAt, now);
515 const isExpired = p.status === "expired";
457516 const statusKey = p.status as
458517 | "building"
459518 | "ready"
460519 | "failed"
461520 | "expired";
462521 const pillClass = `preview-pill is-${statusKey}`;
522 const cardClass = isExpired
523 ? "preview-card is-expired"
524 : "preview-card";
525 // Rebuild pushes a branch-preview re-enqueue via the API.
526 const rebuildHref = `/${owner}/${repo}/previews/rebuild?branch=${encodeURIComponent(p.branchName)}`;
463527 return (
464 <div class="preview-card">
528 <div class={cardClass}>
465529 <div class="preview-card-head">
466530 <div class="preview-card-titles">
467531 <h3 class="preview-card-branch">{p.branchName}</h3>
470534 <a href={p.previewUrl} target="_blank" rel="noopener noreferrer">
471535 {p.previewUrl}
472536 </a>
537 ) : isExpired ? (
538 <span class="preview-card-url-expired">
539 {p.previewUrl}
540 </span>
473541 ) : (
474542 <span style="color: var(--text-muted)">
475543 {p.previewUrl}
481549 commit <code>{shortSha}</code>
482550 </span>
483551 <span>
484 {p.status === "expired"
485 ? "expired"
552 {isExpired
553 ? "preview expired · push to branch to rebuild"
486554 : `expires in ${expiresLabel}`}
487555 </span>
488556 </div>
557 {isExpired && (
558 <a
559 href={rebuildHref}
560 class="preview-rebuild-btn"
561 title="Re-enqueue a preview build for this branch"
562 >
563 ↺ Rebuild
564 </a>
565 )}
489566 </div>
490567 <div>
491568 <span class={pillClass}>
509586 );
510587});
511588
589// ─── PR preview redirect ────────────────────────────────────────────────────
590// GET /:owner/:repo/pull/:prNumber/preview
591// Redirect to the live preview URL for this PR (from pr_previews table).
592// Falls back to the previews list if no ready build exists.
593r.get("/:owner/:repo/pull/:prNumber/preview", softAuth, async (c) => {
594 const { owner, repo, prNumber } = c.req.param();
595 const num = parseInt(prNumber, 10);
596
597 try {
598 // Resolve repo
599 const [repoRow] = await db
600 .select({ id: repositories.id })
601 .from(repositories)
602 .innerJoin(users, eq(repositories.ownerId, users.id))
603 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
604 .limit(1);
605 if (!repoRow) return c.notFound();
606
607 // Find the PR
608 const [pr] = await db
609 .select({ id: pullRequests.id })
610 .from(pullRequests)
611 .where(and(eq(pullRequests.repositoryId, repoRow.id), eq(pullRequests.number, num)))
612 .limit(1);
613 if (!pr) return c.notFound();
614
615 // Find the most recent ready preview for this PR
616 const [preview] = await db
617 .select({ previewUrl: prPreviews.previewUrl, status: prPreviews.status })
618 .from(prPreviews)
619 .where(and(eq(prPreviews.prId, pr.id), eq(prPreviews.status, "ready")))
620 .orderBy(desc(prPreviews.id))
621 .limit(1);
622
623 if (preview?.previewUrl) {
624 return c.redirect(preview.previewUrl, 302);
625 }
626 } catch (err) {
627 console.warn("[previews] PR preview redirect failed:", err instanceof Error ? err.message : err);
628 }
629
630 // Fall back to the previews list page
631 return c.redirect(`/${owner}/${repo}/previews`, 302);
632});
633
634// ─── Static file serving ────────────────────────────────────────────────────
635// GET /previews/:owner/:repo/:prBranch/*
636// Serves built output from the temp build directory created by preview-builder.
637// Only active when PREVIEW_DOMAIN is set (or when the dev fallback applies).
638r.get("/previews/:owner/:repo/:prBranch/*", async (c) => {
639 const { owner, repo, prBranch } = c.req.param();
640 const wildcard = c.req.param("*") || "";
641
642 // Resolve the most recent ready pr_previews row for this branch
643 try {
644 const [repoRow] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 if (!repoRow) return c.notFound();
651
652 const [preview] = await db
653 .select({
654 prId: prPreviews.prId,
655 headSha: prPreviews.headSha,
656 outputDir: prPreviews.outputDir,
657 status: prPreviews.status,
658 branchName: prPreviews.branchName,
659 })
660 .from(prPreviews)
661 .where(
662 and(
663 eq(prPreviews.repoId, repoRow.id),
664 eq(prPreviews.status, "ready"),
665 eq(prPreviews.branchName, prBranch)
666 )
667 )
668 .orderBy(desc(prPreviews.id))
669 .limit(1);
670
671 if (!preview || preview.status !== "ready") {
672 return c.text("Preview not ready yet", 404);
673 }
674
675 const outputDir = preview.outputDir || "dist";
676 // The build dir pattern from preview-builder.ts:
677 // /tmp/previews/<slug(prId)>-<shortSha>/<outputDir>
678 // We stored prId in the row, so read it back and reconstruct.
679 const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
680 const shortSha = preview.headSha.slice(0, 8);
681 const prIdSlug = preview.prId.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60);
682 const buildBase = `${PREVIEW_BUILD_DIR}/${prIdSlug}-${shortSha}/${outputDir}`;
683
684 let filePath = wildcard ? join(buildBase, wildcard) : join(buildBase, "index.html");
685
686 // Prevent directory traversal
687 if (!filePath.startsWith(buildBase)) {
688 return c.text("Forbidden", 403);
689 }
690
691 // Directory → serve index.html
692 if (existsSync(filePath) && !filePath.endsWith("/")) {
693 // Check if it's a directory
694 try {
695 const stat = await Bun.file(filePath).exists();
696 if (!stat && existsSync(join(filePath, "index.html"))) {
697 filePath = join(filePath, "index.html");
698 }
699 } catch {}
700 }
701
702 if (!existsSync(filePath)) {
703 // Try with index.html appended
704 const indexPath = join(filePath, "index.html");
705 if (existsSync(indexPath)) {
706 filePath = indexPath;
707 } else {
708 return c.text("File not found", 404);
709 }
710 }
711
712 const file = Bun.file(filePath);
713 const contentType = getMimeType(filePath);
714
715 return new Response(file, {
716 headers: {
717 "Content-Type": contentType,
718 "Cache-Control": "public, max-age=300",
719 "X-Preview-Sha": preview.headSha.slice(0, 8),
720 },
721 });
722 } catch (err) {
723 console.warn("[previews] static serve failed:", err instanceof Error ? err.message : err);
724 return c.text("Preview unavailable", 500);
725 }
726});
727
728// ─── Rebuild trigger API ─────────────────────────────────────────────────────
729// POST /api/previews/rebuild/:prId
730// Triggers a fresh build for the given PR. Auth required (repo write access).
731r.post("/api/previews/rebuild/:prId", requireAuth, async (c) => {
732 const { prId } = c.req.param();
733 const user = c.get("user");
734
735 try {
736 const [pr] = await db
737 .select({
738 id: pullRequests.id,
739 repositoryId: pullRequests.repositoryId,
740 headBranch: pullRequests.headBranch,
741 authorId: pullRequests.authorId,
742 })
743 .from(pullRequests)
744 .where(eq(pullRequests.id, prId))
745 .limit(1);
746
747 if (!pr) return c.json({ error: "PR not found" }, 404);
748
749 // Only the PR author or repo owner can trigger a rebuild
750 if (user!.id !== pr.authorId) {
751 const [repo] = await db
752 .select({ ownerId: repositories.ownerId })
753 .from(repositories)
754 .where(eq(repositories.id, pr.repositoryId))
755 .limit(1);
756 if (!repo || repo.ownerId !== user!.id) {
757 return c.json({ error: "Unauthorized" }, 403);
758 }
759 }
760
761 // Get the current head SHA from the most recent preview row, or use a sentinel
762 const [existing] = await db
763 .select({ headSha: prPreviews.headSha })
764 .from(prPreviews)
765 .where(eq(prPreviews.prId, prId))
766 .orderBy(desc(prPreviews.id))
767 .limit(1);
768
769 const headSha = existing?.headSha ?? "unknown";
770
771 // Fire-and-forget rebuild
772 buildPreview(prId, pr.repositoryId, headSha).catch((err) =>
773 console.warn("[previews] rebuild failed:", err instanceof Error ? err.message : err)
774 );
775
776 return c.json({ ok: true, message: "Rebuild triggered" });
777 } catch (err) {
778 console.warn("[previews] rebuild endpoint failed:", err instanceof Error ? err.message : err);
779 return c.json({ error: "Internal error" }, 500);
780 }
781});
782
512783export default r;
Modifiedsrc/routes/pricing.tsx+82−7View fileUnifiedSplit
3636 { tagline: string; supportTier: string }
3737> = {
3838 free: {
39 tagline: "Personal projects + open source. Full AI suite.",
39 tagline: "AI review on every PR, auto-merge, and spec-to-PR — on unlimited public repos, forever.",
4040 supportTier: "Community support",
4141 },
4242 pro: {
43 tagline: "Working developers shipping every day.",
43 tagline: "Private repos + higher AI quota. AI review, auto-merge, and spec-to-PR for working developers shipping daily.",
4444 supportTier: "Email support, priority AI queue",
4545 },
4646 team: {
47 tagline: "Teams running production on Gluecron.",
47 tagline: "Full AI suite for production teams — AI review, auto-merge when gates pass, and spec-to-PR at scale.",
4848 supportTier: "Slack channel + 24h response",
4949 },
5050 enterprise: {
51 tagline: "Orgs that need SSO, audit, on-prem.",
51 tagline: "SSO, audit log, on-prem deploy — with the full AI suite: review, auto-merge, and spec-to-PR.",
5252 supportTier: "24/7 incident response + DPA",
5353 },
5454};
5757 const user = c.get("user");
5858 const plans = await listPlans();
5959 return c.html(
60 <Layout title="Pricing — Gluecron" user={user}>
60 <Layout
61 title="Pricing — Gluecron"
62 user={user}
63 description="Simple pricing for AI-native git hosting. Unlimited repos, AI review, auto-merge, spec-to-PR. Start free."
64 ogTitle="Pricing — Gluecron"
65 ogDescription="Simple pricing for AI-native git hosting. Unlimited repos, AI review, auto-merge, spec-to-PR. Start free."
66 twitterCard="summary"
67 >
6168 <PricingPage plans={plans} loggedIn={!!user} />
6269 </Layout>
6370 );
8693 <div class="pl-hero-hairline" aria-hidden="true" />
8794 <div class="pl-hero-orb" aria-hidden="true" />
8895 <div class="eyebrow">Pricing</div>
96 <p class="pl-hero-speed">Spec to PR in 90 seconds.</p>
8997 <h1 class="display pl-hero-title">
9098 One subscription.{" "}
9199 <span class="gradient-text">
93101 </span>
94102 </h1>
95103 <p class="pl-hero-sub">
96 AI code review, autonomous PRs, code completion, and the git host
97 itself — bundled. What costs $89/user on GitHub + Copilot + Advanced
104 AI review fires in ~8s. Merges the instant gates pass. Spec to PR in
105 90 seconds. What costs $89/user on GitHub + Copilot + Advanced
98106 Security starts at $0 here.
99107 </p>
100108 <div class="pl-hero-jumps">
112120 ))}
113121 </section>
114122
123 {/* ------------------- Included in every plan ------------------- */}
124 <section class="pl-section pl-every-plan">
125 <div class="pl-every-plan-inner">
126 <div class="pl-every-plan-label">Included in every plan</div>
127 <ul class="pl-every-plan-list">
128 <li>AI review on every PR</li>
129 <li>Auto-merge when gates pass</li>
130 <li>Spec to PR in 90 seconds</li>
131 <li>Push Watch live stream</li>
132 </ul>
133 </div>
134 </section>
135
115136 {/* ------------------- Bundle math vs GitHub ------------------- */}
116137 <section id="compare" class="pl-section pl-compare">
117138 <div class="section-header">
412433 .pl-hero-orb { animation: none; }
413434 }
414435 .pl-hero .eyebrow { justify-content: center; margin: 0 auto var(--s-4); }
436 .pl-hero-speed {
437 font-family: var(--font-mono);
438 font-size: clamp(13px, 1.4vw, 16px);
439 font-weight: 600;
440 letter-spacing: 0.04em;
441 color: var(--accent);
442 margin: 0 0 var(--s-3);
443 text-transform: uppercase;
444 }
415445 .pl-hero-title {
416446 font-size: clamp(36px, 6.5vw, 72px);
417447 line-height: 1.02;
836866 flex-wrap: wrap;
837867 }
838868
869 /* Every-plan strip */
870 .pl-every-plan { margin: calc(var(--s-14) * -0.5) auto var(--s-10); }
871 .pl-every-plan-inner {
872 display: flex;
873 align-items: center;
874 gap: 20px;
875 flex-wrap: wrap;
876 justify-content: center;
877 padding: 14px 28px;
878 border: 1px solid var(--border-subtle);
879 border-radius: var(--r-full);
880 background: var(--bg-elevated);
881 max-width: 760px;
882 margin: 0 auto;
883 font-size: 13px;
884 }
885 .pl-every-plan-label {
886 font-family: var(--font-mono);
887 font-size: 11px;
888 text-transform: uppercase;
889 letter-spacing: 0.14em;
890 color: var(--text-muted);
891 white-space: nowrap;
892 }
893 .pl-every-plan-list {
894 list-style: none;
895 padding: 0;
896 margin: 0;
897 display: flex;
898 gap: 0;
899 flex-wrap: wrap;
900 justify-content: center;
901 }
902 .pl-every-plan-list li {
903 color: var(--text);
904 display: flex;
905 align-items: center;
906 gap: 10px;
907 }
908 .pl-every-plan-list li + li::before {
909 content: '·';
910 color: var(--text-muted);
911 margin-right: 10px;
912 }
913
839914 /* Responsive */
840915 @media (max-width: 960px) {
841916 .pl-plans { grid-template-columns: repeat(2, 1fr); }
Modifiedsrc/routes/projects.tsx+315−62View fileUnifiedSplit
2121 projectItems,
2222 repositories,
2323 users,
24 issues,
25 pullRequests,
2426} from "../db/schema";
2527import { Layout } from "../views/layout";
2628import { RepoHeader } from "../views/components";
377379 color: var(--text-strong);
378380 line-height: 1.35;
379381 }
382 .proj-kcard-link {
383 color: var(--text-strong);
384 text-decoration: none;
385 }
386 .proj-kcard-link:hover { text-decoration: underline; color: var(--accent); }
387 .proj-kcard-meta {
388 display: flex;
389 align-items: center;
390 gap: 6px;
391 margin-top: 4px;
392 flex-wrap: wrap;
393 }
394 .proj-kcard-num {
395 font-family: var(--font-mono);
396 font-size: 11px;
397 color: var(--text-muted);
398 font-variant-numeric: tabular-nums;
399 }
400 .proj-kcard-state {
401 display: inline-flex;
402 align-items: center;
403 padding: 1px 7px;
404 border-radius: 9999px;
405 font-size: 10.5px;
406 font-weight: 600;
407 text-transform: capitalize;
408 }
409 .proj-kcard-state.is-open { background: rgba(52,211,153,0.14); color: #6ee7b7; }
410 .proj-kcard-state.is-closed { background: rgba(148,163,184,0.16); color: #94a3b8; }
411 .proj-kcard-state.is-merged { background: rgba(167,139,250,0.14); color: #c4b5fd; }
412 .proj-kcard-state.is-draft { background: rgba(148,163,184,0.12); color: #94a3b8; }
380413 .proj-kcard-note {
381414 margin-top: 4px;
382415 color: var(--text-muted);
388421 display: flex;
389422 gap: 4px;
390423 flex-wrap: wrap;
424 align-items: center;
425 }
426 .proj-kcard-actions form { margin: 0; display: inline-flex; align-items: center; gap: 4px; }
427 .proj-kcard-select {
428 font: inherit;
429 font-size: 11.5px;
430 padding: 3px 6px;
431 color: var(--text);
432 background: var(--bg-elevated);
433 border: 1px solid var(--border);
434 border-radius: 6px;
435 cursor: pointer;
391436 }
392 .proj-kcard-actions form { margin: 0; display: inline; }
437 .proj-kcard-select:focus { outline: none; border-color: rgba(140,109,255,0.55); }
393438
394 .proj-kadd { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
439 .proj-kadd-details summary::-webkit-details-marker { display: none; }
440 .proj-kadd-panel {
441 margin-top: 8px;
442 background: rgba(255,255,255,0.02);
443 border: 1px solid var(--border);
444 border-radius: 10px;
445 padding: 10px;
446 display: flex;
447 flex-direction: column;
448 gap: 8px;
449 }
450 .proj-kadd-sep {
451 border: none;
452 border-top: 1px solid var(--border);
453 margin: 2px 0;
454 }
455 .proj-kadd { display: flex; flex-direction: column; gap: 6px; }
395456 .proj-kadd input {
396457 width: 100%;
397458 box-sizing: border-box;
770831 let project: any = null;
771832 let columns: any[] = [];
772833 let items: any[] = [];
834 // Maps itemId → { number, title, state } for linked issues/PRs
835 const linkedIssueMap: Record<string, { number: number; title: string; state: string; isDraft?: boolean }> = {};
836 const linkedPrMap: Record<string, { number: number; title: string; state: string; isDraft?: boolean }> = {};
837
773838 try {
774839 const [row] = await db
775840 .select()
793858 .from(projectItems)
794859 .where(eq(projectItems.projectId, row.id))
795860 .orderBy(asc(projectItems.position));
861
862 // Gather itemIds for linked issues / PRs so we can fetch their details.
863 const issueItemIds = items
864 .filter((it) => it.itemType === "issue" && it.itemId)
865 .map((it) => it.itemId as string);
866 const prItemIds = items
867 .filter((it) => it.itemType === "pr" && it.itemId)
868 .map((it) => it.itemId as string);
869
870 if (issueItemIds.length > 0) {
871 const issueRows = await db
872 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
873 .from(issues)
874 .where(eq(issues.repositoryId, resolved.repo.id));
875 for (const ir of issueRows) {
876 if (issueItemIds.includes(ir.id)) {
877 linkedIssueMap[ir.id] = { number: ir.number, title: ir.title, state: ir.state };
878 }
879 }
880 }
881 if (prItemIds.length > 0) {
882 const prRows = await db
883 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title, state: pullRequests.state, isDraft: pullRequests.isDraft })
884 .from(pullRequests)
885 .where(eq(pullRequests.repositoryId, resolved.repo.id));
886 for (const pr of prRows) {
887 if (prItemIds.includes(pr.id)) {
888 linkedPrMap[pr.id] = { number: pr.number, title: pr.title, state: pr.state, isDraft: pr.isDraft };
889 }
890 }
891 }
796892 }
797893 } catch {
798894 // leave nulls
858954 {(itemsByCol[col.id] || []).length}
859955 </span>
860956 </div>
861 {(itemsByCol[col.id] || []).map((it) => (
862 <div class="proj-kcard">
863 <div class="proj-kcard-title">{it.title || "(untitled)"}</div>
864 {it.note && (
865 <div class="proj-kcard-note">{it.note}</div>
866 )}
867 {user && (
868 <div class="proj-kcard-actions">
869 {columns
870 .filter((oc) => oc.id !== col.id)
871 .map((oc) => (
957 {(itemsByCol[col.id] || []).map((it) => {
958 // Resolve linked issue/PR metadata for richer card display.
959 const linkedIssue = it.itemType === "issue" && it.itemId
960 ? linkedIssueMap[it.itemId]
961 : null;
962 const linkedPr = it.itemType === "pr" && it.itemId
963 ? linkedPrMap[it.itemId]
964 : null;
965 const cardTitle = linkedIssue?.title || linkedPr?.title || it.title || "(untitled)";
966 const cardNumber = linkedIssue?.number ?? linkedPr?.number ?? null;
967 const cardState = linkedIssue?.state ?? (linkedPr?.isDraft ? "draft" : linkedPr?.state) ?? null;
968 const cardHref = linkedIssue
969 ? `/${ownerName}/${repoName}/issues/${linkedIssue.number}`
970 : linkedPr
971 ? `/${ownerName}/${repoName}/pulls/${linkedPr.number}`
972 : null;
973
974 return (
975 <div class="proj-kcard">
976 <div class="proj-kcard-title">
977 {cardHref ? (
978 <a href={cardHref} class="proj-kcard-link">{cardTitle}</a>
979 ) : (
980 cardTitle
981 )}
982 </div>
983 {(cardNumber !== null || cardState) && (
984 <div class="proj-kcard-meta">
985 {cardNumber !== null && (
986 <span class="proj-kcard-num">#{cardNumber}</span>
987 )}
988 {cardState && (
989 <span class={`proj-kcard-state is-${cardState}`}>
990 {cardState}
991 </span>
992 )}
993 </div>
994 )}
995 {it.note && (
996 <div class="proj-kcard-note">{it.note}</div>
997 )}
998 {user && (
999 <div class="proj-kcard-actions">
1000 {columns.length > 1 && (
8721001 <form
8731002 method="post"
8741003 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
1004 style="display:inline-flex;align-items:center;gap:4px"
8751005 >
876 <input
877 type="hidden"
1006 <select
8781007 name="column_id"
879 value={oc.id}
880 />
1008 class="proj-kcard-select"
1009 aria-label="Move to column"
1010 >
1011 {columns
1012 .filter((oc) => oc.id !== col.id)
1013 .map((oc) => (
1014 <option value={oc.id}>{oc.name}</option>
1015 ))}
1016 </select>
8811017 <button
8821018 type="submit"
8831019 class="proj-btn proj-btn-ghost proj-btn-mini"
8841020 >
885 → {oc.name}
1021 Move
8861022 </button>
8871023 </form>
888 ))}
889 <form
890 method="post"
891 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
892 >
893 <button
894 type="submit"
895 class="proj-btn proj-btn-ghost proj-btn-mini"
896 aria-label="Delete item"
1024 )}
1025 <form
1026 method="post"
1027 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
8971028 >
898 ×
899 </button>
900 </form>
901 </div>
902 )}
903 </div>
904 ))}
1029 <button
1030 type="submit"
1031 class="proj-btn proj-btn-ghost proj-btn-mini"
1032 aria-label="Delete item"
1033 >
1034 ×
1035 </button>
1036 </form>
1037 </div>
1038 )}
1039 </div>
1040 );
1041 })}
9051042 {user && (
906 <form
907 method="post"
908 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
909 class="proj-kadd"
910 >
911 <input type="hidden" name="column_id" value={col.id} />
912 <input
913 type="text"
914 name="title"
915 placeholder="New card title"
916 required
917 aria-label="New card title"
918 />
919 <button
920 type="submit"
921 class="proj-btn proj-btn-ghost proj-btn-mini"
922 >
1043 <details class="proj-kadd-details">
1044 <summary class="proj-btn proj-btn-ghost proj-btn-mini" style="cursor:pointer;list-style:none;margin-top:8px">
9231045 <IconPlus />
9241046 Add card
925 </button>
926 </form>
1047 </summary>
1048 <div class="proj-kadd-panel">
1049 <form
1050 method="post"
1051 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1052 class="proj-kadd"
1053 >
1054 <input type="hidden" name="column_id" value={col.id} />
1055 <input type="hidden" name="item_type" value="note" />
1056 <input
1057 type="text"
1058 name="title"
1059 placeholder="Note title"
1060 required
1061 aria-label="Note title"
1062 />
1063 <button
1064 type="submit"
1065 class="proj-btn proj-btn-ghost proj-btn-mini"
1066 >
1067 Add note
1068 </button>
1069 </form>
1070 <hr class="proj-kadd-sep" />
1071 <form
1072 method="post"
1073 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1074 class="proj-kadd"
1075 >
1076 <input type="hidden" name="column_id" value={col.id} />
1077 <input type="hidden" name="item_type" value="issue" />
1078 <input
1079 type="number"
1080 name="issue_number"
1081 placeholder="Issue # (e.g. 42)"
1082 min="1"
1083 aria-label="Issue number"
1084 />
1085 <button
1086 type="submit"
1087 class="proj-btn proj-btn-ghost proj-btn-mini"
1088 >
1089 Link issue
1090 </button>
1091 </form>
1092 <form
1093 method="post"
1094 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
1095 class="proj-kadd"
1096 >
1097 <input type="hidden" name="column_id" value={col.id} />
1098 <input type="hidden" name="item_type" value="pr" />
1099 <input
1100 type="number"
1101 name="pr_number"
1102 placeholder="PR # (e.g. 7)"
1103 min="1"
1104 aria-label="Pull request number"
1105 />
1106 <button
1107 type="submit"
1108 class="proj-btn proj-btn-ghost proj-btn-mini"
1109 >
1110 Link PR
1111 </button>
1112 </form>
1113 </div>
1114 </details>
9271115 )}
9281116 </div>
9291117 ))}
10011189 }
10021190);
10031191
1004// Add item
1192// Add item (note, linked issue, or linked PR)
10051193projectRoutes.post(
10061194 "/:owner/:repo/projects/:number/items",
10071195 requireAuth,
10131201
10141202 const form = await c.req.formData();
10151203 const columnId = (form.get("column_id") as string || "").trim();
1204 const itemType = (form.get("item_type") as string || "note").trim();
10161205 const title = (form.get("title") as string || "").trim();
10171206 const note = (form.get("note") as string || "").trim();
1018 if (!columnId || !title) {
1207 const issueNumberRaw = parseInt(form.get("issue_number") as string || "", 10);
1208 const prNumberRaw = parseInt(form.get("pr_number") as string || "", 10);
1209
1210 if (!columnId) {
1211 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1212 }
1213
1214 // For notes, title is required. For issue/pr links, we look up by number.
1215 if (itemType === "note" && !title) {
1216 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1217 }
1218 if (itemType === "issue" && isNaN(issueNumberRaw)) {
1219 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
1220 }
1221 if (itemType === "pr" && isNaN(prNumberRaw)) {
10191222 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
10201223 }
10211224
10351238 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
10361239 .from(projectItems)
10371240 .where(eq(projectItems.columnId, columnId));
1038 await db.insert(projectItems).values({
1039 projectId: row.id,
1040 columnId,
1041 title,
1042 note,
1043 position: Number(maxPos?.p || -1) + 1,
1044 });
1241 const position = Number(maxPos?.p || -1) + 1;
1242
1243 if (itemType === "issue" && !isNaN(issueNumberRaw)) {
1244 // Look up the issue by number in this repo
1245 const [issueRow] = await db
1246 .select({ id: issues.id, title: issues.title })
1247 .from(issues)
1248 .where(
1249 and(
1250 eq(issues.repositoryId, resolved.repo.id),
1251 eq(issues.number, issueNumberRaw)
1252 )
1253 )
1254 .limit(1);
1255 if (issueRow) {
1256 await db.insert(projectItems).values({
1257 projectId: row.id,
1258 columnId,
1259 itemType: "issue",
1260 itemId: issueRow.id,
1261 title: issueRow.title,
1262 position,
1263 });
1264 }
1265 } else if (itemType === "pr" && !isNaN(prNumberRaw)) {
1266 // Look up the PR by number in this repo
1267 const [prRow] = await db
1268 .select({ id: pullRequests.id, title: pullRequests.title })
1269 .from(pullRequests)
1270 .where(
1271 and(
1272 eq(pullRequests.repositoryId, resolved.repo.id),
1273 eq(pullRequests.number, prNumberRaw)
1274 )
1275 )
1276 .limit(1);
1277 if (prRow) {
1278 await db.insert(projectItems).values({
1279 projectId: row.id,
1280 columnId,
1281 itemType: "pr",
1282 itemId: prRow.id,
1283 title: prRow.title,
1284 position,
1285 });
1286 }
1287 } else {
1288 // Freeform note
1289 await db.insert(projectItems).values({
1290 projectId: row.id,
1291 columnId,
1292 itemType: "note",
1293 title,
1294 note,
1295 position,
1296 });
1297 }
10451298 }
10461299 } catch {
10471300 // swallow
Modifiedsrc/routes/pulls.tsx+130−1View fileUnifiedSplit
5656import {
5757 TRIO_COMMENT_MARKER,
5858 TRIO_SUMMARY_MARKER,
59 isTrioReviewEnabled,
5960 type TrioPersona,
6061} from "../lib/ai-review-trio";
6162import {
121122
122123import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
123124import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
125import { BOT_USERNAME } from "../lib/bot-user";
124126
125127const pulls = new Hono<AuthEnv>();
126128
696698 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 130%);
697699 border-radius: 9999px;
698700 }
701 .prs-bot-badge {
702 display: inline-flex; align-items: center; gap: 3px;
703 padding: 1px 7px;
704 font-size: 10px;
705 font-weight: 600;
706 color: var(--fg-muted);
707 background: var(--bg-elevated);
708 border: 1px solid var(--border);
709 border-radius: 9999px;
710 }
699711
700712 /* Files-changed link card on conversation tab. (Diff itself is in DiffView.) */
701713 .prs-files-card {
14231435 .prs-ci-pill.is-failure, .prs-ci-pill.is-error { color: #f87171; background: rgba(248,113,113,0.10); }
14241436 .prs-ci-link { font-size: 12px; color: var(--accent); text-decoration: none; }
14251437 .prs-ci-link:hover { text-decoration: underline; }
1438
1439 /* ─── AI Trio verdict pills (header summary) ─── */
1440 .trio-pill {
1441 display: inline-flex; align-items: center; gap: 4px;
1442 padding: 2px 8px;
1443 font-size: 11px;
1444 font-weight: 700;
1445 border-radius: 9999px;
1446 border: 1px solid transparent;
1447 text-decoration: none;
1448 line-height: 1.6;
1449 letter-spacing: 0.01em;
1450 cursor: pointer;
1451 transition: opacity 120ms ease;
1452 }
1453 .trio-pill:hover { opacity: 0.8; }
1454 .trio-pill.is-pass {
1455 color: #34d399;
1456 background: rgba(52,211,153,0.10);
1457 border-color: rgba(52,211,153,0.35);
1458 }
1459 .trio-pill.is-fail {
1460 color: #f87171;
1461 background: rgba(248,113,113,0.10);
1462 border-color: rgba(248,113,113,0.35);
1463 }
1464 .trio-pill.is-pending {
1465 color: var(--text-muted);
1466 background: rgba(255,255,255,0.04);
1467 border-color: var(--border-strong);
1468 }
1469 .trio-pills-wrap {
1470 display: inline-flex; align-items: center; gap: 4px;
1471 }
14261472`;
14271473
14281474/**
18211867 const disagreements = summaryBody ? parseDisagreements(summaryBody) : [];
18221868
18231869 return (
1824 <div class="trio-wrap">
1870 <div class="trio-wrap" id="trio-review-section">
18251871 <div class="trio-header">
18261872 <span class="trio-header-dot" aria-hidden="true"></span>
18271873 <strong>AI Trio Review</strong>
19091955 .trim();
19101956}
19111957
1958/**
1959 * Three small verdict pills rendered inline in the PR header. Each pill
1960 * links to the `#trio-review-section` anchor so clicking scrolls to the
1961 * full card grid. Only shown when `AI_TRIO_REVIEW_ENABLED=1` and at
1962 * least one persona comment exists.
1963 */
1964function TrioVerdictPills({
1965 comments,
1966}: {
1967 comments: TrioCommentLike[];
1968}) {
1969 if (!isTrioReviewEnabled()) return null;
1970
1971 // Find latest persona verdicts (same logic as TrioReviewGrid).
1972 const latest: Partial<Record<TrioPersona, string>> = {};
1973 for (let i = comments.length - 1; i >= 0; i--) {
1974 const body = comments[i].body || "";
1975 if (!isTrioComment(body)) continue;
1976 if (body.includes(TRIO_SUMMARY_MARKER)) continue;
1977 const persona = trioPersonaOfComment(body);
1978 if (persona && !latest[persona]) latest[persona] = body;
1979 }
1980
1981 const anyPersona = TRIO_PERSONAS.some((p) => !!latest[p]);
1982 if (!anyPersona) return null;
1983
1984 const PERSONA_LABEL: Record<TrioPersona, string> = {
1985 security: "Security",
1986 correctness: "Correctness",
1987 style: "Style",
1988 };
1989 const PERSONA_ICON: Record<TrioPersona, string> = {
1990 security: "🛡",
1991 correctness: "✓",
1992 style: "✎",
1993 };
1994
1995 return (
1996 <span class="trio-pills-wrap" aria-label="AI Trio Review verdicts">
1997 {TRIO_PERSONAS.map((persona) => {
1998 const body = latest[persona];
1999 const verdict = body ? trioVerdictOfBody(body) : null;
2000 const stateClass =
2001 verdict === "pass"
2002 ? "is-pass"
2003 : verdict === "fail"
2004 ? "is-fail"
2005 : "is-pending";
2006 const glyph =
2007 verdict === "pass" ? "✓" : verdict === "fail" ? "✗" : "⟳";
2008 return (
2009 <a
2010 href="#trio-review-section"
2011 class={`trio-pill ${stateClass}`}
2012 title={`AI ${PERSONA_LABEL[persona]} Review — ${verdict === "pass" ? "Pass" : verdict === "fail" ? "Fail" : "Pending"}`}
2013 >
2014 <span aria-hidden="true">{PERSONA_ICON[persona]}</span>
2015 {PERSONA_LABEL[persona]} {glyph}
2016 </a>
2017 );
2018 })}
2019 </span>
2020 );
2021}
2022
19122023// List PRs
19132024pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
19142025 const { owner: ownerName, repo: repoName } = c.req.param();
31223233 );
31233234 });
31243235
3236 // Migration 0077 — PR preview build. Fire-and-forget; skips when
3237 // PREVIEW_DOMAIN is unset or the repo has no preview_build_command.
3238 // Resolve head SHA asynchronously so we don't block the redirect.
3239 resolveRef(ownerName, repoName, headBranch)
3240 .then((headSha) => {
3241 if (!headSha) return;
3242 return import("../lib/preview-builder").then((m) =>
3243 m.buildPreview(pr.id, resolved.repo.id, headSha)
3244 );
3245 })
3246 .catch(() => {});
3247
31253248 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
31263249 }
31273250);
35723695 {prSizeInfo.label}
35733696 </span>
35743697 )}
3698 <TrioVerdictPills
3699 comments={comments.map(({ comment }) => comment)}
3700 />
35753701 <span>
35763702 <strong>{author?.username}</strong> wants to merge
35773703 </span>
37843910 >
37853911 <div class="prs-comment-head">
37863912 <strong>{commentAuthor.username}</strong>
3913 {commentAuthor.username === BOT_USERNAME && (
3914 <span class="prs-bot-badge">&#x1F916; bot</span>
3915 )}
37873916 {comment.isAiReview && (
37883917 <span class="prs-ai-badge">AI Review</span>
37893918 )}
Modifiedsrc/routes/repo-settings.tsx+131−0View fileUnifiedSplit
693693 </label>
694694 </div>
695695 </div>
696 <div class="repo-settings-field">
697 <label class="repo-settings-field-label">Data region</label>
698 <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
699 <span
700 style={`display:inline-flex; align-items:center; gap:6px; padding:4px 12px; border-radius:9999px; font-size:12px; font-weight:600; font-family:var(--font-mono); ${
701 repo.dataRegion === "eu"
702 ? "background:rgba(54,197,214,0.10); color:#67e8f9; border:1px solid rgba(54,197,214,0.28);"
703 : "background:rgba(140,109,255,0.10); color:#c4b5fd; border:1px solid rgba(140,109,255,0.28);"
704 }`}
705 >
706 {repo.dataRegion === "eu" ? "EU · Frankfurt" : "US · Default"}
707 </span>
708 <span class="repo-settings-field-hint" style="margin:0;">
709 Data region is set at creation and cannot be changed. EU data
710 residency requires a{" "}
711 <a href="/pricing" style="color:var(--accent);text-decoration:none;">Pro plan or higher</a>.
712 </span>
713 </div>
714 </div>
696715 </div>
697716 <div class="repo-settings-section-foot">
698717 <button type="submit" class="repo-settings-cta">
10061025 </form>
10071026 </section>
10081027
1028 {/* ─── PR preview builds (migration 0077) ─── */}
1029 <section class="repo-settings-section">
1030 <div class="repo-settings-section-head">
1031 <div class="repo-settings-section-eyebrow">Preview builds</div>
1032 <h2 class="repo-settings-section-title">PR preview environments</h2>
1033 <p class="repo-settings-section-desc">
1034 When a build command is set, every PR push automatically clones
1035 the branch, runs the command, and serves the built output from a
1036 unique preview URL — like Vercel, but native to Gluecron. Leave
1037 blank to use URL-only previews (no build runs).
1038 </p>
1039 </div>
1040 <form
1041 method="post"
1042 action={`/${ownerName}/${repoName}/settings/previews`}
1043 >
1044 <div class="repo-settings-section-body">
1045 <div class="repo-settings-field">
1046 <label class="repo-settings-field-label" for="preview_build_command">
1047 Build command
1048 </label>
1049 <input
1050 id="preview_build_command"
1051 class="repo-settings-input"
1052 type="text"
1053 name="preview_build_command"
1054 placeholder="npm run build"
1055 value={
1056 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1057 }
1058 />
1059 <p class="repo-settings-field-hint">
1060 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1061 <code>hugo</code>. Runs inside the cloned branch directory.
1062 </p>
1063 </div>
1064 <div class="repo-settings-field">
1065 <label class="repo-settings-field-label" for="preview_output_dir">
1066 Output directory
1067 </label>
1068 <input
1069 id="preview_output_dir"
1070 class="repo-settings-input"
1071 type="text"
1072 name="preview_output_dir"
1073 placeholder="dist"
1074 value={
1075 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1076 }
1077 />
1078 <p class="repo-settings-field-hint">
1079 The directory your build writes to. Common values:{" "}
1080 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1081 <code>build</code>. Served from{" "}
1082 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1083 </p>
1084 </div>
1085 </div>
1086 <div class="repo-settings-section-foot">
1087 <button type="submit" class="repo-settings-cta">
1088 Save preview settings <span class="arrow"></span>
1089 </button>
1090 </div>
1091 </form>
1092 </section>
1093
10091094 {/* ─── Danger zone ─── */}
10101095 <section class="repo-settings-danger">
10111096 <div class="repo-settings-danger-head">
14541539 }
14551540);
14561541
1542// Migration 0077 — PR preview build configuration. Owner-only.
1543repoSettings.post(
1544 "/:owner/:repo/settings/previews",
1545 requireAuth,
1546 requireRepoAccess("admin"),
1547 async (c) => {
1548 const { owner: ownerName, repo: repoName } = c.req.param();
1549 const user = c.get("user")!;
1550 const body = await c.req.parseBody();
1551
1552 const [owner] = await db
1553 .select()
1554 .from(users)
1555 .where(eq(users.username, ownerName))
1556 .limit(1);
1557 if (!owner || owner.id !== user.id) {
1558 return c.redirect(`/${ownerName}/${repoName}`);
1559 }
1560
1561 const [repo] = await db
1562 .select()
1563 .from(repositories)
1564 .where(
1565 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1566 )
1567 .limit(1);
1568 if (!repo) return c.notFound();
1569
1570 const buildCommand = String(body.preview_build_command || "").trim() || null;
1571 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1572
1573 await db
1574 .update(repositories)
1575 .set({
1576 previewBuildCommand: buildCommand,
1577 previewOutputDir: outputDir,
1578 updatedAt: new Date(),
1579 })
1580 .where(eq(repositories.id, repo.id));
1581
1582 return c.redirect(
1583 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1584 );
1585 }
1586);
1587
14571588export default repoSettings;
Addedsrc/routes/security.tsx+617−0View fileUnifiedSplit
1/**
2 * Security CVE issues page — dependency vulnerability scanner findings.
3 *
4 * GET /:owner/:repo/security/vulnerabilities
5 *
6 * Shows all open issues that were auto-generated by the dependency CVE scanner
7 * (identified by `[CVE]` prefix in title or the weekly digest marker in body).
8 * Groups by severity (critical → high → medium → low) with count badges.
9 * Shows an "All clear" panel when no security issues are open.
10 *
11 * This is a pure surfacing layer over the existing `issues` table — no new
12 * schema changes required.
13 */
14
15import { Hono } from "hono";
16import { and, desc, eq, ilike, or } from "drizzle-orm";
17import { db } from "../db";
18import { issues, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { __internal as scanInternal } from "../lib/dependency-scanner";
24
25const securityRoutes = new Hono<AuthEnv>();
26securityRoutes.use("*", softAuth);
27
28/* ─────────────────────────────────────────────────────────────────────────
29 * Scoped CSS — every class prefixed `.svp-` (Security Vulnerability Page).
30 * Matches the gradient-hairline + radial-orb design language used by
31 * code-scanning.tsx and admin-integrations.tsx.
32 * ───────────────────────────────────────────────────────────────────── */
33const styles = `
34 .svp-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
35
36 .svp-hero {
37 position: relative;
38 margin-bottom: var(--space-5);
39 padding: var(--space-5) var(--space-6);
40 background: var(--bg-elevated);
41 border: 1px solid var(--border);
42 border-radius: 16px;
43 overflow: hidden;
44 }
45 .svp-hero::before {
46 content: '';
47 position: absolute;
48 top: 0; left: 0; right: 0;
49 height: 2px;
50 background: linear-gradient(90deg, transparent 0%, #d73a4a 30%, #f97316 70%, transparent 100%);
51 opacity: 0.75;
52 pointer-events: none;
53 }
54 .svp-hero-orb {
55 position: absolute;
56 inset: -30% -15% auto auto;
57 width: 460px; height: 460px;
58 background: radial-gradient(circle, rgba(215,58,74,0.18), rgba(249,115,22,0.08) 45%, transparent 70%);
59 filter: blur(80px);
60 opacity: 0.7;
61 pointer-events: none;
62 z-index: 0;
63 }
64 .svp-hero-inner { position: relative; z-index: 1; max-width: 760px; }
65 .svp-eyebrow {
66 display: inline-flex;
67 align-items: center;
68 gap: 8px;
69 text-transform: uppercase;
70 font-family: var(--font-mono);
71 font-size: 11px;
72 letter-spacing: 0.18em;
73 color: var(--text-muted);
74 font-weight: 600;
75 margin-bottom: 14px;
76 }
77 .svp-eyebrow-dot {
78 width: 8px; height: 8px;
79 border-radius: 9999px;
80 background: linear-gradient(135deg, #d73a4a, #f97316);
81 box-shadow: 0 0 0 3px rgba(215,58,74,0.18);
82 }
83 .svp-title {
84 font-family: var(--font-display);
85 font-size: clamp(24px, 3.5vw, 36px);
86 font-weight: 800;
87 letter-spacing: -0.025em;
88 line-height: 1.05;
89 margin: 0 0 var(--space-2);
90 color: var(--text-strong);
91 }
92 .svp-title-grad {
93 background-image: linear-gradient(135deg, #f87171 0%, #d73a4a 50%, #f97316 100%);
94 -webkit-background-clip: text;
95 background-clip: text;
96 -webkit-text-fill-color: transparent;
97 color: transparent;
98 }
99 .svp-sub {
100 font-size: 15px;
101 color: var(--text-muted);
102 margin: 0;
103 line-height: 1.55;
104 max-width: 640px;
105 }
106
107 /* Counts grid */
108 .svp-counts {
109 display: grid;
110 grid-template-columns: repeat(4, 1fr);
111 gap: var(--space-3);
112 margin-bottom: var(--space-5);
113 }
114 @media (max-width: 680px) {
115 .svp-counts { grid-template-columns: repeat(2, 1fr); }
116 }
117 .svp-count-card {
118 background: var(--bg-elevated);
119 border: 1px solid var(--border);
120 border-radius: 14px;
121 padding: var(--space-4);
122 transition: border-color 120ms ease, transform 120ms ease;
123 }
124 .svp-count-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
125 .svp-count-card.is-critical { border-color: rgba(239,68,68,0.4); }
126 .svp-count-card.is-high { border-color: rgba(249,115,22,0.4); }
127 .svp-count-card.is-medium { border-color: rgba(234,179,8,0.3); }
128 .svp-count-card.is-low { border-color: rgba(99,102,241,0.25); }
129 .svp-count-label {
130 font-size: 10.5px;
131 letter-spacing: 0.14em;
132 text-transform: uppercase;
133 color: var(--text-muted);
134 font-weight: 700;
135 margin-bottom: 6px;
136 }
137 .svp-count-value {
138 font-family: var(--font-display);
139 font-size: 36px;
140 font-weight: 800;
141 letter-spacing: -0.025em;
142 line-height: 1;
143 color: var(--text-strong);
144 font-variant-numeric: tabular-nums;
145 }
146 .svp-count-card.is-critical .svp-count-value { color: #fca5a5; }
147 .svp-count-card.is-high .svp-count-value { color: #fdba74; }
148 .svp-count-card.is-medium .svp-count-value { color: #fde047; }
149 .svp-count-card.is-low .svp-count-value { color: #a5b4fc; }
150 .svp-count-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
151
152 /* All-clear banner */
153 .svp-clear {
154 display: flex;
155 align-items: center;
156 gap: 14px;
157 margin-bottom: var(--space-4);
158 padding: 16px 20px;
159 border-radius: 14px;
160 background: linear-gradient(135deg, rgba(52,211,153,0.10), rgba(54,197,214,0.06));
161 border: 1px solid rgba(52,211,153,0.32);
162 color: #bbf7d0;
163 }
164 .svp-clear-icon {
165 flex: 0 0 auto;
166 width: 38px; height: 38px;
167 border-radius: 9999px;
168 display: inline-flex;
169 align-items: center;
170 justify-content: center;
171 background: linear-gradient(135deg, #34d399 0%, #36c5d6 100%);
172 color: #04231a;
173 font-size: 18px;
174 box-shadow: 0 0 0 4px rgba(52,211,153,0.16);
175 }
176 .svp-clear-text strong { display: block; color: #d1fae5; font-weight: 700; font-size: 15px; margin-bottom: 2px; }
177 .svp-clear-text span { color: rgba(187,247,208,0.85); font-size: 13px; }
178
179 /* Section heading */
180 .svp-section-heading {
181 display: flex;
182 align-items: center;
183 gap: 10px;
184 margin-bottom: var(--space-3);
185 }
186 .svp-section-title {
187 font-family: var(--font-display);
188 font-size: 17px;
189 font-weight: 700;
190 color: var(--text-strong);
191 letter-spacing: -0.012em;
192 margin: 0;
193 }
194 .svp-section-badge {
195 display: inline-flex;
196 align-items: center;
197 padding: 2px 8px;
198 border-radius: 9999px;
199 font-family: var(--font-mono);
200 font-size: 11px;
201 font-weight: 700;
202 letter-spacing: 0.04em;
203 background: var(--bg-subtle, rgba(255,255,255,0.06));
204 border: 1px solid var(--border);
205 color: var(--text-muted);
206 }
207
208 /* Issue list */
209 .svp-issue-list {
210 display: flex;
211 flex-direction: column;
212 gap: var(--space-2);
213 margin-bottom: var(--space-5);
214 }
215 .svp-issue-card {
216 position: relative;
217 background: var(--bg-elevated);
218 border: 1px solid var(--border);
219 border-radius: 12px;
220 padding: var(--space-3) var(--space-4);
221 transition: border-color 120ms ease, transform 120ms ease;
222 }
223 .svp-issue-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
224 .svp-issue-card.sev-critical { border-left: 3px solid #ef4444; }
225 .svp-issue-card.sev-high { border-left: 3px solid #f97316; }
226 .svp-issue-card.sev-medium { border-left: 3px solid #eab308; }
227 .svp-issue-card.sev-low { border-left: 3px solid #6366f1; }
228 .svp-issue-row {
229 display: flex;
230 justify-content: space-between;
231 align-items: flex-start;
232 gap: 14px;
233 flex-wrap: wrap;
234 }
235 .svp-issue-main { min-width: 0; flex: 1; }
236 .svp-issue-title {
237 font-family: var(--font-display);
238 font-size: 14px;
239 font-weight: 700;
240 color: var(--text-strong);
241 letter-spacing: -0.005em;
242 line-height: 1.35;
243 margin: 0 0 4px;
244 word-break: break-word;
245 }
246 .svp-issue-title a { color: inherit; text-decoration: none; }
247 .svp-issue-title a:hover { text-decoration: underline; }
248 .svp-issue-meta {
249 display: flex;
250 gap: 10px;
251 align-items: center;
252 flex-wrap: wrap;
253 margin-top: 4px;
254 }
255 .svp-issue-num { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
256 .svp-issue-date { font-size: 12px; color: var(--text-muted); }
257 .svp-sev-pill {
258 display: inline-flex;
259 align-items: center;
260 padding: 2px 9px;
261 border-radius: 9999px;
262 font-size: 11.5px;
263 font-weight: 700;
264 letter-spacing: 0.04em;
265 white-space: nowrap;
266 }
267 .svp-sev-pill.sev-critical { background: rgba(239,68,68,0.15); color: #fca5a5; border: 1px solid rgba(239,68,68,0.35); }
268 .svp-sev-pill.sev-high { background: rgba(249,115,22,0.15); color: #fdba74; border: 1px solid rgba(249,115,22,0.35); }
269 .svp-sev-pill.sev-medium { background: rgba(234,179,8,0.12); color: #fde047; border: 1px solid rgba(234,179,8,0.30); }
270 .svp-sev-pill.sev-low { background: rgba(99,102,241,0.12); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.28); }
271 .svp-sev-pill.sev-digest { background: rgba(107,114,128,0.12); color: var(--text-muted); border: 1px solid var(--border); }
272
273 /* Last scan info */
274 .svp-scan-info {
275 margin-top: var(--space-5);
276 padding: 14px 18px;
277 background: var(--bg-subtle, rgba(255,255,255,0.03));
278 border: 1px solid var(--border);
279 border-radius: 12px;
280 font-size: 12.5px;
281 color: var(--text-muted);
282 display: flex;
283 align-items: center;
284 gap: 8px;
285 }
286
287 /* How-it-works aside */
288 .svp-how {
289 margin-top: var(--space-4);
290 padding: 14px 18px;
291 background: rgba(140,109,255,0.06);
292 border: 1px solid rgba(140,109,255,0.18);
293 border-radius: 12px;
294 font-size: 13px;
295 color: var(--text-muted);
296 line-height: 1.55;
297 }
298 .svp-how strong { color: var(--text-normal); }
299`;
300
301// ---------------------------------------------------------------------------
302// Route
303// ---------------------------------------------------------------------------
304
305interface SecurityIssueRow {
306 id: string;
307 number: number;
308 title: string;
309 body: string | null;
310 createdAt: Date;
311 updatedAt: Date;
312}
313
314/** Infer severity from issue title (set by the scanner). */
315function inferSeverityFromTitle(
316 title: string
317): "critical" | "high" | "medium" | "low" | "digest" {
318 const upper = title.toUpperCase();
319 if (upper.includes("CRITICAL")) return "critical";
320 if (upper.includes("HIGH")) return "high";
321 if (upper.includes("MEDIUM")) return "medium";
322 if (upper.includes("LOW")) return "low";
323 // Weekly digest issues
324 if (upper.includes("DIGEST")) return "digest";
325 return "medium";
326}
327
328function severityLabel(sev: ReturnType<typeof inferSeverityFromTitle>): string {
329 switch (sev) {
330 case "critical": return "CRITICAL";
331 case "high": return "HIGH";
332 case "medium": return "MEDIUM";
333 case "low": return "LOW";
334 case "digest": return "DIGEST";
335 }
336}
337
338function fmtDate(d: Date): string {
339 try {
340 return d.toLocaleDateString("en-US", {
341 month: "short",
342 day: "numeric",
343 year: "numeric",
344 });
345 } catch {
346 return "";
347 }
348}
349
350securityRoutes.get("/:owner/:repo/security/vulnerabilities", async (c) => {
351 const { owner: ownerName, repo: repoName } = c.req.param();
352 const user = c.get("user");
353
354 // Load repo — swallow DB errors so the route returns 404 gracefully
355 // even when the DB is unavailable in tests or CI.
356 let ownerRow: { id: string; username: string } | null = null;
357 let repoRow: {
358 id: string;
359 ownerId: string;
360 isPrivate: boolean;
361 isArchived: boolean;
362 isTemplate: boolean;
363 } | null = null;
364 try {
365 const [o] = await db
366 .select({ id: users.id, username: users.username })
367 .from(users)
368 .where(eq(users.username, ownerName))
369 .limit(1);
370 ownerRow = o || null;
371 } catch {
372 return c.notFound();
373 }
374 if (!ownerRow) return c.notFound();
375
376 try {
377 const [r] = await db
378 .select({
379 id: repositories.id,
380 ownerId: repositories.ownerId,
381 isPrivate: repositories.isPrivate,
382 isArchived: repositories.isArchived,
383 isTemplate: repositories.isTemplate,
384 })
385 .from(repositories)
386 .where(
387 and(
388 eq(repositories.ownerId, ownerRow.id),
389 eq(repositories.name, repoName)
390 )
391 )
392 .limit(1);
393 repoRow = r || null;
394 } catch {
395 return c.notFound();
396 }
397 if (!repoRow) return c.notFound();
398
399 // Private repo — must be owner or collaborator (soft check: must be logged in as owner)
400 if (repoRow.isPrivate && (!user || user.id !== ownerRow.id)) {
401 return c.notFound();
402 }
403
404 // Fetch open security issues: those with [CVE] in title or "Dependency scan digest" in title
405 let securityIssues: SecurityIssueRow[] = [];
406 try {
407 securityIssues = await db
408 .select({
409 id: issues.id,
410 number: issues.number,
411 title: issues.title,
412 body: issues.body,
413 createdAt: issues.createdAt,
414 updatedAt: issues.updatedAt,
415 })
416 .from(issues)
417 .where(
418 and(
419 eq(issues.repositoryId, repoRow.id),
420 eq(issues.state, "open"),
421 or(
422 ilike(issues.title, "%[CVE]%"),
423 ilike(issues.title, "%Dependency scan digest%")
424 )
425 )
426 )
427 .orderBy(desc(issues.createdAt))
428 .limit(200);
429 } catch {
430 securityIssues = [];
431 }
432
433 // Compute counts per severity
434 const counts = { critical: 0, high: 0, medium: 0, low: 0, digest: 0 };
435 for (const issue of securityIssues) {
436 const sev = inferSeverityFromTitle(issue.title);
437 counts[sev]++;
438 }
439
440 // Find the most recent scan timestamp (most recent security issue created_at)
441 const lastScanAt =
442 securityIssues.length > 0
443 ? securityIssues.reduce((latest, i) =>
444 i.createdAt > latest ? i.createdAt : latest,
445 securityIssues[0].createdAt
446 )
447 : null;
448
449 // Group by severity for display
450 const groups: Array<{
451 sev: ReturnType<typeof inferSeverityFromTitle>;
452 label: string;
453 issues: SecurityIssueRow[];
454 }> = [
455 { sev: "critical", label: "Critical", issues: [] },
456 { sev: "high", label: "High", issues: [] },
457 { sev: "medium", label: "Medium", issues: [] },
458 { sev: "low", label: "Low", issues: [] },
459 { sev: "digest", label: "Digest", issues: [] },
460 ];
461
462 for (const issue of securityIssues) {
463 const sev = inferSeverityFromTitle(issue.title);
464 const group = groups.find((g) => g.sev === sev);
465 if (group) group.issues.push(issue);
466 }
467
468 const hasFindings = securityIssues.length > 0;
469
470 return c.html(
471 <Layout title={`Security vulnerabilities — ${ownerName}/${repoName}`} user={user}>
472 <style dangerouslySetInnerHTML={{ __html: styles }} />
473 <div class="svp-wrap">
474 {/* Repo header + nav */}
475 <RepoHeader
476 owner={ownerName}
477 repo={repoName}
478 currentUser={user?.username}
479 archived={repoRow.isArchived}
480 isTemplate={repoRow.isTemplate}
481 />
482 <RepoNav owner={ownerName} repo={repoName} active="security" />
483
484 {/* Hero */}
485 <div class="svp-hero">
486 <div class="svp-hero-orb" />
487 <div class="svp-hero-inner">
488 <div class="svp-eyebrow">
489 <span class="svp-eyebrow-dot" />
490 Dependency Scanner
491 </div>
492 <h1 class="svp-title">
493 <span class="svp-title-grad">CVE</span> Vulnerabilities
494 </h1>
495 <p class="svp-sub">
496 Auto-detected dependency vulnerabilities from push-time scans via the{" "}
497 <a href="https://osv.dev" target="_blank" rel="noopener noreferrer" style="color:inherit">
498 OSV database
499 </a>
500 . Critical and high findings open issues immediately. Medium/low findings are batched into a weekly digest.
501 </p>
502 </div>
503 </div>
504
505 {/* Severity count grid */}
506 <div class="svp-counts">
507 <div class={`svp-count-card${counts.critical > 0 ? " is-critical" : ""}`}>
508 <div class="svp-count-label">Critical</div>
509 <div class="svp-count-value">{counts.critical}</div>
510 <div class="svp-count-hint">open issues</div>
511 </div>
512 <div class={`svp-count-card${counts.high > 0 ? " is-high" : ""}`}>
513 <div class="svp-count-label">High</div>
514 <div class="svp-count-value">{counts.high}</div>
515 <div class="svp-count-hint">open issues</div>
516 </div>
517 <div class={`svp-count-card${counts.medium > 0 ? " is-medium" : ""}`}>
518 <div class="svp-count-label">Medium</div>
519 <div class="svp-count-value">{counts.medium}</div>
520 <div class="svp-count-hint">open issues</div>
521 </div>
522 <div class={`svp-count-card${counts.low > 0 ? " is-low" : ""}`}>
523 <div class="svp-count-label">Low</div>
524 <div class="svp-count-value">{counts.low}</div>
525 <div class="svp-count-hint">open issues</div>
526 </div>
527 </div>
528
529 {/* All clear or findings */}
530 {!hasFindings ? (
531 <div class="svp-clear">
532 <span class="svp-clear-icon"></span>
533 <div class="svp-clear-text">
534 <strong>All clear</strong>
535 <span>
536 No open dependency vulnerability issues detected.{" "}
537 {lastScanAt
538 ? `Last scanned ${fmtDate(lastScanAt)}.`
539 : "Enable DEPENDENCY_SCAN_ENABLED=1 to start scanning pushes automatically."}
540 </span>
541 </div>
542 </div>
543 ) : (
544 <>
545 {groups
546 .filter((g) => g.issues.length > 0)
547 .map((group) => (
548 <div>
549 <div class="svp-section-heading">
550 <h2 class="svp-section-title">{group.label} severity</h2>
551 <span class="svp-section-badge">{group.issues.length}</span>
552 </div>
553 <div class="svp-issue-list">
554 {group.issues.map((issue) => (
555 <div class={`svp-issue-card sev-${group.sev}`}>
556 <div class="svp-issue-row">
557 <div class="svp-issue-main">
558 <p class="svp-issue-title">
559 <a href={`/${ownerName}/${repoName}/issues/${issue.number}`}>
560 {issue.title}
561 </a>
562 </p>
563 <div class="svp-issue-meta">
564 <span class="svp-issue-num">#{issue.number}</span>
565 <span class="svp-issue-date">
566 Opened {fmtDate(issue.createdAt)}
567 </span>
568 </div>
569 </div>
570 <span
571 class={`svp-sev-pill sev-${group.sev}`}
572 >
573 {severityLabel(group.sev)}
574 </span>
575 </div>
576 </div>
577 ))}
578 </div>
579 </div>
580 ))}
581 </>
582 )}
583
584 {/* Last scan info */}
585 {lastScanAt && (
586 <div class="svp-scan-info">
587 <span>🕐</span>
588 <span>
589 Last scan: <strong>{fmtDate(lastScanAt)}</strong> — scans run automatically on every push that touches{" "}
590 <code>package.json</code>, <code>requirements.txt</code>,{" "}
591 <code>Cargo.toml</code>, <code>go.mod</code>, or <code>Gemfile</code>.
592 </span>
593 </div>
594 )}
595
596 {/* How it works */}
597 <div class="svp-how">
598 <strong>How it works:</strong> GlueCron scans your dependency files on
599 every push using the{" "}
600 <a href="https://osv.dev" target="_blank" rel="noopener noreferrer">
601 OSV (Open Source Vulnerabilities)
602 </a>{" "}
603 database. Critical and high severity CVEs open individual issues
604 immediately. Medium and low findings are batched into a weekly digest
605 issue. Requires <code>DEPENDENCY_SCAN_ENABLED=1</code> on your
606 deployment.
607 {" "}
608 <a href={`/${ownerName}/${repoName}/security`}>
609 View gate scan results →
610 </a>
611 </div>
612 </div>
613 </Layout>
614 );
615});
616
617export default securityRoutes;
Addedsrc/routes/settings-sessions.tsx+271−0View fileUnifiedSplit
1/**
2 * Session management — SOC 2 CC6.1 session visibility.
3 *
4 * GET /settings/sessions — list all active sessions for the current user
5 * POST /settings/sessions/:id/revoke — delete a specific session
6 * POST /settings/sessions/revoke-all — delete all sessions except the current one
7 */
8
9import { Hono } from "hono";
10import { and, desc, eq, ne } from "drizzle-orm";
11import { getCookie, deleteCookie } from "hono/cookie";
12import { db } from "../db";
13import { sessions } from "../db/schema";
14import { requireAuth } from "../middleware/auth";
15import type { AuthEnv } from "../middleware/auth";
16import { Layout } from "../views/layout";
17
18const settingsSessions = new Hono<AuthEnv>();
19settingsSessions.use("/settings/sessions*", requireAuth);
20
21// ── Helper: parse a friendly device/browser hint from a user-agent string ──
22function parseBrowserHint(ua: string | null | undefined): string {
23 if (!ua) return "Unknown device";
24 const s = ua.toLowerCase();
25
26 // OS
27 let os = "";
28 if (s.includes("android")) os = "Android";
29 else if (s.includes("iphone") || s.includes("ipad")) os = "iOS";
30 else if (s.includes("mac os") || s.includes("macintosh")) os = "macOS";
31 else if (s.includes("windows")) os = "Windows";
32 else if (s.includes("linux")) os = "Linux";
33
34 // Browser
35 let browser = "";
36 if (s.includes("edg/") || s.includes("edge/")) browser = "Edge";
37 else if (s.includes("chrome/") && !s.includes("chromium")) browser = "Chrome";
38 else if (s.includes("firefox/")) browser = "Firefox";
39 else if (s.includes("safari/") && !s.includes("chrome")) browser = "Safari";
40 else if (s.includes("curl/")) browser = "curl";
41 else if (s.includes("postman")) browser = "Postman";
42
43 if (browser && os) return `${browser} on ${os}`;
44 if (browser) return browser;
45 if (os) return os;
46 // Fallback: first 60 chars of raw UA
47 return ua.slice(0, 60);
48}
49
50// ── Scoped styles ────────────────────────────────────────────────────────────
51const sessionsStyles = `
52 .sessions-page { max-width: 800px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
53 .sessions-hero {
54 position: relative;
55 margin-bottom: var(--space-6);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .sessions-hero::before {
63 content: '';
64 position: absolute; top: 0; left: 0; right: 0; height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.7; pointer-events: none;
67 }
68 .sessions-hero h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
69 .sessions-hero p { color: var(--text-muted); margin: 0; font-size: 14px; }
70 .sessions-list { display: flex; flex-direction: column; gap: 12px; }
71 .session-card {
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 10px;
75 padding: var(--space-4) var(--space-5);
76 display: flex; align-items: center; gap: 16px;
77 }
78 .session-card.current { border-color: #8c6dff44; background: #8c6dff0a; }
79 .session-icon {
80 width: 40px; height: 40px; border-radius: 8px;
81 background: var(--bg-canvas); display: flex; align-items: center;
82 justify-content: center; flex-shrink: 0; font-size: 18px;
83 }
84 .session-info { flex: 1; min-width: 0; }
85 .session-device { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
86 .session-current-badge {
87 font-size: 11px; font-weight: 600; padding: 2px 8px;
88 background: #8c6dff22; color: #a388ff; border-radius: 100px;
89 text-transform: uppercase; letter-spacing: 0.04em;
90 }
91 .session-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
92 .sessions-actions { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--border); }
93 .sessions-actions h3 { font-size: 14px; font-weight: 600; margin: 0 0 8px; }
94 .sessions-actions p { font-size: 13px; color: var(--text-muted); margin: 0 0 12px; }
95`;
96
97// ── GET /settings/sessions ───────────────────────────────────────────────────
98settingsSessions.get("/settings/sessions", async (c) => {
99 const user = c.get("user")!;
100 const currentToken = getCookie(c, "session") ?? "";
101
102 const allSessions = await db
103 .select()
104 .from(sessions)
105 .where(
106 and(
107 eq(sessions.userId, user.id),
108 // Only show non-expired sessions
109 )
110 )
111 .orderBy(desc(sessions.createdAt));
112
113 // Filter to non-expired
114 const now = new Date();
115 const activeSessions = allSessions.filter((s) => new Date(s.expiresAt) > now);
116
117 return c.html(
118 <Layout title="Active sessions" user={user}>
119 <style dangerouslySetInnerHTML={{ __html: sessionsStyles }} />
120 <div class="sessions-page">
121 <div class="sessions-hero">
122 <h1>Active sessions</h1>
123 <p>
124 These are the devices currently signed in to your account. Revoke
125 any session you don't recognise.
126 </p>
127 </div>
128
129 <div class="sessions-list">
130 {activeSessions.map((s) => {
131 const isCurrent = s.token === currentToken;
132 const deviceHint = parseBrowserHint(s.userAgent);
133 const lastSeen = s.lastSeenAt
134 ? new Date(s.lastSeenAt).toLocaleString("en-US", {
135 month: "short",
136 day: "numeric",
137 year: "numeric",
138 hour: "2-digit",
139 minute: "2-digit",
140 })
141 : "Unknown";
142 const created = new Date(s.createdAt).toLocaleString("en-US", {
143 month: "short",
144 day: "numeric",
145 year: "numeric",
146 });
147 const icon =
148 deviceHint.toLowerCase().includes("mobile") ||
149 deviceHint.toLowerCase().includes("android") ||
150 deviceHint.toLowerCase().includes("ios")
151 ? "📱"
152 : "💻";
153
154 return (
155 <div class={`session-card${isCurrent ? " current" : ""}`} key={s.id}>
156 <div class="session-icon">{icon}</div>
157 <div class="session-info">
158 <div class="session-device">
159 {deviceHint}
160 {isCurrent && (
161 <span class="session-current-badge">Current</span>
162 )}
163 </div>
164 <div class="session-meta">
165 {s.ip ? `IP: ${s.ip} · ` : ""}
166 Last active: {lastSeen} · Signed in: {created}
167 </div>
168 </div>
169 {!isCurrent && (
170 <form method="post" action={`/settings/sessions/${s.id}/revoke`}>
171 <input
172 type="hidden"
173 name="_csrf"
174 value={(c.get("csrfToken") as string | undefined) ?? ""}
175 />
176 <button
177 type="submit"
178 class="btn btn-sm"
179 style="color: var(--danger); border-color: var(--danger);"
180 >
181 Revoke
182 </button>
183 </form>
184 )}
185 </div>
186 );
187 })}
188 </div>
189
190 {activeSessions.length > 1 && (
191 <div class="sessions-actions">
192 <h3>Sign out everywhere else</h3>
193 <p>
194 This will revoke all sessions except your current one. Any device
195 signed in to your account will need to log in again.
196 </p>
197 <form method="post" action="/settings/sessions/revoke-all">
198 <input
199 type="hidden"
200 name="_csrf"
201 value={(c.get("csrfToken") as string | undefined) ?? ""}
202 />
203 <button
204 type="submit"
205 class="btn"
206 style="color: var(--danger); border-color: var(--danger);"
207 >
208 Revoke all other sessions
209 </button>
210 </form>
211 </div>
212 )}
213 </div>
214 </Layout>
215 );
216});
217
218// ── POST /settings/sessions/:id/revoke ───────────────────────────────────────
219settingsSessions.post("/settings/sessions/:id/revoke", async (c) => {
220 const user = c.get("user")!;
221 const { id } = c.req.param();
222 const currentToken = getCookie(c, "session") ?? "";
223
224 // Fetch the session to verify ownership and ensure it's not current.
225 const [target] = await db
226 .select()
227 .from(sessions)
228 .where(and(eq(sessions.id, id), eq(sessions.userId, user.id)))
229 .limit(1);
230
231 if (!target) {
232 return c.redirect("/settings/sessions?error=Session+not+found");
233 }
234 if (target.token === currentToken) {
235 return c.redirect("/settings/sessions?error=Cannot+revoke+your+current+session");
236 }
237
238 await db.delete(sessions).where(eq(sessions.id, id));
239 return c.redirect("/settings/sessions?success=Session+revoked");
240});
241
242// ── POST /settings/sessions/revoke-all ──────────────────────────────────────
243settingsSessions.post("/settings/sessions/revoke-all", async (c) => {
244 const user = c.get("user")!;
245 const currentToken = getCookie(c, "session") ?? "";
246
247 // Find and delete all sessions belonging to this user EXCEPT the current one.
248 const [currentSession] = await db
249 .select({ id: sessions.id })
250 .from(sessions)
251 .where(and(eq(sessions.userId, user.id), eq(sessions.token, currentToken)))
252 .limit(1);
253
254 if (currentSession) {
255 await db
256 .delete(sessions)
257 .where(
258 and(
259 eq(sessions.userId, user.id),
260 ne(sessions.id, currentSession.id)
261 )
262 );
263 } else {
264 // Fallback: delete all (this shouldn't happen in normal flow).
265 await db.delete(sessions).where(eq(sessions.userId, user.id));
266 }
267
268 return c.redirect("/settings/sessions?success=All+other+sessions+revoked");
269});
270
271export default settingsSessions;
Modifiedsrc/routes/settings.tsx+219−87View fileUnifiedSplit
55import { Hono } from "hono";
66import { eq } from "drizzle-orm";
77import { db } from "../db";
8import { users, sshKeys } from "../db/schema";
8import { users, sshKeys, ssoUserLinks } from "../db/schema";
99import { getStandupPrefs, setStandupPrefs } from "../lib/ai-standup";
1010import type { AuthEnv } from "../middleware/auth";
1111import { requireAuth } from "../middleware/auth";
2222 daysUntilPurge,
2323} from "../lib/account-deletion";
2424import { deleteCookie } from "hono/cookie";
25import { audit } from "../lib/notify";
2526
2627const settings = new Hono<AuthEnv>();
2728
598599}
599600
600601/** Pill-row sub-navigation, hand-built so we don't depend on shared components. */
601export type SettingsSubnavKey = "profile" | "keys" | "agents";
602export type SettingsSubnavKey = "profile" | "keys" | "agents" | "deploy-targets";
602603
603604export function SettingsSubnav(props: { active: SettingsSubnavKey }) {
604605 const items: Array<{ key: SettingsSubnavKey; href: string; label: string }> = [
605606 { key: "profile", href: "/settings", label: "Profile" },
606607 { key: "keys", href: "/settings/keys", label: "SSH keys" },
607608 { key: "agents", href: "/settings/agents", label: "Agents" },
609 { key: "deploy-targets", href: "/settings/deploy-targets", label: "Deploy targets" },
608610 ];
609611 return (
610612 <nav class="settings-subnav" aria-label="Settings sections">
636638settings.get("/settings", async (c) => {
637639 const user = c.get("user")!;
638640 const success = c.req.query("success");
641 // Check if the user has a GitHub OAuth link (subject starts with "github:")
642 const githubLink = await db
643 .select({ id: ssoUserLinks.id, subject: ssoUserLinks.subject })
644 .from(ssoUserLinks)
645 .where(eq(ssoUserLinks.userId, user.id))
646 .then((rows) => rows.find((r) => r.subject.startsWith("github:")));
647 const hasGithubLinked = !!githubLink;
639648 const standupPrefs = (await getStandupPrefs(user.id)) || {
640649 dailyEnabled: false,
641650 weeklyEnabled: false,
685694 <Banner kind="success" text={decodeURIComponent(success)} />
686695 )}
687696
697 {/* ─── GitHub account link ─── */}
698 {hasGithubLinked && (
699 <section class="settings-section">
700 <div class="settings-section-head">
701 <div class="settings-section-eyebrow">Connected account</div>
702 <h2 class="settings-section-title">GitHub</h2>
703 <p class="settings-section-desc">
704 Your Gluecron account is linked to a GitHub account.
705 Disconnecting removes the link but does not delete your
706 Gluecron account or any of your repositories.
707 </p>
708 </div>
709 <div class="settings-section-body">
710 <div
711 style={
712 "display:flex;align-items:center;gap:12px;padding:12px 14px;" +
713 "background:rgba(52,211,153,0.06);border:1px solid rgba(52,211,153,0.22);" +
714 "border-radius:10px;"
715 }
716 >
717 <span
718 style={
719 "display:inline-flex;align-items:center;justify-content:center;" +
720 "width:32px;height:32px;border-radius:8px;" +
721 "background:rgba(52,211,153,0.14);color:#6ee7b7;font-weight:700;font-size:15px;"
722 }
723 aria-hidden="true"
724 >
725
726 </span>
727 <span style="font-size:14px;color:var(--text)">
728 GitHub account connected
729 </span>
730 </div>
731 </div>
732 <div class="settings-section-foot">
733 <form method="post" action="/settings/github/unlink">
734 <button type="submit" class="btn btn-danger btn-sm">
735 Disconnect GitHub
736 </button>
737 </form>
738 </div>
739 </section>
740 )}
741
688742 {/* ─── Profile ─── */}
689743 <section class="settings-section">
690744 <div class="settings-section-head">
16151669 const success = c.req.query("success");
16161670
16171671 // Count how many event toggles are enabled per channel (just for the pill).
1672 // Email: mention, assign, gate_fail, digest_weekly, pending_comment (5 total)
16181673 const emailOnCount =
16191674 (user.notifyEmailOnMention ? 1 : 0) +
16201675 (user.notifyEmailOnAssign ? 1 : 0) +
16211676 (user.notifyEmailOnGateFail ? 1 : 0) +
1622 (user.notifyEmailDigestWeekly ? 1 : 0);
1677 (user.notifyEmailDigestWeekly ? 1 : 0) +
1678 (user.notifyEmailOnPendingComment ? 1 : 0);
1679 // Push: mention, assign, review_request, deploy_failed (4 total)
16231680 const pushOnCount =
16241681 (user.notifyPushOnMention ? 1 : 0) +
16251682 (user.notifyPushOnAssign ? 1 : 0) +
16801737 </div>
16811738 <div class="notifset-channel-text">
16821739 <h3 class="notifset-channel-name">Email</h3>
1683 <p class="notifset-channel-meta">{emailOnCount} of 4 events enabled</p>
1740 <p class="notifset-channel-meta">{emailOnCount} of 5 events enabled</p>
16841741 </div>
16851742 <span class={"notifset-channel-pill " + (emailOnCount > 0 ? "is-on" : "")}>
16861743 <span class="dot" aria-hidden="true" />
17021759 </div>
17031760 </div>
17041761
1705 {/* ─── Event rules form ─── */}
1762 {/* ─── Event rules form — 4 categories ─── */}
17061763 <form method="post" action="/settings/notifications">
1764
1765 {/* ── Category 1: AI activity ── */}
17071766 <section class="notifset-section">
17081767 <header class="notifset-section-head">
17091768 <h3 class="notifset-section-title">
17101769 <span class="notifset-section-icon" aria-hidden="true">
1711 <NotifsetMailIcon />
1770 <NotifsetInappIcon />
17121771 </span>
1713 Email rules
1772 AI activity
17141773 </h3>
17151774 <p class="notifset-section-sub">
1716 Pick which events email you. In-app notifications continue
1717 regardless of what you toggle here.
1775 Be notified when Claude acts on your behalf — reviews, spec
1776 builds, CI self-heals, and auto-merges. In-app always fires;
1777 email and push are opt-in.
17181778 </p>
17191779 </header>
17201780 <div class="notifset-section-body">
17211781 <label class="notifset-rule">
17221782 <input
17231783 type="checkbox"
1724 name="notify_email_on_mention"
1784 name="notify_email_digest_weekly"
17251785 value="1"
1726 checked={user.notifyEmailOnMention}
1727 aria-label="Someone @mentions me or requests a review"
1786 checked={user.notifyEmailDigestWeekly}
1787 aria-label="Weekly AI activity digest"
17281788 />
17291789 <span class="notifset-rule-text">
1730 Someone <code>@mentions</code> me or requests a review
1790 Weekly AI activity digest (email) &mdash;{" "}
1791 <a href="/settings/digest/preview" style="color:var(--accent)">preview</a>
17311792 <span class="notifset-rule-hint">
1732 Direct pings on issues, PRs, and code comments.
1793 A Monday summary of AI reviews posted, specs built, CI heals,
1794 and auto-merges from the past week.
17331795 </span>
17341796 </span>
17351797 </label>
17361798 <label class="notifset-rule">
17371799 <input
17381800 type="checkbox"
1739 name="notify_email_on_assign"
1801 name="sleep_mode_enabled"
17401802 value="1"
1741 checked={user.notifyEmailOnAssign}
1742 aria-label="I am assigned to an issue or PR"
1803 checked={user.sleepModeEnabled}
1804 aria-label="Enable Sleep Mode overnight digest"
17431805 />
17441806 <span class="notifset-rule-text">
1745 I am assigned to an issue or PR
1807 Enable Sleep Mode overnight digest (email)
17461808 <span class="notifset-rule-hint">
1747 Email when someone hands you something to work on.
1809 Claude operates autonomously overnight and sends a daily
1810 report of what shipped — PRs auto-merged, issues built,
1811 AI reviews, security auto-fixes, gate repairs.{" "}
1812 <a href="/sleep-mode" style="color:var(--accent)">Learn more</a>.
17481813 </span>
17491814 </span>
17501815 </label>
1751 <label class="notifset-rule">
1816 <div class="notifset-hour">
1817 <label for="sleep_mode_digest_hour_utc">
1818 Morning digest hour (UTC 0–23):
1819 </label>
17521820 <input
1753 type="checkbox"
1754 name="notify_email_on_gate_fail"
1755 value="1"
1756 checked={user.notifyEmailOnGateFail}
1757 aria-label="A gate fails on one of my repositories"
1821 type="number"
1822 id="sleep_mode_digest_hour_utc"
1823 name="sleep_mode_digest_hour_utc"
1824 min={0}
1825 max={23}
1826 step={1}
1827 value={String(user.sleepModeDigestHourUtc)}
1828 aria-label="Sleep Mode digest UTC hour"
17581829 />
1759 <span class="notifset-rule-text">
1760 A gate fails on one of my repositories
1761 <span class="notifset-rule-hint">
1762 Security, test, or build gate alerts on your repos.
1763 </span>
1830 <a href="/settings/sleep-mode/preview">Preview digest now</a>
1831 </div>
1832 </div>
1833 </section>
1834
1835 {/* ── Category 2: CI/CD ── */}
1836 <section class="notifset-section">
1837 <header class="notifset-section-head">
1838 <h3 class="notifset-section-title">
1839 <span class="notifset-section-icon" aria-hidden="true">
1840 <NotifsetPushIcon />
17641841 </span>
1765 </label>
1842 CI/CD
1843 </h3>
1844 <p class="notifset-section-sub">
1845 Workflow runs, gate results, and deploy outcomes on repositories
1846 you own. Email and push are opt-in per channel.
1847 </p>
1848 </header>
1849 <div class="notifset-section-body">
17661850 <label class="notifset-rule">
17671851 <input
17681852 type="checkbox"
1769 name="notify_email_digest_weekly"
1853 name="notify_email_on_gate_fail"
17701854 value="1"
1771 checked={user.notifyEmailDigestWeekly}
1772 aria-label="Weekly digest email"
1855 checked={user.notifyEmailOnGateFail}
1856 aria-label="A gate fails on one of my repositories — email"
17731857 />
17741858 <span class="notifset-rule-text">
1775 Weekly digest &mdash; <a href="/settings/digest/preview" style="color:var(--accent)">preview</a>
1859 Gate / workflow fails (email)
17761860 <span class="notifset-rule-hint">
1777 A Monday summary of what shipped last week.
1861 Security, test, or build gate failures on your repos —
1862 triggered on push and PR.
17781863 </span>
17791864 </span>
17801865 </label>
17811866 <label class="notifset-rule">
17821867 <input
17831868 type="checkbox"
1784 name="notify_email_on_pending_comment"
1869 name="notify_push_on_deploy_failed"
17851870 value="1"
1786 checked={user.notifyEmailOnPendingComment}
1787 aria-label="Someone leaves a comment on a public repo you own and it needs your approval"
1871 checked={user.notifyPushOnDeployFailed}
1872 aria-label="A deploy fails — web push"
17881873 />
17891874 <span class="notifset-rule-text">
1790 Pending comment requests on my repos
1875 Deploy fails (web push)
17911876 <span class="notifset-rule-hint">
1792 A non-collaborator left a comment on a public repo you
1793 own. Comments stay hidden until you review them.
1877 Push notification when a deploy to production fails on
1878 one of your repositories.
17941879 </span>
17951880 </span>
17961881 </label>
17971882 </div>
17981883 </section>
17991884
1885 {/* ── Category 3: Code review ── */}
18001886 <section class="notifset-section">
18011887 <header class="notifset-section-head">
18021888 <h3 class="notifset-section-title">
18031889 <span class="notifset-section-icon" aria-hidden="true">
1804 <NotifsetPushIcon />
1890 <NotifsetMailIcon />
18051891 </span>
1806 Web push rules
1892 Code review
18071893 </h3>
18081894 <p class="notifset-section-sub">
1809 Install Gluecron as a PWA, then subscribe a device from{" "}
1810 <a href="/settings" style="color:var(--accent)">main settings</a>. These
1811 toggles control which event kinds trigger a push notification.
1895 PR comments, review requests, assignments, and merges.
18121896 </p>
18131897 </header>
18141898 <div class="notifset-section-body">
18151899 <label class="notifset-rule">
18161900 <input
18171901 type="checkbox"
1818 name="notify_push_on_mention"
1902 name="notify_push_on_review_request"
18191903 value="1"
1820 checked={user.notifyPushOnMention}
1821 aria-label="Someone @mentions me"
1904 checked={user.notifyPushOnReviewRequest}
1905 aria-label="Someone requests a review from me — web push"
18221906 />
18231907 <span class="notifset-rule-text">
1824 Someone <code>@mentions</code> me
1908 Review requested (web push)
1909 <span class="notifset-rule-hint">
1910 Push notification when you are added as a reviewer to a PR.
1911 </span>
18251912 </span>
18261913 </label>
18271914 <label class="notifset-rule">
18281915 <input
18291916 type="checkbox"
1830 name="notify_push_on_assign"
1917 name="notify_email_on_assign"
18311918 value="1"
1832 checked={user.notifyPushOnAssign}
1833 aria-label="I am assigned to an issue or PR"
1919 checked={user.notifyEmailOnAssign}
1920 aria-label="I am assigned to an issue or PR — email"
18341921 />
1835 <span class="notifset-rule-text">I am assigned to an issue or PR</span>
1922 <span class="notifset-rule-text">
1923 Assigned to an issue or PR (email)
1924 <span class="notifset-rule-hint">
1925 Email when someone hands you a PR or issue to work on.
1926 </span>
1927 </span>
18361928 </label>
18371929 <label class="notifset-rule">
18381930 <input
18391931 type="checkbox"
1840 name="notify_push_on_review_request"
1932 name="notify_push_on_assign"
18411933 value="1"
1842 checked={user.notifyPushOnReviewRequest}
1843 aria-label="Someone requests a review from me"
1934 checked={user.notifyPushOnAssign}
1935 aria-label="I am assigned to an issue or PR — web push"
18441936 />
1845 <span class="notifset-rule-text">Someone requests a review from me</span>
1937 <span class="notifset-rule-text">
1938 Assigned to an issue or PR (web push)
1939 <span class="notifset-rule-hint">
1940 Push notification when you are assigned to a PR or issue.
1941 </span>
1942 </span>
18461943 </label>
18471944 <label class="notifset-rule">
18481945 <input
18491946 type="checkbox"
1850 name="notify_push_on_deploy_failed"
1947 name="notify_email_on_pending_comment"
18511948 value="1"
1852 checked={user.notifyPushOnDeployFailed}
1853 aria-label="A deploy fails"
1949 checked={user.notifyEmailOnPendingComment}
1950 aria-label="Pending comment approval needed on my repos"
18541951 />
18551952 <span class="notifset-rule-text">
1856 A deploy fails on one of my repositories
1953 Pending comment needs approval (email)
1954 <span class="notifset-rule-hint">
1955 A non-collaborator left a comment on a public repo you
1956 own. Comments stay hidden until you review them.
1957 </span>
18571958 </span>
18581959 </label>
18591960 </div>
18601961 </section>
18611962
1963 {/* ── Category 4: Mentions ── */}
18621964 <section class="notifset-section">
18631965 <header class="notifset-section-head">
18641966 <h3 class="notifset-section-title">
18651967 <span class="notifset-section-icon" aria-hidden="true">
1866 <NotifsetInappIcon />
1968 <NotifsetBellIcon />
18671969 </span>
1868 Sleep Mode
1970 Mentions
18691971 </h3>
18701972 <p class="notifset-section-sub">
1871 Toggle Sleep Mode and pick when your morning digest lands.
1872 <a href="/sleep-mode" style="color:var(--accent);margin-left:4px">Learn more</a>.
1973 Direct <code style="font-family:var(--font-mono);font-size:12px;background:rgba(255,255,255,0.04);border:1px solid var(--border);padding:1px 6px;border-radius:4px">@mentions</code> in
1974 issues and PR comments. In-app always fires; email and push are opt-in.
18731975 </p>
18741976 </header>
18751977 <div class="notifset-section-body">
18761978 <label class="notifset-rule">
18771979 <input
18781980 type="checkbox"
1879 name="sleep_mode_enabled"
1981 name="notify_email_on_mention"
18801982 value="1"
1881 checked={user.sleepModeEnabled}
1882 aria-label="Enable Sleep Mode"
1983 checked={user.notifyEmailOnMention}
1984 aria-label="Someone @mentions me or requests a review — email"
18831985 />
18841986 <span class="notifset-rule-text">
1885 Enable Sleep Mode (daily &ldquo;overnight&rdquo; digest)
1987 <code>@mention</code> in issue or PR comment (email)
18861988 <span class="notifset-rule-hint">
1887 Claude operates autonomously between digests and reports
1888 back on the schedule you pick.
1989 Email when someone tags you directly in a thread or
1990 requests a review.
18891991 </span>
18901992 </span>
18911993 </label>
1892 <div class="notifset-hour">
1893 <label for="sleep_mode_digest_hour_utc">
1894 Morning digest hour (UTC 0–23):
1895 </label>
1994 <label class="notifset-rule">
18961995 <input
1897 type="number"
1898 id="sleep_mode_digest_hour_utc"
1899 name="sleep_mode_digest_hour_utc"
1900 min={0}
1901 max={23}
1902 step={1}
1903 value={String(user.sleepModeDigestHourUtc)}
1904 aria-label="Sleep Mode digest UTC hour"
1996 type="checkbox"
1997 name="notify_push_on_mention"
1998 value="1"
1999 checked={user.notifyPushOnMention}
2000 aria-label="Someone @mentions me — web push"
19052001 />
1906 <a href="/settings/sleep-mode/preview">Preview digest now</a>
1907 </div>
2002 <span class="notifset-rule-text">
2003 <code>@mention</code> in issue or PR comment (web push)
2004 <span class="notifset-rule-hint">
2005 Push notification when someone tags you in a thread.
2006 </span>
2007 </span>
2008 </label>
19082009 </div>
19092010 <div class="notifset-section-foot">
19102011 <span class="notifset-foot-hint">
23762477 return c.json({ deleted: true });
23772478});
23782479
2480// ─── /settings/github/unlink — disconnect GitHub OAuth ───────────────────────
2481// Alias for /settings/sso/unlink scoped to just the "github:" prefixed links.
2482// Deletes only the github: sso_user_links row(s) for the current user, leaving
2483// any enterprise-OIDC or google: links intact.
2484settings.post("/settings/github/unlink", requireAuth, async (c) => {
2485 const user = c.get("user")!;
2486 // Fetch all SSO links for this user, then filter for GitHub ones in JS
2487 // (avoids a raw SQL LIKE in the ORM layer).
2488 const allLinks = await db
2489 .select({ id: ssoUserLinks.id, subject: ssoUserLinks.subject })
2490 .from(ssoUserLinks)
2491 .where(eq(ssoUserLinks.userId, user.id));
2492 const githubLinks = allLinks.filter((l) => l.subject.startsWith("github:"));
2493 if (githubLinks.length === 0) {
2494 return c.redirect(
2495 "/settings?error=" + encodeURIComponent("No GitHub account is linked.")
2496 );
2497 }
2498 for (const link of githubLinks) {
2499 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.id, link.id));
2500 }
2501 await audit({
2502 userId: user.id,
2503 action: "auth.github.unlink",
2504 metadata: { removedLinks: githubLinks.length },
2505 });
2506 return c.redirect(
2507 "/settings?success=" + encodeURIComponent("GitHub account disconnected.")
2508 );
2509});
2510
23792511export default settings;
Addedsrc/routes/share.tsx+665−0View fileUnifiedSplit
1/**
2 * Shareable "AI hours saved" cards — viral growth lever for Twitter / LinkedIn.
3 *
4 * GET /share/hours-saved?user=:username
5 * Returns a 1200×630 SVG OG image card showing how many hours the user
6 * (or the platform globally) has saved with AI tooling.
7 *
8 * GET /share/:username
9 * HTML landing page with proper OG meta tags (og:image points to the
10 * SVG endpoint above), a live stat display, and a "Share on Twitter"
11 * button with pre-filled tweet text.
12 *
13 * Hours-saved formula (all-time, per user):
14 * - AI-merged PRs × 1.5 h (source: auditLog action="auto_merge.merged",
15 * joined to pull_requests where authorId = user)
16 * - AI reviews × 0.5 h (source: pr_comments where isAiReview=true and
17 * the PR's repository is owned by the user)
18 * - CI heals × 0.3 h (source: gate_runs where repairSucceeded=true and
19 * repositoryId in user's repos)
20 *
21 * For missing / unauthenticated users the SVG and page fall back to
22 * platform-wide aggregate stats.
23 *
24 * No new npm dependencies — pure SVG text, no canvas / sharp / puppeteer.
25 */
26
27import { Hono } from "hono";
28import { eq, and, sql, inArray } from "drizzle-orm";
29import { db } from "../db";
30import {
31 users,
32 pullRequests,
33 prComments,
34 gateRuns,
35 repositories,
36 auditLog,
37} from "../db/schema";
38import { Layout } from "../views/layout";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41
42const share = new Hono<AuthEnv>();
43share.use("*", softAuth);
44
45// ─── Hours computation ────────────────────────────────────────────────────
46
47interface HoursSaved {
48 aiMergedPrs: number;
49 aiReviews: number;
50 ciHeals: number;
51 totalHours: number;
52}
53
54/** Compute hours saved for a specific user (all-time). */
55async function computeHoursForUser(userId: string): Promise<HoursSaved> {
56 // Repos owned by the user — needed for CI heals and AI-review lookups.
57 const userRepos = await db
58 .select({ id: repositories.id })
59 .from(repositories)
60 .where(eq(repositories.ownerId, userId));
61
62 const repoIds = userRepos.map((r) => r.id);
63
64 // 1. AI-merged PRs: audit_log rows where action='auto_merge.merged' and
65 // the associated PR was authored by the user.
66 let aiMergedPrs = 0;
67 try {
68 const [row] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(auditLog)
71 .where(
72 and(
73 eq(auditLog.action, "auto_merge.merged"),
74 eq(auditLog.userId, userId)
75 )
76 );
77 aiMergedPrs = Number(row?.n ?? 0);
78 } catch {
79 aiMergedPrs = 0;
80 }
81
82 // 2. AI reviews: pr_comments with isAiReview=true on PRs in user's repos.
83 let aiReviews = 0;
84 try {
85 if (repoIds.length > 0) {
86 const [row] = await db
87 .select({ n: sql<number>`count(*)::int` })
88 .from(prComments)
89 .innerJoin(
90 pullRequests,
91 eq(prComments.pullRequestId, pullRequests.id)
92 )
93 .where(
94 and(
95 eq(prComments.isAiReview, true),
96 inArray(pullRequests.repositoryId, repoIds)
97 )
98 );
99 aiReviews = Number(row?.n ?? 0);
100 }
101 } catch {
102 aiReviews = 0;
103 }
104
105 // 3. CI heals: gate_runs where repairSucceeded=true on user's repos.
106 let ciHeals = 0;
107 try {
108 if (repoIds.length > 0) {
109 const [row] = await db
110 .select({ n: sql<number>`count(*)::int` })
111 .from(gateRuns)
112 .where(
113 and(
114 eq(gateRuns.repairSucceeded, true),
115 inArray(gateRuns.repositoryId, repoIds)
116 )
117 );
118 ciHeals = Number(row?.n ?? 0);
119 }
120 } catch {
121 ciHeals = 0;
122 }
123
124 const totalHours =
125 aiMergedPrs * 1.5 + aiReviews * 0.5 + ciHeals * 0.3;
126
127 return { aiMergedPrs, aiReviews, ciHeals, totalHours };
128}
129
130/** Platform-wide aggregate hours saved (for fallback / anonymous). */
131async function computeGlobalHours(): Promise<HoursSaved> {
132 let aiMergedPrs = 0;
133 let aiReviews = 0;
134 let ciHeals = 0;
135
136 try {
137 const [row] = await db
138 .select({ n: sql<number>`count(*)::int` })
139 .from(auditLog)
140 .where(eq(auditLog.action, "auto_merge.merged"));
141 aiMergedPrs = Number(row?.n ?? 0);
142 } catch {
143 aiMergedPrs = 0;
144 }
145
146 try {
147 const [row] = await db
148 .select({ n: sql<number>`count(*)::int` })
149 .from(prComments)
150 .where(eq(prComments.isAiReview, true));
151 aiReviews = Number(row?.n ?? 0);
152 } catch {
153 aiReviews = 0;
154 }
155
156 try {
157 const [row] = await db
158 .select({ n: sql<number>`count(*)::int` })
159 .from(gateRuns)
160 .where(eq(gateRuns.repairSucceeded, true));
161 ciHeals = Number(row?.n ?? 0);
162 } catch {
163 ciHeals = 0;
164 }
165
166 const totalHours =
167 aiMergedPrs * 1.5 + aiReviews * 0.5 + ciHeals * 0.3;
168
169 return { aiMergedPrs, aiReviews, ciHeals, totalHours };
170}
171
172/** Format a number to one decimal place, dropping ".0" when it's clean. */
173function fmtHours(n: number): string {
174 if (n === 0) return "0";
175 const s = n.toFixed(1);
176 return s.endsWith(".0") ? s.slice(0, -2) : s;
177}
178
179// ─── SVG OG image ────────────────────────────────────────────────────────
180
181/**
182 * GET /share/hours-saved?user=:username
183 * Returns a 1200×630 SVG card suitable for use as an OG image.
184 */
185share.get("/share/hours-saved", async (c) => {
186 const username = c.req.query("user") ?? "";
187 let stats: HoursSaved;
188 let displayName = username || "the community";
189 let atName = username ? `@${username}` : "gluecron.com";
190 let isGlobal = !username;
191
192 if (username) {
193 const [found] = await db
194 .select()
195 .from(users)
196 .where(eq(users.username, username))
197 .limit(1);
198
199 if (found) {
200 stats = await computeHoursForUser(found.id);
201 } else {
202 // Unknown user — fall back to global
203 stats = await computeGlobalHours();
204 displayName = "the community";
205 atName = "gluecron.com";
206 isGlobal = true;
207 }
208 } else {
209 stats = await computeGlobalHours();
210 }
211
212 const hoursStr = fmtHours(stats.totalHours);
213 const label = isGlobal
214 ? "hours saved with AI — platform-wide"
215 : "hours saved with AI";
216
217 const svg = buildOgSvg({ hoursStr, label, atName, stats });
218
219 c.header("Content-Type", "image/svg+xml; charset=utf-8");
220 c.header("Cache-Control", "public, max-age=300, s-maxage=900");
221 return c.body(svg);
222});
223
224/** Build the 1200×630 SVG string. Pure function — no I/O. */
225function buildOgSvg({
226 hoursStr,
227 label,
228 atName,
229 stats,
230}: {
231 hoursStr: string;
232 label: string;
233 atName: string;
234 stats: HoursSaved;
235}): string {
236 // Estimate text width for the giant number so we can center it.
237 // Each digit ≈ 95px wide at font-size 160; decimal point ≈ 32px.
238 const charWidths: Record<string, number> = {
239 "0": 95, "1": 70, "2": 95, "3": 95, "4": 95,
240 "5": 95, "6": 95, "7": 85, "8": 95, "9": 95,
241 ".": 32,
242 };
243 const numWidth = [...hoursStr].reduce(
244 (w, ch) => w + (charWidths[ch] ?? 90),
245 0
246 );
247 const numX = Math.round(600 - numWidth / 2);
248
249 const pill = (x: number, y: number, n: number, text: string) =>
250 `<g transform="translate(${x},${y})">
251 <rect x="0" y="0" width="240" height="46" rx="10" fill="rgba(0,255,136,0.08)" stroke="rgba(0,255,136,0.22)" stroke-width="1"/>
252 <text x="20" y="30" font-family="'Courier New',monospace" font-size="14" fill="#00ff88" font-weight="700">${n.toLocaleString()}</text>
253 <text x="60" y="30" font-family="'Courier New',monospace" font-size="14" fill="#8b949e">${text}</text>
254 </g>`;
255
256 return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
257 <defs>
258 <linearGradient id="bg-grad" x1="0" y1="0" x2="1" y2="1">
259 <stop offset="0%" stop-color="#0d1117"/>
260 <stop offset="100%" stop-color="#0a0e14"/>
261 </linearGradient>
262 <linearGradient id="num-grad" x1="0" y1="0" x2="1" y2="1">
263 <stop offset="0%" stop-color="#00ff88"/>
264 <stop offset="100%" stop-color="#00e5ff"/>
265 </linearGradient>
266 <filter id="glow">
267 <feGaussianBlur stdDeviation="6" result="blur"/>
268 <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
269 </filter>
270 <!-- radial glow orb behind the number -->
271 <radialGradient id="orb-grad" cx="50%" cy="50%" r="50%">
272 <stop offset="0%" stop-color="rgba(0,255,136,0.10)"/>
273 <stop offset="100%" stop-color="rgba(0,255,136,0)"/>
274 </radialGradient>
275 </defs>
276
277 <!-- Background -->
278 <rect width="1200" height="630" fill="url(#bg-grad)"/>
279
280 <!-- Subtle grid lines -->
281 <line x1="0" y1="1" x2="1200" y2="1" stroke="#30363d" stroke-width="1"/>
282 <line x1="0" y1="629" x2="1200" y2="629" stroke="#30363d" stroke-width="1"/>
283
284 <!-- Top accent bar -->
285 <rect x="0" y="0" width="1200" height="3" fill="url(#num-grad)" opacity="0.85"/>
286
287 <!-- Gluecron logo / wordmark -->
288 <text x="60" y="72" font-family="'Courier New',monospace" font-size="22" font-weight="700" fill="#00ff88" letter-spacing="0.18em">GLUECRON</text>
289 <text x="222" y="72" font-family="'Courier New',monospace" font-size="14" fill="#444c56">· AI-native git platform</text>
290
291 <!-- Orb glow behind number -->
292 <ellipse cx="600" cy="310" rx="320" ry="180" fill="url(#orb-grad)"/>
293
294 <!-- Giant hours number -->
295 <text
296 x="${numX}"
297 y="340"
298 font-family="'Courier New',monospace"
299 font-size="160"
300 font-weight="700"
301 fill="url(#num-grad)"
302 filter="url(#glow)"
303 >${hoursStr}</text>
304
305 <!-- Label below number -->
306 <text x="600" y="400" text-anchor="middle" font-family="'Courier New',monospace" font-size="26" fill="#e6edf3" letter-spacing="0.01em">${label}</text>
307
308 <!-- Breakdown pills -->
309 ${pill(60, 450, stats.aiMergedPrs, "auto-merged PRs × 1.5h")}
310 ${pill(330, 450, stats.aiReviews, "AI reviews × 0.5h")}
311 ${pill(600, 450, stats.ciHeals, "CI heals × 0.3h")}
312
313 <!-- Bottom footer -->
314 <line x1="60" y1="548" x2="1140" y2="548" stroke="#21262d" stroke-width="1"/>
315 <text x="60" y="580" font-family="'Courier New',monospace" font-size="18" fill="#8b949e">gluecron.com</text>
316 <text x="1140" y="580" text-anchor="end" font-family="'Courier New',monospace" font-size="18" fill="#8b949e">${escSvg(atName)}</text>
317</svg>`;
318}
319
320/** Escape special XML/SVG characters in text content. */
321function escSvg(s: string): string {
322 return s
323 .replace(/&/g, "&amp;")
324 .replace(/</g, "&lt;")
325 .replace(/>/g, "&gt;")
326 .replace(/"/g, "&quot;")
327 .replace(/'/g, "&#39;");
328}
329
330// ─── Shareable HTML page ──────────────────────────────────────────────────
331
332const shareStyles = `
333 .share-wrap { max-width: 860px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
334
335 .share-hero {
336 position: relative;
337 margin-bottom: var(--space-5);
338 padding: clamp(32px,5vw,56px) clamp(24px,4vw,48px);
339 background: var(--bg-elevated);
340 border: 1px solid var(--border);
341 border-radius: 18px;
342 overflow: hidden;
343 text-align: center;
344 }
345 .share-hero::before {
346 content: '';
347 position: absolute; top: 0; left: 0; right: 0; height: 3px;
348 background: linear-gradient(90deg, transparent 0%, #00ff88 30%, #00e5ff 70%, transparent 100%);
349 opacity: 0.85; pointer-events: none;
350 }
351 .share-orb {
352 position: absolute;
353 inset: -20% 10% auto 10%;
354 width: 80%; height: 300px;
355 background: radial-gradient(ellipse, rgba(0,255,136,0.10), rgba(0,229,255,0.06) 50%, transparent 80%);
356 filter: blur(60px); opacity: 0.8;
357 pointer-events: none; z-index: 0;
358 }
359 .share-hero-inner { position: relative; z-index: 1; }
360
361 .share-eyebrow {
362 font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.16em;
363 text-transform: uppercase; color: var(--text-muted); font-weight: 600;
364 margin-bottom: var(--space-3);
365 }
366 .share-eyebrow-dot {
367 display: inline-block; width: 8px; height: 8px; border-radius: 9999px;
368 background: #00ff88; box-shadow: 0 0 0 3px rgba(0,255,136,0.18);
369 margin-right: 8px; vertical-align: middle;
370 }
371
372 .share-hours-num {
373 font-family: var(--font-mono);
374 font-size: clamp(64px, 14vw, 120px);
375 font-weight: 700;
376 line-height: 1;
377 background: linear-gradient(135deg, #00ff88 0%, #00e5ff 100%);
378 -webkit-background-clip: text; background-clip: text;
379 -webkit-text-fill-color: transparent; color: transparent;
380 letter-spacing: -0.03em;
381 margin: 0 0 var(--space-2);
382 }
383 .share-hours-label {
384 font-size: clamp(16px, 3vw, 22px);
385 color: var(--text);
386 font-weight: 600;
387 margin: 0 0 var(--space-4);
388 letter-spacing: -0.01em;
389 }
390
391 .share-pills {
392 display: flex; flex-wrap: wrap; justify-content: center;
393 gap: var(--space-2); margin-bottom: var(--space-4);
394 }
395 .share-pill {
396 display: inline-flex; align-items: center; gap: 6px;
397 padding: 6px 14px; border-radius: 9999px;
398 background: rgba(0,255,136,0.08);
399 border: 1px solid rgba(0,255,136,0.22);
400 font-family: var(--font-mono); font-size: 13px; color: #e6edf3;
401 }
402 .share-pill-num { color: #00ff88; font-weight: 700; }
403
404 .share-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3); }
405 .share-btn {
406 display: inline-flex; align-items: center; gap: 8px;
407 padding: 12px 22px; border-radius: 10px; font-size: 14px; font-weight: 600;
408 text-decoration: none; cursor: pointer; border: none; transition: opacity 150ms ease;
409 }
410 .share-btn:hover { opacity: 0.85; }
411 .share-btn-twitter {
412 background: #1d9bf0; color: #fff;
413 }
414 .share-btn-copy {
415 background: rgba(0,255,136,0.12);
416 border: 1px solid rgba(0,255,136,0.30);
417 color: #00ff88;
418 }
419 .share-btn-copy:hover { background: rgba(0,255,136,0.20); }
420
421 .share-preview-wrap {
422 margin-bottom: var(--space-5);
423 border: 1px solid var(--border);
424 border-radius: 14px;
425 overflow: hidden;
426 background: var(--bg-elevated);
427 }
428 .share-preview-head {
429 padding: 12px 20px; border-bottom: 1px solid var(--border);
430 font-size: 12px; color: var(--text-muted); font-weight: 500;
431 display: flex; align-items: center; gap: 8px;
432 }
433 .share-preview-dot { width: 8px; height: 8px; border-radius: 9999px; background: var(--border-strong); }
434 .share-preview-img { display: block; width: 100%; }
435
436 .share-breakdown {
437 margin-bottom: var(--space-5);
438 background: var(--bg-elevated);
439 border: 1px solid var(--border);
440 border-radius: 14px;
441 overflow: hidden;
442 }
443 .share-breakdown-head {
444 padding: 14px 20px; border-bottom: 1px solid var(--border);
445 font-size: 13px; font-weight: 700; color: var(--text-strong);
446 }
447 .share-breakdown-body { padding: 0; }
448 .share-stat-row {
449 display: flex; align-items: center; justify-content: space-between;
450 padding: 14px 20px; border-bottom: 1px solid var(--border-subtle);
451 font-size: 13.5px;
452 }
453 .share-stat-row:last-child { border-bottom: 0; }
454 .share-stat-label { color: var(--text-muted); }
455 .share-stat-val { font-family: var(--font-mono); font-weight: 600; color: var(--text-strong); }
456 .share-stat-hrs { color: #00ff88; margin-left: 8px; font-size: 12px; }
457
458 .share-foot {
459 text-align: center; font-size: 12.5px; color: var(--text-muted);
460 padding-top: var(--space-4); border-top: 1px solid var(--border);
461 }
462 .share-foot a { color: var(--accent); text-decoration: none; }
463 .share-foot a:hover { text-decoration: underline; }
464`;
465
466/**
467 * GET /share/:username
468 * HTML page with OG meta tags + stat display + Twitter share button.
469 */
470share.get("/share/:username", async (c) => {
471 const username = c.req.param("username");
472 const user = c.get("user");
473
474 let targetUser: typeof users.$inferSelect | null = null;
475 let stats: HoursSaved;
476 let isGlobal = false;
477
478 const [found] = await db
479 .select()
480 .from(users)
481 .where(eq(users.username, username))
482 .limit(1);
483
484 if (found) {
485 targetUser = found;
486 stats = await computeHoursForUser(found.id);
487 } else {
488 // Unknown user — show global stats with a note
489 stats = await computeGlobalHours();
490 isGlobal = true;
491 }
492
493 const hoursStr = fmtHours(stats.totalHours);
494 const ogImageUrl = `/share/hours-saved?user=${encodeURIComponent(username)}`;
495
496 const ogTitle = isGlobal
497 ? `The Gluecron community saved ${hoursStr} hours with AI`
498 : `I saved ${hoursStr} hours with Gluecron AI`;
499
500 const ogDesc =
501 "AI review, auto-merge, and spec-to-PR — all automatic. Try Gluecron free.";
502
503 // Pre-filled tweet text
504 const tweetText = isGlobal
505 ? `The @gluecron community has saved ${hoursStr} hours with AI review, auto-merge, and CI healing. This is what AI-native git looks like. gluecron.com/share/${username}`
506 : `I've saved ${hoursStr} hours using @gluecron's AI review, auto-merge, and CI healing. This is what AI-native git looks like. gluecron.com/share/${username}`;
507
508 const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`;
509 const shareUrl = `https://gluecron.com/share/${username}`;
510
511 return c.html(
512 <Layout
513 title={ogTitle + " — Gluecron"}
514 user={user}
515 ogTitle={ogTitle}
516 ogDescription={ogDesc}
517 ogType="website"
518 twitterCard="summary_large_image"
519 >
520 <style dangerouslySetInnerHTML={{ __html: shareStyles }} />
521 {/* og:image injected inline since Layout doesn't support it yet */}
522 <script
523 dangerouslySetInnerHTML={{
524 __html: `
525 (function(){
526 var m = document.createElement('meta');
527 m.setAttribute('property','og:image');
528 m.setAttribute('content','${ogImageUrl}');
529 document.head.appendChild(m);
530 var tw = document.createElement('meta');
531 tw.setAttribute('name','twitter:image');
532 tw.setAttribute('content','${ogImageUrl}');
533 document.head.appendChild(tw);
534 })();
535 `,
536 }}
537 />
538 <div class="share-wrap">
539 {/* Hero card */}
540 <section class="share-hero">
541 <div class="share-orb" aria-hidden="true" />
542 <div class="share-hero-inner">
543 <div class="share-eyebrow">
544 <span class="share-eyebrow-dot" aria-hidden="true" />
545 {isGlobal
546 ? "Platform-wide · all-time · AI impact"
547 : `@${username} · all-time · AI impact`}
548 </div>
549
550 <div class="share-hours-num">{hoursStr}</div>
551 <p class="share-hours-label">
552 {isGlobal
553 ? "hours saved with AI — platform-wide"
554 : "hours saved with AI"}
555 </p>
556
557 <div class="share-pills">
558 <span class="share-pill">
559 <span class="share-pill-num">{stats.aiMergedPrs.toLocaleString()}</span>
560 auto-merged PRs
561 </span>
562 <span class="share-pill">
563 <span class="share-pill-num">{stats.aiReviews.toLocaleString()}</span>
564 AI reviews
565 </span>
566 <span class="share-pill">
567 <span class="share-pill-num">{stats.ciHeals.toLocaleString()}</span>
568 CI heals
569 </span>
570 </div>
571
572 <div class="share-actions">
573 <a
574 href={twitterUrl}
575 target="_blank"
576 rel="noopener noreferrer"
577 class="share-btn share-btn-twitter"
578 >
579 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
580 <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.259 5.63 5.905-5.63zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
581 </svg>
582 Share on X / Twitter
583 </a>
584 <button
585 class="share-btn share-btn-copy"
586 onclick={`navigator.clipboard.writeText('${shareUrl}').then(()=>this.textContent='Copied!').catch(()=>{})`}
587 >
588 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
589 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
590 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
591 </svg>
592 Copy link
593 </button>
594 </div>
595 </div>
596 </section>
597
598 {/* OG image preview */}
599 <div class="share-preview-wrap">
600 <div class="share-preview-head">
601 <span class="share-preview-dot" aria-hidden="true" />
602 <span class="share-preview-dot" aria-hidden="true" />
603 <span class="share-preview-dot" aria-hidden="true" />
604 OG image preview — how this link appears when shared
605 </div>
606 <img
607 src={ogImageUrl}
608 alt={`OG image card: ${hoursStr} hours saved`}
609 class="share-preview-img"
610 loading="lazy"
611 />
612 </div>
613
614 {/* Breakdown table */}
615 <section class="share-breakdown">
616 <div class="share-breakdown-head">How hours are calculated</div>
617 <div class="share-breakdown-body">
618 <div class="share-stat-row">
619 <span class="share-stat-label">AI-merged pull requests</span>
620 <span class="share-stat-val">
621 {stats.aiMergedPrs.toLocaleString()}
622 <span class="share-stat-hrs">× 1.5h = {fmtHours(stats.aiMergedPrs * 1.5)}h</span>
623 </span>
624 </div>
625 <div class="share-stat-row">
626 <span class="share-stat-label">AI code reviews</span>
627 <span class="share-stat-val">
628 {stats.aiReviews.toLocaleString()}
629 <span class="share-stat-hrs">× 0.5h = {fmtHours(stats.aiReviews * 0.5)}h</span>
630 </span>
631 </div>
632 <div class="share-stat-row">
633 <span class="share-stat-label">CI heals (auto-repair)</span>
634 <span class="share-stat-val">
635 {stats.ciHeals.toLocaleString()}
636 <span class="share-stat-hrs">× 0.3h = {fmtHours(stats.ciHeals * 0.3)}h</span>
637 </span>
638 </div>
639 <div class="share-stat-row">
640 <span class="share-stat-label" style="font-weight:700;color:var(--text-strong)">Total hours saved</span>
641 <span class="share-stat-val" style="color:#00ff88;font-size:16px">{hoursStr}h</span>
642 </div>
643 </div>
644 </section>
645
646 <p class="share-foot">
647 {isGlobal ? (
648 <>
649 Platform-wide stats. Want your personal card?{" "}
650 <a href="/register">Sign up free</a> and visit{" "}
651 <a href="/share/{your-username}">/share/{"{your-username}"}</a>.
652 </>
653 ) : (
654 <>
655 Share your stats · <a href="/billing/usage">View AI usage dashboard</a> ·{" "}
656 <a href="/">Back to Gluecron</a>
657 </>
658 )}
659 </p>
660 </div>
661 </Layout>
662 );
663});
664
665export default share;
Modifiedsrc/routes/specs.tsx+593−21View fileUnifiedSplit
22 * Spec-to-PR — paste a plain-English feature spec, get back a draft PR
33 * generated by the Claude API.
44 *
5 * GET /:owner/:repo/spec — form (requires write access)
6 * POST /:owner/:repo/spec — hands off to lib/spec-to-pr.ts, redirects to
7 * the new PR on success, re-renders the form
8 * with an error banner on failure
5 * GET /:owner/:repo/spec — form (requires write access)
6 * POST /:owner/:repo/spec — kicks off background job,
7 * redirects to progress page
8 * GET /:owner/:repo/spec/:jobId/progress — live progress page (SSE)
9 * GET /:owner/:repo/spec/:jobId/progress/events — SSE event stream
10 * GET /:owner/:repo/spec/:jobId/status — JSON status (polling fallback)
911 *
1012 * The backend (`createSpecPR` in `src/lib/spec-to-pr.ts`) is being built in
1113 * parallel. We import it dynamically so this file compiles and its tests
3133 parseFrontMatter,
3234 type SpecStatus,
3335} from "../lib/spec-to-pr";
36import { publish, subscribe } from "../lib/sse";
37
38// ---------------------------------------------------------------------------
39// In-memory spec job registry
40// ---------------------------------------------------------------------------
41// Each background spec run is tracked here from the moment the POST fires
42// until it completes (or the server restarts). The Map is keyed by a short
43// random job ID that lives in the redirect URL.
44//
45// Eviction: jobs are kept for 10 minutes after completion so late-arriving
46// SSE clients still get a "done" snapshot. The autopilot-ish ticker below
47// sweeps stale entries.
48
49export type SpecStage =
50 | "queued"
51 | "analyzing"
52 | "writing"
53 | "opening_pr"
54 | "done"
55 | "error";
56
57export interface SpecJob {
58 id: string;
59 owner: string;
60 repo: string;
61 stage: SpecStage;
62 label: string; // human-readable stage label
63 prNumber: number | null;
64 error: string | null;
65 startedAt: number; // Date.now()
66 completedAt: number | null;
67}
68
69const specJobs = new Map<string, SpecJob>();
70
71// Sweep completed jobs older than 10 minutes.
72setInterval(() => {
73 const cutoff = Date.now() - 10 * 60 * 1000;
74 for (const [id, job] of specJobs) {
75 if (job.completedAt !== null && job.completedAt < cutoff) {
76 specJobs.delete(id);
77 }
78 }
79}, 60_000);
80
81function specJobTopic(jobId: string): string {
82 return `spec-job:${jobId}`;
83}
84
85/** Advance a job to a new stage and publish the SSE event. */
86function advanceStage(job: SpecJob, stage: SpecStage, label: string): void {
87 job.stage = stage;
88 job.label = label;
89 publish(specJobTopic(job.id), {
90 event: "stage",
91 data: { stage, label, prNumber: job.prNumber, error: job.error },
92 });
93}
94
95const STAGE_LABELS: Record<SpecStage, string> = {
96 queued: "Queued",
97 analyzing: "Analyzing spec",
98 writing: "Writing code",
99 opening_pr: "Opening PR",
100 done: "Done",
101 error: "Error",
102};
103
104/**
105 * Run the spec-to-PR pipeline in the background, publishing SSE events at
106 * each stage transition. Accepts the same arguments as `createSpecPR`.
107 */
108async function runSpecJobInBackground(
109 job: SpecJob,
110 args: { repoId: string; spec: string; baseRef: string; userId: string }
111): Promise<void> {
112 // Stage 1 — analyzing spec / building context
113 advanceStage(job, "analyzing", STAGE_LABELS.analyzing);
114
115 // Dynamically import the backend (same guard as the POST handler).
116 let createSpecPR:
117 | ((args: {
118 repoId: string;
119 spec: string;
120 baseRef: string;
121 userId: string;
122 }) => Promise<
123 | { ok: true; prNumber: number }
124 | { ok: false; error: string }
125 >)
126 | null = null;
127 try {
128 const mod: any = await import("../lib/spec-to-pr");
129 createSpecPR =
130 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
131 null;
132 } catch {
133 createSpecPR = null;
134 }
135
136 if (!createSpecPR) {
137 job.error = "Backend not available — spec-to-PR is not deployed yet.";
138 job.completedAt = Date.now();
139 advanceStage(job, "error", STAGE_LABELS.error);
140 return;
141 }
142
143 // Stage 2 — writing code (AI generating). We publish this immediately
144 // before the Claude call so the UI shows "Writing code" while we wait.
145 advanceStage(job, "writing", STAGE_LABELS.writing);
146
147 let result: { ok: true; prNumber: number } | { ok: false; error: string };
148 try {
149 result = await createSpecPR(args);
150 } catch (err) {
151 result = {
152 ok: false,
153 error: err instanceof Error ? err.message : "Unexpected error",
154 };
155 }
156
157 if (!result.ok) {
158 job.error = result.error;
159 job.completedAt = Date.now();
160 advanceStage(job, "error", STAGE_LABELS.error);
161 return;
162 }
163
164 // Stage 3 — opening PR (the insert already happened inside createSpecPR,
165 // but we surface this stage as a brief acknowledgement).
166 advanceStage(job, "opening_pr", STAGE_LABELS.opening_pr);
167 job.prNumber = result.prNumber;
168
169 // Stage 4 — done.
170 job.completedAt = Date.now();
171 advanceStage(job, "done", STAGE_LABELS.done);
172}
34173
35174const specs = new Hono<AuthEnv>();
36175
806945 );
807946 }
808947
809 let result:
810 | { ok: true; prNumber: number }
811 | { ok: false; error: string };
812 try {
813 result = await createSpecPR({
814 repoId: resolved.repoId,
815 spec,
816 baseRef,
817 userId: user.id,
948 // Kick off the spec job in the background and redirect to the live
949 // progress page immediately. This means the user sees a live timeline
950 // rather than staring at a browser spinner for 10-30s.
951 const jobId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
952 const job: SpecJob = {
953 id: jobId,
954 owner,
955 repo,
956 stage: "queued",
957 label: STAGE_LABELS.queued,
958 prNumber: null,
959 error: null,
960 startedAt: Date.now(),
961 completedAt: null,
962 };
963 specJobs.set(jobId, job);
964
965 // Fire and forget — never await so the response is immediate.
966 void runSpecJobInBackground(job, {
967 repoId: resolved.repoId,
968 spec,
969 baseRef,
970 userId: user.id,
971 });
972
973 return c.redirect(`/${owner}/${repo}/spec/${jobId}/progress`);
974});
975
976// ─── Spec progress routes ───────────────────────────────────────────────────
977//
978// GET /:owner/:repo/spec/:jobId/progress — HTML progress page
979// GET /:owner/:repo/spec/:jobId/progress/events — SSE stream
980// GET /:owner/:repo/spec/:jobId/status — JSON polling endpoint
981
982const progressStyles = `
983 .sp-wrap {
984 max-width: 640px;
985 margin: 0 auto;
986 padding: var(--space-8) var(--space-4);
987 }
988 .sp-head {
989 margin-bottom: var(--space-6);
990 text-align: center;
991 }
992 .sp-eyebrow {
993 display: inline-flex;
994 align-items: center;
995 gap: 8px;
996 text-transform: uppercase;
997 font-family: var(--font-mono);
998 font-size: 11px;
999 letter-spacing: 0.16em;
1000 color: var(--text-muted);
1001 font-weight: 600;
1002 margin-bottom: 10px;
1003 }
1004 .sp-title {
1005 font-family: var(--font-display);
1006 font-size: clamp(22px, 3vw, 30px);
1007 font-weight: 800;
1008 letter-spacing: -0.024em;
1009 color: var(--text-strong);
1010 margin: 0 0 8px;
1011 }
1012 .sp-sub {
1013 font-size: 13px;
1014 color: var(--text-muted);
1015 margin: 0;
1016 }
1017 /* ── Timeline ── */
1018 .sp-timeline {
1019 position: relative;
1020 padding: 0;
1021 list-style: none;
1022 margin: 0;
1023 }
1024 .sp-timeline::before {
1025 content: '';
1026 position: absolute;
1027 left: 20px;
1028 top: 12px;
1029 bottom: 12px;
1030 width: 1px;
1031 background: var(--border);
1032 }
1033 .sp-step {
1034 position: relative;
1035 display: flex;
1036 align-items: flex-start;
1037 gap: 16px;
1038 padding: 12px 0;
1039 }
1040 .sp-dot {
1041 flex-shrink: 0;
1042 width: 40px;
1043 height: 40px;
1044 border-radius: 50%;
1045 display: flex;
1046 align-items: center;
1047 justify-content: center;
1048 font-size: 16px;
1049 background: var(--bg-elevated);
1050 border: 2px solid var(--border);
1051 position: relative;
1052 z-index: 1;
1053 transition: border-color 300ms ease, background 300ms ease;
1054 }
1055 .sp-dot.is-done {
1056 border-color: #22c55e;
1057 background: rgba(34,197,94,0.12);
1058 }
1059 .sp-dot.is-active {
1060 border-color: #8c6dff;
1061 background: rgba(140,109,255,0.14);
1062 animation: sp-pulse 1.6s ease-in-out infinite;
1063 }
1064 .sp-dot.is-error {
1065 border-color: #f87171;
1066 background: rgba(248,113,113,0.12);
1067 }
1068 @keyframes sp-pulse {
1069 0%, 100% { box-shadow: 0 0 0 0 rgba(140,109,255,0.35); }
1070 50% { box-shadow: 0 0 0 8px rgba(140,109,255,0); }
1071 }
1072 .sp-step-body {
1073 padding-top: 8px;
1074 }
1075 .sp-step-label {
1076 font-size: 14px;
1077 font-weight: 600;
1078 color: var(--text-muted);
1079 transition: color 300ms ease;
1080 }
1081 .sp-step-label.is-done { color: #22c55e; }
1082 .sp-step-label.is-active { color: var(--text-strong); }
1083 .sp-step-label.is-error { color: #f87171; }
1084 .sp-step-detail {
1085 font-size: 12px;
1086 color: var(--text-muted);
1087 margin-top: 2px;
1088 font-family: var(--font-mono);
1089 }
1090 /* ── Done card ── */
1091 .sp-done-card {
1092 margin-top: var(--space-5);
1093 padding: 20px 24px;
1094 background: rgba(34,197,94,0.08);
1095 border: 1px solid rgba(34,197,94,0.30);
1096 border-radius: 14px;
1097 text-align: center;
1098 display: none;
1099 }
1100 .sp-done-card.is-visible { display: block; }
1101 .sp-done-card h2 {
1102 font-family: var(--font-display);
1103 font-size: 18px;
1104 font-weight: 700;
1105 color: #22c55e;
1106 margin: 0 0 8px;
1107 }
1108 .sp-done-card p {
1109 font-size: 13px;
1110 color: var(--text-muted);
1111 margin: 0 0 14px;
1112 }
1113 .sp-done-link {
1114 display: inline-flex;
1115 align-items: center;
1116 gap: 6px;
1117 padding: 10px 20px;
1118 background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
1119 color: #fff;
1120 border-radius: 10px;
1121 font-size: 13.5px;
1122 font-weight: 600;
1123 text-decoration: none;
1124 }
1125 .sp-done-link:hover { opacity: 0.92; text-decoration: none; }
1126 /* ── Error card ── */
1127 .sp-error-card {
1128 margin-top: var(--space-5);
1129 padding: 16px 20px;
1130 background: rgba(248,113,113,0.08);
1131 border: 1px solid rgba(248,113,113,0.30);
1132 border-radius: 14px;
1133 display: none;
1134 }
1135 .sp-error-card.is-visible { display: block; }
1136 .sp-error-card p {
1137 font-size: 13px;
1138 color: #fca5a5;
1139 margin: 0 0 10px;
1140 }
1141 .sp-error-card a {
1142 font-size: 13px;
1143 color: #f87171;
1144 text-decoration: underline;
1145 }
1146`;
1147
1148/** Inline JS for the progress page — polls /status every 2s. ~18 lines. */
1149const PROGRESS_POLL_JS = `
1150(function() {
1151 var stages = ['queued','analyzing','writing','opening_pr','done','error'];
1152 var url = location.pathname.replace(/\\/progress$/, '/status');
1153 function update(d) {
1154 stages.forEach(function(s) {
1155 var dot = document.getElementById('dot-' + s);
1156 var lbl = document.getElementById('lbl-' + s);
1157 if (!dot || !lbl) return;
1158 dot.className = 'sp-dot';
1159 lbl.className = 'sp-step-label';
1160 var si = stages.indexOf(s), di = stages.indexOf(d.stage);
1161 if (si < di) { dot.className += ' is-done'; lbl.className += ' is-done'; }
1162 else if (s === d.stage && s !== 'error') { dot.className += ' is-active'; lbl.className += ' is-active'; }
1163 else if (s === 'error' && d.stage === 'error') { dot.className += ' is-error'; lbl.className += ' is-error'; }
8181164 });
819 } catch (err) {
820 const msg =
821 err instanceof Error ? err.message : "Unexpected error generating PR.";
822 return renderWithError(`Failed to generate PR: ${msg}`, 500);
1165 if (d.stage === 'done' && d.prNumber) {
1166 var c = document.getElementById('sp-done-card');
1167 var lk = document.getElementById('sp-pr-link');
1168 if (c) c.className = 'sp-done-card is-visible';
1169 if (lk) lk.href = lk.dataset.base + '/' + d.prNumber;
1170 }
1171 if (d.stage === 'error') {
1172 var ec = document.getElementById('sp-error-card');
1173 var em = document.getElementById('sp-error-msg');
1174 if (ec) ec.className = 'sp-error-card is-visible';
1175 if (em) em.textContent = d.error || 'Unknown error.';
1176 }
1177 if (d.stage !== 'done' && d.stage !== 'error') { setTimeout(poll, 2000); }
1178 }
1179 function poll() {
1180 fetch(url).then(function(r){ return r.json(); }).then(update).catch(function(){ setTimeout(poll, 3000); });
1181 }
1182 poll();
1183})();
1184`;
1185
1186/** The 4 pipeline steps shown on the progress page (excluding queued). */
1187const PIPELINE_STEPS: Array<{ stage: SpecStage; label: string; icon: string }> = [
1188 { stage: "analyzing", label: "Analyzing spec", icon: "🔍" },
1189 { stage: "writing", label: "Writing code", icon: "✍️" },
1190 { stage: "opening_pr", label: "Opening PR", icon: "📬" },
1191 { stage: "done", label: "Done", icon: "✅" },
1192];
1193
1194function ProgressPage({
1195 owner,
1196 repo,
1197 job,
1198}: {
1199 owner: string;
1200 repo: string;
1201 jobId: string;
1202 job: SpecJob;
1203}) {
1204 const stageIdx = (s: SpecStage) => {
1205 const order: SpecStage[] = ["queued", "analyzing", "writing", "opening_pr", "done", "error"];
1206 return order.indexOf(s);
1207 };
1208 const curIdx = stageIdx(job.stage);
1209
1210 return (
1211 <div class="sp-wrap">
1212 <header class="sp-head">
1213 <div class="sp-eyebrow">Spec to PR · Live progress</div>
1214 <h1 class="sp-title">Building your PR…</h1>
1215 <p class="sp-sub">Sit tight — Claude is generating the implementation.</p>
1216 </header>
1217
1218 <ul class="sp-timeline">
1219 {PIPELINE_STEPS.map(({ stage, label, icon }) => {
1220 const sIdx = stageIdx(stage);
1221 const isDone = curIdx > sIdx || (job.stage === "done" && stage === "done");
1222 const isActive = job.stage === stage;
1223 const isError = job.stage === "error" && stage === "writing"; // show error on writing step
1224 let dotClass = "sp-dot";
1225 let lblClass = "sp-step-label";
1226 if (isDone) { dotClass += " is-done"; lblClass += " is-done"; }
1227 else if (isActive) { dotClass += " is-active"; lblClass += " is-active"; }
1228 else if (isError) { dotClass += " is-error"; lblClass += " is-error"; }
1229
1230 return (
1231 <li class="sp-step">
1232 <div id={`dot-${stage}`} class={dotClass}>{icon}</div>
1233 <div class="sp-step-body">
1234 <div id={`lbl-${stage}`} class={lblClass}>{label}</div>
1235 </div>
1236 </li>
1237 );
1238 })}
1239 {/* always show the error stage dot so JS can target it */}
1240 <li class="sp-step" id="step-error" style={job.stage === "error" ? "" : "display:none"}>
1241 <div id="dot-error" class={`sp-dot${job.stage === "error" ? " is-error" : ""}`}></div>
1242 <div class="sp-step-body">
1243 <div id="lbl-error" class={`sp-step-label${job.stage === "error" ? " is-error" : ""}`}>Error</div>
1244 </div>
1245 </li>
1246 </ul>
1247
1248 {/* Done card — hidden until JS reveals it */}
1249 <div
1250 id="sp-done-card"
1251 class={`sp-done-card${job.stage === "done" ? " is-visible" : ""}`}
1252 >
1253 <h2>PR opened!</h2>
1254 {job.completedAt && job.startedAt && (
1255 <p>
1256 Merged in {Math.round((job.completedAt - job.startedAt) / 1000)}s
1257 </p>
1258 )}
1259 <a
1260 id="sp-pr-link"
1261 class="sp-done-link"
1262 href={job.prNumber ? `/${owner}/${repo}/pulls/${job.prNumber}` : "#"}
1263 data-base={`/${owner}/${repo}/pulls`}
1264 >
1265 View PR {job.prNumber ? `#${job.prNumber}` : ""}
1266 </a>
1267 </div>
1268
1269 {/* Error card */}
1270 <div
1271 id="sp-error-card"
1272 class={`sp-error-card${job.stage === "error" ? " is-visible" : ""}`}
1273 >
1274 <p id="sp-error-msg">{job.error || ""}</p>
1275 <a href={`/${owner}/${repo}/spec`}>Try again</a>
1276 </div>
1277
1278 <script dangerouslySetInnerHTML={{ __html: PROGRESS_POLL_JS }} />
1279 <style dangerouslySetInnerHTML={{ __html: progressStyles }} />
1280 </div>
1281 );
1282}
1283
1284// GET /:owner/:repo/spec/:jobId/progress — HTML progress page
1285specs.get("/:owner/:repo/spec/:jobId/progress", softAuth, requireAuth, async (c) => {
1286 const { owner, repo, jobId } = c.req.param();
1287 const user = c.get("user")!;
1288
1289 const resolved = await resolveRepo(owner, repo);
1290 if (!resolved) return c.notFound();
1291 if (!hasWriteAccess(resolved, user.id)) return c.text("Forbidden", 403);
1292
1293 const job = specJobs.get(jobId);
1294 if (!job) {
1295 // Job expired or unknown — redirect to spec form.
1296 return c.redirect(`/${owner}/${repo}/spec`);
8231297 }
8241298
825 if (!result || !result.ok) {
826 const msg = (result && "error" in result && result.error) || "Unknown error.";
827 return renderWithError(`Failed to generate PR: ${msg}`);
1299 // If already done, redirect straight to the PR.
1300 if (job.stage === "done" && job.prNumber) {
1301 return c.redirect(`/${owner}/${repo}/pulls/${job.prNumber}`);
8281302 }
8291303
830 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
1304 return c.html(
1305 <Layout title={`Generating PR — ${owner}/${repo}`} user={user}>
1306 <RepoHeader owner={owner} repo={repo} />
1307 <ProgressPage owner={owner} repo={repo} jobId={jobId} job={job} />
1308 </Layout>
1309 );
1310});
1311
1312// GET /:owner/:repo/spec/:jobId/progress/events — SSE stream
1313specs.get("/:owner/:repo/spec/:jobId/progress/events", softAuth, requireAuth, async (c) => {
1314 const { owner, repo, jobId } = c.req.param();
1315 const user = c.get("user")!;
1316
1317 const resolved = await resolveRepo(owner, repo);
1318 if (!resolved) return c.json({ error: "Not found" }, 404);
1319 if (!hasWriteAccess(resolved, user.id)) return c.json({ error: "Forbidden" }, 403);
1320
1321 const job = specJobs.get(jobId);
1322 if (!job) return c.json({ error: "Job not found" }, 404);
1323
1324 const topic = specJobTopic(jobId);
1325 const encoder = new TextEncoder();
1326
1327 const stream = new ReadableStream<Uint8Array>({
1328 start(controller) {
1329 let closed = false;
1330 const safeEnqueue = (chunk: string) => {
1331 if (closed) return;
1332 try { controller.enqueue(encoder.encode(chunk)); } catch { closed = true; }
1333 };
1334
1335 safeEnqueue(": open\n\n");
1336
1337 // Send current state immediately so late connectors get a snapshot.
1338 const snapJob = specJobs.get(jobId);
1339 if (snapJob) {
1340 const payload = JSON.stringify({ stage: snapJob.stage, label: snapJob.label, prNumber: snapJob.prNumber, error: snapJob.error });
1341 safeEnqueue(`event: stage\ndata: ${payload}\n\n`);
1342 }
1343
1344 const unsubscribe = subscribe(topic, (ev) => {
1345 let out = "";
1346 if (ev.event) out += `event: ${ev.event}\n`;
1347 const data = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
1348 for (const line of data.split("\n")) out += `data: ${line}\n`;
1349 out += "\n";
1350 safeEnqueue(out);
1351 });
1352
1353 const ping = setInterval(() => safeEnqueue(": ping\n\n"), 25_000);
1354
1355 const cleanup = () => {
1356 if (closed) return;
1357 closed = true;
1358 clearInterval(ping);
1359 unsubscribe();
1360 try { controller.close(); } catch { /* already closed */ }
1361 };
1362
1363 const signal = c.req.raw.signal;
1364 if (signal) {
1365 if (signal.aborted) cleanup();
1366 else signal.addEventListener("abort", cleanup, { once: true });
1367 }
1368 },
1369 });
1370
1371 return new Response(stream, {
1372 status: 200,
1373 headers: {
1374 "Content-Type": "text/event-stream; charset=utf-8",
1375 "Cache-Control": "no-cache, no-transform",
1376 "Connection": "keep-alive",
1377 "X-Accel-Buffering": "no",
1378 },
1379 });
1380});
1381
1382// GET /:owner/:repo/spec/:jobId/status — JSON polling endpoint
1383specs.get("/:owner/:repo/spec/:jobId/status", softAuth, requireAuth, async (c) => {
1384 const { owner, repo, jobId } = c.req.param();
1385 const user = c.get("user")!;
1386
1387 const resolved = await resolveRepo(owner, repo);
1388 if (!resolved) return c.json({ error: "Not found" }, 404);
1389 if (!hasWriteAccess(resolved, user.id)) return c.json({ error: "Forbidden" }, 403);
1390
1391 const job = specJobs.get(jobId);
1392 if (!job) return c.json({ error: "Job not found or expired" }, 404);
1393
1394 return c.json({
1395 jobId: job.id,
1396 stage: job.stage,
1397 label: job.label,
1398 prNumber: job.prNumber,
1399 error: job.error,
1400 startedAt: job.startedAt,
1401 completedAt: job.completedAt,
1402 });
8311403});
8321404
8331405// ─── GET /specs — dashboard listing all specs across the user's repos ──────
Modifiedsrc/routes/status.tsx+696−140View fileUnifiedSplit
Large file (1,033 lines). Load full file
Modifiedsrc/routes/vs-github.tsx+29−20View fileUnifiedSplit
6060 {
6161 feature: "AI code review on every PR",
6262 gh: { verdict: "partial", note: "via Copilot subscription ($10/u)" },
63 gc: { verdict: "yes", note: "Built-in (Sonnet 4)", href: "/demo" },
63 gc: { verdict: "yes", note: "AI review fires the moment PR opens (~8 seconds)", href: "/demo" },
6464 },
6565 {
6666 feature: "AI auto-merge when checks pass",
6767 gh: { verdict: "no", note: "Not available" },
68 gc: { verdict: "yes", note: "Per-branch opt-in", href: "/sleep-mode" },
68 gc: { verdict: "yes", note: "Auto-merge triggers the instant gates pass — no click needed", href: "/sleep-mode" },
6969 },
7070 {
7171 feature: "AI explain-this-codebase",
7272 gh: { verdict: "partial", note: "via Copilot Chat" },
73 gc: { verdict: "yes", note: "Cached per commit" },
73 gc: { verdict: "yes", note: "Cached per commit — answers in under 2 seconds" },
7474 },
7575 {
7676 feature: "AI changelog per commit range",
7777 gh: { verdict: "no", note: "Not available" },
78 gc: { verdict: "yes", note: "Built-in" },
78 gc: { verdict: "yes", note: "Generated on demand — ready before you tab back" },
7979 },
8080 {
8181 feature: "AI incident responder",
8282 gh: { verdict: "no", note: "Not available" },
83 gc: { verdict: "yes", note: "Auto-issue on deploy fail" },
83 gc: { verdict: "yes", note: "Issue opened within seconds of deploy failure" },
8484 },
8585 {
8686 feature: "AI dependency updater",
8787 gh: { verdict: "partial", note: "via Dependabot ($25/mo)" },
88 gc: { verdict: "yes", note: "Claude-reasoned bumps" },
88 gc: { verdict: "yes", note: "Claude-reasoned bumps — PR open in under 60 seconds" },
8989 },
9090 {
9191 feature: "AI security scan on every push",
9292 gh: { verdict: "partial", note: "via CodeQL (enterprise)" },
93 gc: { verdict: "yes", note: "Sonnet 4 + 15 patterns" },
93 gc: { verdict: "yes", note: "Sonnet 4 + 15 patterns — result before push completes" },
9494 },
9595 {
96 feature: "AI Sleep Mode (overnight digest)",
96 feature: "AI Sleep Mode (async batch digest)",
9797 gh: { verdict: "no", note: "Not available" },
98 gc: { verdict: "yes", note: "Toggle, walk away", href: "/sleep-mode" },
98 gc: { verdict: "yes", note: "Real-time by default; batch it for async users if you prefer", href: "/sleep-mode" },
9999 },
100100 {
101101 feature: "AI commit messages",
102102 gh: { verdict: "partial", note: "via Copilot CLI (separate)" },
103 gc: { verdict: "yes", note: "Native git hook" },
103 gc: { verdict: "yes", note: "Native git hook — message ready at commit time" },
104104 },
105105 {
106106 feature: "Label-an-issue → AI builds it",
107107 gh: { verdict: "no", note: "Not available" },
108 gc: { verdict: "yes", note: "ai:build label autopilot" },
108 gc: { verdict: "yes", note: "ai:build label → draft PR in 90 seconds" },
109109 },
110110 ],
111111 },
281281 {
282282 icon: "spec",
283283 title: "Spec-to-PR autopilot",
284 body: "Drop a markdown spec in .gluecron/specs/. Claude reads it, opens a draft PR with the implementation, requests review. Zero clicks between idea and diff.",
284 body: "Drop a markdown spec in .gluecron/specs/. Claude reads it, opens a draft PR with the implementation, requests review — all within 90 seconds. Zero clicks between idea and diff.",
285285 href: "/specs",
286286 cta: "See it run",
287287 },
288288 {
289289 icon: "voice",
290290 title: "Voice-to-PR (mobile)",
291 body: "Talk into your phone. MediaRecorder ships the audio to Claude, Claude opens a PR. Diff on your watch by the time you're at the coffee shop.",
291 body: "Talk into your phone. MediaRecorder ships the audio to Claude, Claude opens a PR. Diff is live in seconds — while you're still holding the phone.",
292292 href: "/voice",
293293 cta: "Try voice mode",
294294 },
678678vsGithub.get("/vs-github", (c) => {
679679 const user = c.get("user");
680680 return c.html(
681 <Layout title="Gluecron vs GitHub — 16 years vs one weekend" user={user}>
681 <Layout
682 title="Gluecron vs GitHub — 16 years vs one weekend"
683 user={user}
684 description="How Gluecron outperforms GitHub on every AI metric. AI review in ~8s. Auto-merge on gate pass. Spec to PR in 90 seconds."
685 ogTitle="Gluecron vs GitHub — 16 years vs one weekend"
686 ogDescription="How Gluecron outperforms GitHub on every AI metric. AI review in ~8s. Auto-merge on gate pass. Spec to PR in 90 seconds."
687 twitterCard="summary_large_image"
688 >
682689 <style dangerouslySetInnerHTML={{ __html: pageCss }} />
683690 <div class="vsg-page vsg-root">
684691 {/* ============ 1. SHOCKING HERO ============ */}
702709 </h1>
703710 <p class="vsg-hero-sub">
704711 Gluecron ships in one batch what Microsoft ships in 18 months.
705 The git host built around Claude. The git host built around Claude.
712 The git host built around Claude — where AI acts in seconds, not overnight.
706713 </p>
707714
708715 <div class="vsg-hero-cta">
11811188 The killer move
11821189 </div>
11831190 <h2 class="vsg-killer-headline">
1184 Toggle Sleep Mode, walk away,{" "}
1185 <span class="vsg-title-grad">wake up to a digest.</span>
1191 Claude ships code{" "}
1192 <span class="vsg-title-grad">while you watch — or while you sleep.</span>
11861193 </h2>
11871194 <p class="vsg-killer-sub">
1188 GitHub: not possible. Gluecron: built-in. While you sleep,
1189 Claude auto-merges green PRs, builds features from{" "}
1190 <code>ai:build</code> issues, and patches the gates that fail.
1195 GitHub: not possible. Gluecron: built-in. Auto-merges fire the
1196 instant gates go green, <code>ai:build</code> issues become PRs
1197 in 90 seconds, and failing gates get patched in real time.
1198 Toggle Sleep Mode if you'd rather batch it and catch up async —
1199 but the speed is always there.
11911200 </p>
11921201 <a href="/sleep-mode" class="btn btn-secondary btn-lg">
11931202 See how Sleep Mode works &rarr;
Modifiedsrc/routes/web.tsx+655−59View fileUnifiedSplit
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc, inArray, sql } from "drizzle-orm";
8import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
99import { db } from "../db";
1010import { config } from "../lib/config";
1111import {
1313 repositories,
1414 stars,
1515 commitVerifications,
16 activityFeed,
17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
1622} from "../db/schema";
1723import { Layout } from "../views/layout";
1824import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
2531 BranchSwitcher,
2632 HighlightedCode,
2733 PlainCode,
34 type RecentPush,
2835} from "../views/components";
2936import { DiffView } from "../views/diff-view";
3037import {
4754} from "../git/repository";
4855import { renderMarkdown, markdownCss } from "../lib/markdown";
4956import { highlightCode } from "../lib/highlight";
57import { computeHealthScore } from "../lib/health-score";
58import type { HealthScore } from "../lib/health-score";
5059import { softAuth, requireAuth } from "../middleware/auth";
5160import type { AuthEnv } from "../middleware/auth";
5261import { trackByName } from "../lib/traffic";
6675// Soft auth on all web routes — c.get("user") available but may be null
6776web.use("*", softAuth);
6877
78// ---------------------------------------------------------------------------
79// Repo AI stats — computed once per repo home page load, best-effort.
80// Fast because every WHERE clause is bounded to a 7-day window.
81// ---------------------------------------------------------------------------
82interface RepoAiStats {
83 mergedCount: number;
84 reviewCount: number;
85 securityAlertCount: number;
86 hoursSaved: string;
87}
88
89async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
90 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
91
92 // 1. AI-merged PRs this week: activity_feed entries with action =
93 // 'auto_merge.merged' in the last 7 days for this repo.
94 // This is cheaper than a JOIN on pull_requests and covers both the
95 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
96 const [mergedRow] = await db
97 .select({ n: count() })
98 .from(activityFeed)
99 .where(
100 and(
101 eq(activityFeed.repositoryId, repoId),
102 eq(activityFeed.action, "auto_merge.merged"),
103 gte(activityFeed.createdAt, since)
104 )
105 );
106 const mergedCount = Number(mergedRow?.n ?? 0);
107
108 // 2. AI reviews this week: pr_comments where is_ai_review = true in
109 // the last 7 days, joined through pull_requests to scope to this repo.
110 const [reviewRow] = await db
111 .select({ n: count() })
112 .from(prComments)
113 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
114 .where(
115 and(
116 eq(pullRequests.repositoryId, repoId),
117 eq(prComments.isAiReview, true),
118 gte(prComments.createdAt, since)
119 )
120 );
121 const reviewCount = Number(reviewRow?.n ?? 0);
122
123 // 3. Open security alerts: open issues that carry a label whose name
124 // contains 'security' (case-insensitive) for this repo.
125 const [secRow] = await db
126 .select({ n: count() })
127 .from(issues)
128 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
129 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
130 .where(
131 and(
132 eq(issues.repositoryId, repoId),
133 eq(issues.state, "open"),
134 sql`lower(${labels.name}) like '%security%'`
135 )
136 );
137 const securityAlertCount = Number(secRow?.n ?? 0);
138
139 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
140 const hours = mergedCount * 1.5 + reviewCount * 0.5;
141 const hoursSaved = hours.toFixed(1);
142
143 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
144}
145
146/**
147 * Query the most recent push to a repo from the activity_feed table.
148 * Returns a RecentPush if there's been a push in the last 24 hours,
149 * or null otherwise. Used by the RepoHeader live/watch indicator.
150 *
151 * Queries activity_feed where action='push' and targetId holds the commit SHA.
152 */
153async function getRecentPush(repoId: string): Promise<RecentPush | null> {
154 try {
155 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
156 const [row] = await db
157 .select({
158 targetId: activityFeed.targetId,
159 createdAt: activityFeed.createdAt,
160 })
161 .from(activityFeed)
162 .where(
163 and(
164 eq(activityFeed.repositoryId, repoId),
165 eq(activityFeed.action, "push"),
166 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
167 )
168 )
169 .orderBy(desc(activityFeed.createdAt))
170 .limit(1);
171 if (!row || !row.targetId) return null;
172 return {
173 sha: row.targetId,
174 ageMs: Date.now() - new Date(row.createdAt).getTime(),
175 };
176 } catch {
177 // DB unavailable or schema mismatch — degrade gracefully.
178 return null;
179 }
180}
181
69182/**
70183 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
71184 *
879992 background: rgba(255,255,255,0.04);
880993 }
881994 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
995 /* Push Watch link — eye icon next to the SHA chip */
996 .commits-row-watch {
997 display: inline-flex;
998 align-items: center;
999 justify-content: center;
1000 width: 28px;
1001 height: 28px;
1002 border-radius: 6px;
1003 border: 1px solid var(--border);
1004 color: var(--text-muted);
1005 background: transparent;
1006 cursor: pointer;
1007 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1008 text-decoration: none !important;
1009 }
1010 .commits-row-watch:hover {
1011 border-color: rgba(140,109,255,0.45);
1012 color: var(--accent);
1013 background: rgba(140,109,255,0.08);
1014 text-decoration: none !important;
1015 }
8821016 .commits-empty {
8831017 position: relative;
8841018 overflow: hidden;
18682002 Just a UI hint — push your own commits to fill the repo.
18692003 </p>
18702004 </div>
2005 <div class="new-repo-row">
2006 <label class="new-repo-label" for="data_region">
2007 Data region
2008 </label>
2009 <select
2010 id="data_region"
2011 name="data_region"
2012 class="new-repo-input"
2013 style="cursor: pointer;"
2014 >
2015 <option value="us" selected>US (default)</option>
2016 <option value="eu">EU (Frankfurt)</option>
2017 </select>
2018 <p class="new-repo-hint">
2019 EU data residency requires a{" "}
2020 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2021 Pro plan or higher
2022 </a>
2023 . Repositories cannot be moved between regions after creation.
2024 </p>
2025 </div>
18712026 <div class="new-repo-callout">
18722027 <div class="new-repo-callout-eyebrow">AI-native by default</div>
18732028 <p class="new-repo-callout-body">
18962051 const name = String(body.name || "").trim();
18972052 const description = String(body.description || "").trim();
18982053 const isPrivate = body.visibility === "private";
2054 const dataRegion = body.data_region === "eu" ? "eu" : "us";
18992055
19002056 if (!name) {
19012057 return c.redirect("/new?error=Repository+name+is+required");
19272083 description: description || null,
19282084 isPrivate,
19292085 diskPath,
2086 dataRegion,
19302087 })
19312088 .returning();
19322089
23802537 repoOwnerId,
23812538 } = starInfo;
23822539
2540 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2541 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2542 // and the badge simply doesn't render.
2543 let healthScore: HealthScore | null = null;
2544 if (repoId) {
2545 try {
2546 healthScore = await computeHealthScore(repoId);
2547 } catch {
2548 // swallow — badge is optional
2549 }
2550 }
2551
23832552 // Pending-comments banner data (lazy + best-effort). Only the repo
23842553 // owner sees the banner, so non-owner views skip the DB hit entirely.
23852554 let repoHomePendingCount = 0;
23942563 }
23952564 }
23962565
2566 // Push Watch discoverability — fetch the most recent push within 24 h.
2567 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2568 const recentPush: RecentPush | null = repoId
2569 ? await getRecentPush(repoId)
2570 : null;
2571
2572 // AI stats strip — best-effort, fail-open to zero values.
2573 let aiStats: RepoAiStats = {
2574 mergedCount: 0,
2575 reviewCount: 0,
2576 securityAlertCount: 0,
2577 hoursSaved: "0.0",
2578 };
2579 if (repoId) {
2580 try {
2581 aiStats = await getRepoAiStats(repoId);
2582 } catch {
2583 /* swallow — show zero values */
2584 }
2585 }
2586
23972587 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
23982588 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
23992589 const repoHomeCss = `
27122902 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
27132903 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
27142904
2905 /* Health score badge */
2906 .repo-health-badge {
2907 display: inline-flex;
2908 align-items: center;
2909 gap: 5px;
2910 padding: 3px 10px;
2911 border-radius: 999px;
2912 font-size: 11.5px;
2913 font-weight: 700;
2914 font-family: var(--font-mono);
2915 text-decoration: none;
2916 border: 1px solid transparent;
2917 transition: filter 120ms ease, opacity 120ms ease;
2918 vertical-align: middle;
2919 }
2920 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
2921 .repo-health-badge-elite {
2922 background: rgba(52,211,153,0.15);
2923 color: #6ee7b7;
2924 border-color: rgba(52,211,153,0.30);
2925 }
2926 .repo-health-badge-strong {
2927 background: rgba(96,165,250,0.15);
2928 color: #93c5fd;
2929 border-color: rgba(96,165,250,0.30);
2930 }
2931 .repo-health-badge-improving {
2932 background: rgba(251,191,36,0.15);
2933 color: #fde68a;
2934 border-color: rgba(251,191,36,0.30);
2935 }
2936 .repo-health-badge-needs-attention {
2937 background: rgba(248,113,113,0.15);
2938 color: #fca5a5;
2939 border-color: rgba(248,113,113,0.30);
2940 }
2941
2942 /* Three-option empty-state panel */
2943 .repo-empty-options {
2944 display: grid;
2945 grid-template-columns: repeat(3, 1fr);
2946 gap: var(--space-3);
2947 margin-top: var(--space-5);
2948 }
2949 @media (max-width: 760px) {
2950 .repo-empty-options { grid-template-columns: 1fr; }
2951 }
2952 .repo-empty-option {
2953 position: relative;
2954 background: var(--bg-elevated);
2955 border: 1px solid var(--border);
2956 border-radius: 14px;
2957 padding: var(--space-4) var(--space-4);
2958 display: flex;
2959 flex-direction: column;
2960 gap: var(--space-2);
2961 overflow: hidden;
2962 transition: border-color 140ms ease, background 140ms ease;
2963 }
2964 .repo-empty-option:hover {
2965 border-color: rgba(140,109,255,0.45);
2966 background: rgba(140,109,255,0.04);
2967 }
2968 .repo-empty-option::before {
2969 content: '';
2970 position: absolute;
2971 top: 0; left: 0; right: 0;
2972 height: 2px;
2973 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2974 opacity: 0.55;
2975 pointer-events: none;
2976 }
2977 .repo-empty-option-label {
2978 font-size: 10px;
2979 font-family: var(--font-mono);
2980 font-weight: 700;
2981 letter-spacing: 0.12em;
2982 text-transform: uppercase;
2983 color: var(--accent);
2984 margin-bottom: 2px;
2985 }
2986 .repo-empty-option-title {
2987 font-family: var(--font-display);
2988 font-weight: 700;
2989 font-size: 16px;
2990 letter-spacing: -0.01em;
2991 color: var(--text-strong);
2992 margin: 0;
2993 }
2994 .repo-empty-option-sub {
2995 font-size: 13px;
2996 color: var(--text-muted);
2997 line-height: 1.5;
2998 margin: 0;
2999 flex: 1;
3000 }
3001 .repo-empty-option-snippet {
3002 background: var(--bg);
3003 border: 1px solid var(--border);
3004 border-radius: 8px;
3005 padding: var(--space-2) var(--space-3);
3006 font-family: var(--font-mono);
3007 font-size: 11.5px;
3008 line-height: 1.75;
3009 color: var(--text-strong);
3010 overflow-x: auto;
3011 white-space: pre;
3012 margin: var(--space-1) 0 0;
3013 }
3014 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3015 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3016 .repo-empty-option-cta {
3017 display: inline-flex;
3018 align-items: center;
3019 gap: 6px;
3020 margin-top: auto;
3021 padding-top: var(--space-2);
3022 font-size: 13px;
3023 font-weight: 600;
3024 color: var(--accent);
3025 text-decoration: none;
3026 transition: color 120ms ease;
3027 }
3028 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3029
27153030 @media (max-width: 720px) {
27163031 .repo-home-hero { padding: var(--space-4) var(--space-4); }
27173032 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
27223037 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
27233038 .repo-home-side-card { padding: var(--space-3); }
27243039 }
3040
3041 /* AI stats strip */
3042 .repo-ai-stats-strip {
3043 display: flex;
3044 align-items: center;
3045 gap: 6px;
3046 margin-top: 12px;
3047 margin-bottom: 4px;
3048 font-size: 12px;
3049 color: var(--text-muted);
3050 flex-wrap: wrap;
3051 }
3052 .repo-ai-stats-strip a {
3053 color: var(--text-muted);
3054 text-decoration: underline;
3055 text-decoration-color: transparent;
3056 transition: color 120ms ease, text-decoration-color 120ms ease;
3057 }
3058 .repo-ai-stats-strip a:hover {
3059 color: var(--accent);
3060 text-decoration-color: currentColor;
3061 }
3062 .repo-ai-stats-sep {
3063 opacity: 0.4;
3064 user-select: none;
3065 }
27253066 `;
27263067 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
27273068 // SSH URL — port-aware:
27613102 };
27623103
27633104 if (tree.length === 0) {
3105 const repoOgDesc = description
3106 ? `${owner}/${repo} on Gluecron — ${description}`
3107 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
27643108 return c.html(
2765 <Layout title={`${owner}/${repo}`} user={user}>
3109 <Layout
3110 title={`${owner}/${repo}`}
3111 user={user}
3112 description={repoOgDesc}
3113 ogTitle={`${owner}/${repo} — Gluecron`}
3114 ogDescription={repoOgDesc}
3115 twitterCard="summary"
3116 >
27663117 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3118 <style dangerouslySetInnerHTML={{ __html: `
3119 .empty-options-grid {
3120 display: grid;
3121 grid-template-columns: repeat(3, 1fr);
3122 gap: var(--space-4);
3123 margin-top: var(--space-4);
3124 }
3125 @media (max-width: 800px) {
3126 .empty-options-grid { grid-template-columns: 1fr; }
3127 }
3128 .empty-option-card {
3129 position: relative;
3130 background: var(--bg-elevated);
3131 border: 1px solid var(--border);
3132 border-radius: 14px;
3133 padding: var(--space-5);
3134 display: flex;
3135 flex-direction: column;
3136 gap: var(--space-3);
3137 overflow: hidden;
3138 transition: border-color 140ms ease, box-shadow 140ms ease;
3139 }
3140 .empty-option-card:hover {
3141 border-color: rgba(140,109,255,0.45);
3142 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3143 }
3144 .empty-option-card::before {
3145 content: '';
3146 position: absolute;
3147 top: 0; left: 0; right: 0;
3148 height: 2px;
3149 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3150 opacity: 0.55;
3151 pointer-events: none;
3152 }
3153 .empty-option-label {
3154 font-size: 10.5px;
3155 font-family: var(--font-mono);
3156 text-transform: uppercase;
3157 letter-spacing: 0.14em;
3158 color: var(--accent);
3159 font-weight: 700;
3160 }
3161 .empty-option-title {
3162 font-family: var(--font-display);
3163 font-weight: 700;
3164 font-size: 17px;
3165 letter-spacing: -0.01em;
3166 color: var(--text-strong);
3167 margin: 0;
3168 line-height: 1.25;
3169 }
3170 .empty-option-body {
3171 font-size: 13px;
3172 color: var(--text-muted);
3173 line-height: 1.55;
3174 margin: 0;
3175 flex: 1;
3176 }
3177 .empty-option-snippet {
3178 background: var(--bg);
3179 border: 1px solid var(--border);
3180 border-radius: 8px;
3181 padding: var(--space-3) var(--space-4);
3182 font-family: var(--font-mono);
3183 font-size: 11.5px;
3184 line-height: 1.75;
3185 color: var(--text-strong);
3186 overflow-x: auto;
3187 white-space: pre;
3188 margin: 0;
3189 }
3190 .empty-option-snippet .ec { color: var(--text-faint); }
3191 .empty-option-snippet .em { color: var(--accent); }
3192 .empty-option-cta {
3193 display: inline-flex;
3194 align-items: center;
3195 gap: 6px;
3196 padding: 8px 16px;
3197 font-size: 13px;
3198 font-weight: 600;
3199 color: var(--text-strong);
3200 background: var(--bg-secondary);
3201 border: 1px solid var(--border);
3202 border-radius: 9999px;
3203 text-decoration: none;
3204 align-self: flex-start;
3205 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3206 }
3207 .empty-option-cta:hover {
3208 border-color: rgba(140,109,255,0.55);
3209 background: rgba(140,109,255,0.08);
3210 color: var(--accent);
3211 text-decoration: none;
3212 }
3213 .empty-option-cta-accent {
3214 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3215 border-color: transparent;
3216 color: #fff;
3217 }
3218 .empty-option-cta-accent:hover {
3219 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3220 border-color: transparent;
3221 color: #fff;
3222 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3223 }
3224 ` }} />
27673225 <div class="repo-home-hero">
27683226 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
27693227 <div class="repo-home-hero-orb" />
27723230 <div class="repo-home-eyebrow">
27733231 <strong>Repository</strong> · {owner}
27743232 </div>
2775 <RepoHeader
2776 owner={owner}
2777 repo={repo}
2778 starCount={starCount}
2779 starred={starred}
2780 forkCount={forkCount}
2781 currentUser={user?.username}
2782 archived={archived}
2783 isTemplate={isTemplate}
2784 />
3233 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3234 <RepoHeader
3235 owner={owner}
3236 repo={repo}
3237 starCount={starCount}
3238 starred={starred}
3239 forkCount={forkCount}
3240 currentUser={user?.username}
3241 archived={archived}
3242 isTemplate={isTemplate}
3243 recentPush={recentPush}
3244 />
3245 {healthScore && (() => {
3246 const gradeLabel: Record<string, string> = {
3247 elite: "Elite", strong: "Strong",
3248 improving: "Improving", "needs-attention": "Needs Attention",
3249 };
3250 return (
3251 <a
3252 href={`/${owner}/${repo}/insights/health`}
3253 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3254 title={`Health score: ${healthScore!.total}/100`}
3255 >
3256 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3257 </a>
3258 );
3259 })()}
3260 </div>
27853261 {description ? (
27863262 <p class="repo-home-description">{description}</p>
27873263 ) : (
27933269 </div>
27943270 </div>
27953271 <RepoNav owner={owner} repo={repo} active="code" />
2796 <div class="repo-home-empty">
2797 <div class="repo-home-empty-eyebrow">Getting started</div>
3272 <div style="margin-top:var(--space-4)">
3273 <div style="font-size:12px;font-family:var(--font-mono);color:var(--accent);letter-spacing:0.12em;text-transform:uppercase;font-weight:600;margin-bottom:var(--space-2)">
3274 Getting started
3275 </div>
27983276 <h2 class="repo-home-empty-title">
2799 Push your first commit to{" "}
2800 <span class="gradient-text">{repo}</span>.
3277 <span class="gradient-text">{repo}</span> is ready — make your first move.
28013278 </h2>
28023279 <p class="repo-home-empty-sub">
2803 This repository is empty. Paste the snippet below in an existing
2804 project directory to wire it up to Gluecron — your push triggers
2805 gate checks and AI review automatically.
3280 Choose how you want to kick things off. Every push wires up gate
3281 checks and AI review automatically.
28063282 </p>
2807 <pre class="repo-home-empty-snippet">
2808 <span class="repo-home-cmt">
2809 # from an existing project directory
2810 </span>
2811 {"\n"}
2812 <span class="repo-home-cmd">git remote add</span>
2813 {` origin ${cloneHttpsUrl}`}
2814 {"\n"}
2815 <span class="repo-home-cmd">git branch</span>
2816 {` -M main`}
2817 {"\n"}
2818 <span class="repo-home-cmd">git push</span>
2819 {` -u origin main`}
2820 </pre>
3283 <div class="empty-options-grid">
3284 <div class="empty-option-card">
3285 <div class="empty-option-label">Option A</div>
3286 <h3 class="empty-option-title">Push your first commit</h3>
3287 <p class="empty-option-body">
3288 Wire up an existing project in seconds. Run these commands from
3289 your project directory.
3290 </p>
3291 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3292<span class="em">git remote add</span> origin {cloneHttpsUrl}
3293<span class="em">git branch</span> -M main
3294<span class="em">git push</span> -u origin main</pre>
3295 </div>
3296 <div class="empty-option-card">
3297 <div class="empty-option-label">Option B</div>
3298 <h3 class="empty-option-title">Import from GitHub</h3>
3299 <p class="empty-option-body">
3300 Mirror an existing GitHub repository here in one click. Gluecron
3301 syncs the full history and branches.
3302 </p>
3303 <a href="/import" class="empty-option-cta">
3304 Import repository
3305 </a>
3306 </div>
3307 <div class="empty-option-card">
3308 <div class="empty-option-label">Option C</div>
3309 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3310 <p class="empty-option-body">
3311 Let AI write your first feature. Describe what you want to build
3312 and Gluecron opens a pull request with the code.
3313 </p>
3314 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3315 Let AI write your first feature
3316 </a>
3317 </div>
3318 </div>
28213319 </div>
28223320 </Layout>
28233321 );
28293327 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
28303328 const dirCount = tree.filter((e: any) => e.type === "tree").length;
28313329
3330 const repoOgDesc = description
3331 ? `${owner}/${repo} on Gluecron — ${description}`
3332 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3333
28323334 return c.html(
2833 <Layout title={`${owner}/${repo}`} user={user}>
3335 <Layout
3336 title={`${owner}/${repo}`}
3337 user={user}
3338 description={repoOgDesc}
3339 ogTitle={`${owner}/${repo} — Gluecron`}
3340 ogDescription={repoOgDesc}
3341 twitterCard="summary"
3342 >
28343343 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
28353344 <div class="repo-home-hero">
28363345 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
28403349 <div class="repo-home-eyebrow">
28413350 <strong>Repository</strong> · {owner}
28423351 </div>
2843 <RepoHeader
2844 owner={owner}
2845 repo={repo}
2846 starCount={starCount}
2847 starred={starred}
2848 forkCount={forkCount}
2849 currentUser={user?.username}
2850 archived={archived}
2851 isTemplate={isTemplate}
2852 />
3352 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3353 <RepoHeader
3354 owner={owner}
3355 repo={repo}
3356 starCount={starCount}
3357 starred={starred}
3358 forkCount={forkCount}
3359 currentUser={user?.username}
3360 archived={archived}
3361 isTemplate={isTemplate}
3362 recentPush={recentPush}
3363 />
3364 {healthScore && (() => {
3365 const gradeLabel: Record<string, string> = {
3366 elite: "Elite", strong: "Strong",
3367 improving: "Improving", "needs-attention": "Needs Attention",
3368 };
3369 return (
3370 <a
3371 href={`/${owner}/${repo}/insights/health`}
3372 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3373 title={`Health score: ${healthScore!.total}/100`}
3374 >
3375 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3376 </a>
3377 );
3378 })()}
3379 </div>
28533380 {description ? (
28543381 <p class="repo-home-description">{description}</p>
28553382 ) : (
30363563 ref={defaultBranch}
30373564 path=""
30383565 />
3566 {/* AI stats strip — one-line summary of AI value for this repo */}
3567 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3568 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3569 <span>{"⚡"}</span>
3570 {aiStats.mergedCount > 0 ? (
3571 <a href={`/${owner}/${repo}/pulls?state=merged`}>
3572 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
3573 </a>
3574 ) : (
3575 <span>0 AI merges this week</span>
3576 )}
3577 <span class="repo-ai-stats-sep">{"·"}</span>
3578 <span>Saved ~{aiStats.hoursSaved} hrs</span>
3579 <span class="repo-ai-stats-sep">{"·"}</span>
3580 {aiStats.securityAlertCount > 0 ? (
3581 <a href={`/${owner}/${repo}/issues?label=security`}>
3582 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
3583 </a>
3584 ) : (
3585 <span>0 open security alerts</span>
3586 )}
3587 </div>
3588 ) : (
3589 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3590 <span>{"⚡"}</span>
3591 <span>AI is watching — no activity this week</span>
3592 </div>
3593 )}
30393594 {readme && (() => {
30403595 const readmeHtml = renderMarkdown(readme);
30413596 return (
32063761 </div>
32073762 )}
32083763 </div>
3764 {/* Claude AI — quick-access card for authenticated users */}
3765 {user && (
3766 <div class="repo-home-side-card" style="margin-top:12px">
3767 <h3 class="repo-home-side-title" style="margin-bottom:10px">
3768 ✨ Claude AI
3769 </h3>
3770 <a
3771 href={`/${owner}/${repo}/claude`}
3772 style="display:block;background:#1f6feb;color:#fff;text-align:center;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-bottom:8px"
3773 >
3774 Claude Code sessions
3775 </a>
3776 <a
3777 href={`/${owner}/${repo}/ask`}
3778 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
3779 >
3780 Ask AI about this repo
3781 </a>
3782 </div>
3783 )}
32093784 </aside>
32103785 </div>
32113786 <script
40394614 const commits = await listCommits(owner, repo, ref, 50);
40404615
40414616 // Block J3 — batch-fetch cached verification results for the page.
4617 // Also resolve repoId here so we can query the recent push for the
4618 // Push Watch live/watch indicator in the header.
40424619 let verifications: Record<string, { verified: boolean; reason: string }> = {};
4620 let commitsPageRecentPush: RecentPush | null = null;
40434621 try {
40444622 const [ownerRow] = await db
40454623 .select()
40574635 )
40584636 )
40594637 .limit(1);
4060 if (repoRow && commits.length > 0) {
4061 const rows = await db
4062 .select()
4063 .from(commitVerifications)
4064 .where(
4065 and(
4066 eq(commitVerifications.repositoryId, repoRow.id),
4067 inArray(
4068 commitVerifications.commitSha,
4069 commits.map((c) => c.sha)
4070 )
4071 )
4072 );
4073 for (const r of rows) {
4638 if (repoRow) {
4639 // Fetch verifications and recent push in parallel.
4640 const [verRows, rp] = await Promise.all([
4641 commits.length > 0
4642 ? db
4643 .select()
4644 .from(commitVerifications)
4645 .where(
4646 and(
4647 eq(commitVerifications.repositoryId, repoRow.id),
4648 inArray(
4649 commitVerifications.commitSha,
4650 commits.map((c) => c.sha)
4651 )
4652 )
4653 )
4654 : Promise.resolve([]),
4655 getRecentPush(repoRow.id),
4656 ]);
4657 for (const r of verRows) {
40744658 verifications[r.commitSha] = {
40754659 verified: r.verified,
40764660 reason: r.reason,
40774661 };
40784662 }
4663 commitsPageRecentPush = rp;
40794664 }
40804665 }
40814666 } catch {
40854670 return c.html(
40864671 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
40874672 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4088 <RepoHeader owner={owner} repo={repo} />
4673 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
40894674 <RepoNav owner={owner} repo={repo} active="commits" />
40904675 <div class="commits-hero">
40914676 <div class="commits-hero-orb-wrap" aria-hidden="true">
42444829 >
42454830 {cm.sha.slice(0, 7)}
42464831 </a>
4832 <a
4833 href={`/${owner}/${repo}/push/${cm.sha}`}
4834 class="commits-row-watch"
4835 title="Watch gate + deploy results for this push"
4836 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
4837 >
4838 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4839 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
4840 <circle cx="12" cy="12" r="3" />
4841 </svg>
4842 </a>
42474843 <button
42484844 type="button"
42494845 class="commits-row-copy"
Modifiedsrc/views/components.tsx+122−56View fileUnifiedSplit
33import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
44import type { Repository } from "../db/schema";
55
6/**
7 * Describes the most recent push to a repo, used by RepoHeader to render
8 * the Push Watch discoverability indicator.
9 *
10 * - ageMs < 5 min \u2192 pulsing red "\u25cf Live" badge
11 * - ageMs < 24 hr \u2192 dimmer "\u25cb Watch" link
12 * - otherwise \u2192 nothing shown
13 */
14export interface RecentPush {
15 sha: string;
16 ageMs: number;
17}
18
619export const RepoHeader: FC<{
720 owner: string;
821 repo: string;
1326 forkedFrom?: string | null;
1427 archived?: boolean;
1528 isTemplate?: boolean;
29 /** Most recent push info for Push Watch discoverability indicator. */
30 recentPush?: RecentPush | null;
1631}> = ({
1732 owner,
1833 repo,
2338 forkedFrom,
2439 archived,
2540 isTemplate,
26}) => (
27 <div class="repo-header">
28 <div>
29 <div class="repo-header-title">
30 <a href={`/${owner}`} class="owner">
31 {owner}
32 </a>
33 <span class="separator">/</span>
34 <a href={`/${owner}/${repo}`} class="name">
35 {repo}
36 </a>
37 {archived && (
38 <span
39 class="repo-header-pill repo-header-pill-archived"
40 title="Read-only: pushes and new issues/PRs disabled"
41 >
42 Archived
43 </span>
44 )}
45 {isTemplate && (
46 <span
47 class="repo-header-pill repo-header-pill-template"
48 title="This repository can be used as a template"
49 >
50 Template
51 </span>
41 recentPush,
42}) => {
43 const FIVE_MIN = 5 * 60 * 1000;
44 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
45 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
46 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
47
48 return (
49 <div class="repo-header">
50 <div>
51 <div class="repo-header-title">
52 <a href={`/${owner}`} class="owner">
53 {owner}
54 </a>
55 <span class="separator">/</span>
56 <a href={`/${owner}/${repo}`} class="name">
57 {repo}
58 </a>
59 {archived && (
60 <span
61 class="repo-header-pill repo-header-pill-archived"
62 title="Read-only: pushes and new issues/PRs disabled"
63 >
64 Archived
65 </span>
66 )}
67 {isTemplate && (
68 <span
69 class="repo-header-pill repo-header-pill-template"
70 title="This repository can be used as a template"
71 >
72 Template
73 </span>
74 )}
75 {isLive && recentPush && (
76 <a
77 href={`/${owner}/${repo}/push/${recentPush.sha}`}
78 class="repo-header-live-badge repo-header-live-badge--live"
79 title="Push in progress \u2014 watch live gate + deploy status"
80 aria-label="Live push \u2014 click to watch status"
81 >
82 <span class="repo-header-live-dot" aria-hidden="true">{"\u25cf"}</span>
83 Live
84 </a>
85 )}
86 {!isLive && isRecent && recentPush && (
87 <a
88 href={`/${owner}/${repo}/push/${recentPush.sha}`}
89 class="repo-header-live-badge repo-header-live-badge--recent"
90 title="Watch the most recent push's gate + deploy results"
91 aria-label="Watch most recent push"
92 >
93 <span aria-hidden="true">{"\u25cb"}</span>
94 Watch
95 </a>
96 )}
97 </div>
98 {forkedFrom && (
99 <div class="repo-header-fork">
100 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
101 </div>
52102 )}
53103 </div>
54 {forkedFrom && (
55 <div class="repo-header-fork">
56 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
57 </div>
58 )}
59 </div>
60 <div class="repo-header-actions">
61 {currentUser && currentUser !== owner && (
62 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
63 <button type="submit" class="star-btn">
64 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
65 </button>
66 </form>
67 )}
68 {starCount !== undefined && (
69 currentUser ? (
70 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
71 <button
72 type="submit"
73 class={`star-btn${starred ? " starred" : ""}`}
74 >
75 {starred ? "\u2605" : "\u2606"} {starCount}
104 <div class="repo-header-actions">
105 {currentUser && currentUser !== owner && (
106 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
107 <button type="submit" class="star-btn">
108 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
76109 </button>
77110 </form>
78 ) : (
79 <span class="star-btn">
80 {"\u2606"} {starCount}
81 </span>
82 )
83 )}
111 )}
112 {starCount !== undefined && (
113 currentUser ? (
114 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
115 <button
116 type="submit"
117 class={`star-btn${starred ? " starred" : ""}`}
118 >
119 {starred ? "\u2605" : "\u2606"} {starCount}
120 </button>
121 </form>
122 ) : (
123 <span class="star-btn">
124 {"\u2606"} {starCount}
125 </span>
126 )
127 )}
128 </div>
84129 </div>
85 </div>
86);
130 );
131};
87132
88133export const RepoNav: FC<{
89134 owner: string;
102147 | "semantic"
103148 | "wiki"
104149 | "projects"
150 | "agents"
151 | "discussions"
152 | "security"
105153 | "settings";
106154}> = ({ owner, repo, active }) => (
107155 <div class="repo-nav">
114162 >
115163 Issues
116164 </a>
165 <a
166 href={`/${owner}/${repo}/discussions`}
167 class={active === "discussions" ? "active" : ""}
168 >
169 Discussions
170 </a>
117171 <a
118172 href={`/${owner}/${repo}/wiki`}
119173 class={active === "wiki" ? "active" : ""}
156210 >
157211 {"\u25CF"} Gates
158212 </a>
213 <a
214 href={`/${owner}/${repo}/security/vulnerabilities`}
215 class={active === "security" ? "active" : ""}
216 >
217 Security
218 </a>
159219 <a
160220 href={`/${owner}/${repo}/insights`}
161221 class={active === "insights" ? "active" : ""}
162222 >
163223 Insights
164224 </a>
225 <a
226 href={`/${owner}/${repo}/agents`}
227 class={active === "agents" ? "active" : ""}
228 >
229 Agents
230 </a>
165231 <a
166232 href={`/${owner}/${repo}/explain`}
167233 class={`repo-nav-ai${active === "explain" ? " active" : ""}`}
Modifiedsrc/views/landing-2030.tsx+8−7View fileUnifiedSplit
6161const FEATURES: { icon: FC; title: string; body: string }[] = [
6262 { icon: IconReview, title: "Claude code review",
6363 body: "Every pull request gets a senior-level review the moment it opens — line-level comments, risk flags, and a verdict, in seconds." },
64 { icon: IconMerge, title: "Merge while you sleep",
65 body: "Gates green and review clean? Gluecron merges autonomously. Label an issue at night, wake to a shipped PR." },
64 { icon: IconMerge, title: "Auto-merge the instant gates pass",
65 body: "Gates green and review clean? Gluecron merges autonomously. Label an issue, get a shipped PR — no waiting." },
6666 { icon: IconGate, title: "Push-time gate enforcement",
6767 body: "Security and quality gates run at the moment of push — not minutes later in CI. Bad code never reaches your branch." },
6868 { icon: IconGit, title: "Git-native hosting",
7777 { n: "01", title: "Label an issue", body: "Drop a label on an issue — or just describe what you want. That's the whole input." },
7878 { n: "02", title: "Agents go to work", body: "Claude opens a branch, writes the change, and submits a pull request against your gates." },
7979 { n: "03", title: "Reviewed & gated", body: "The PR is reviewed line-by-line and run through push-time security and quality gates." },
80 { n: "04", title: "Merged, autonomously", body: "Green across the board? It merges itself and deploys. You wake up to shipped work." },
80 { n: "04", title: "Merged, autonomously", body: "Green across the board? It merges itself and deploys. Shipped while you were still in the same coding session." },
8181];
8282
8383export const Landing2030Page: FC<Landing2030Props> = () => {
84 const title = "Gluecron — The git host built for 2030";
84 const title = "Gluecron — The AI-native git host";
8585 const desc =
86 "Gluecron is the AI-native git host. Claude reviews every pull request, gates run at push time, and clean PRs merge while you sleep. Label an issue, walk away, wake up to a merged PR.";
86 "The AI-native git host. Spec to PR in 90 seconds. Auto-merge the instant gates pass. Ship faster than any team on GitHub.";
8787 return (
8888 <html lang="en">
8989 <head>
138138 </h1>
139139 <p class="lede rise" style="--d:120ms">
140140 Gluecron hosts your code, reviews every pull request with Claude,
141 enforces gates at push time, and merges clean work while you sleep.
142 Label an issue, walk away, wake up to a merged PR.
141 enforces gates at push time, and merges clean work the instant
142 gates pass. Spec to PR in 90 seconds — ship faster than any team
143 on GitHub.
143144 </p>
144145 <div class="hero-actions rise" style="--d:180ms">
145146 <a href="/register" class="btn btn-solid btn-lg">Start building →</a>
Modifiedsrc/views/landing.tsx+57−13View fileUnifiedSplit
88 * Block L10 — hero rewrite. The hero now lands the Block L positioning
99 * ("the git host built around Claude"): gradient headline, one-line
1010 * install snippet w/ copy button, three CTAs (Sign up / Demo / vs-GitHub),
11 * and a four-line "what just happened" rail driven off the L4 publicStats
11 * and a four-line activity rail driven off the L4 publicStats
1212 * payload. The L4 counters tile section and L5 vs-GitHub CTA are both
1313 * preserved — additive only.
1414 *
1515 * Also adds two new editorial sections below the L4 counters:
16 * - "Three reasons to switch" (Sleep Mode / Migrate / Demo)
16 * - "Three reasons to switch" (Instant Shipping / Migrate / Demo)
1717 * - "How is this different from GitHub?" pull-quote → /vs-github
1818 *
1919 * Pure presentational. Drops into <Layout user={null}>.
147147 </div>
148148
149149 <h1 class="landing-hero-title display">
150 <span class="gradient-text">The git host built around Claude.</span>
150 <span class="gradient-text">Write the spec. Gluecron ships it.</span>
151151 </h1>
152152
153153 <p class="landing-hero-sub">
154 Label an issue. Walk away. Wake up to a merged PR.
154 Spec to PR in 90 seconds. Push to live in 25. AI review, auto-merge, deploy — automatic.
155155 </p>
156156
157 {/* U1 — primary CTA row, demoted to 2 buttons. */}
157 {/* U1 — primary CTA row. "Migrate from GitHub" added as a
158 secondary CTA alongside sign-up to capture visitors who
159 already have GitHub repos and want a one-click move. */}
158160 <div class="landing-hero-ctas" data-testid="hero-primary-ctas">
159161 <a href="/register" class="btn btn-primary btn-xl landing-cta-primary">
160162 Sign up free
161163 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
162164 </a>
165 {/* Migrate from GitHub — prominent secondary CTA */}
166 <a
167 href="/import"
168 class="btn btn-xl landing-cta-migrate"
169 data-testid="cta-migrate"
170 >
171 Migrate from GitHub
172 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
173 </a>
163174 {/* BLOCK Q1 — one-click Claude Desktop install. */}
164175 <a
165176 href="/gluecron.dxt"
215226 </div>
216227 </div>
217228
218 {/* U1 — tightened "what just happened" rail.
229 {/* U1 — tightened activity rail.
219230 Same data as before, rendered as a single horizontal
220231 rule with the gradient accent line on top. Numbers
221232 smaller, copy still scannable. */}
222233 {publicStats && (
223 <ul class="landing-hero-rail" aria-label="What just happened on Gluecron">
234 <ul class="landing-hero-rail" aria-label="Gluecron live this week">
224235 <li>
225236 <strong>{publicStats.weeklyPrsAutoMerged.toLocaleString()}</strong>
226237 <span class="landing-hero-rail-label">PRs auto-merged</span>
231242 </li>
232243 <li>
233244 <strong>{publicStats.weeklyDeploysShipped.toLocaleString()}</strong>
234 <span class="landing-hero-rail-label">deploys overnight</span>
245 <span class="landing-hero-rail-label">deploys shipped</span>
235246 </li>
236247 <li>
237248 <strong>{`~${Math.round(publicStats.weeklyHoursSaved).toLocaleString()}`}</strong>
333344 <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
334345 </svg>
335346 }
336 title="Toggle Sleep Mode"
337 body="Claude does the work overnight. You get a 9 AM digest of what shipped — PRs merged, deploys live, incidents triaged."
338 link={{ href: "/sleep-mode", label: "Turn on Sleep Mode" }}
347 title="Ships in seconds, not tabs"
348 body="Spec to draft PR in 90 seconds. AI review posted in under 10. Push to live in 25. Every step streams in real time — no polling, no waiting on a CI tab. Or let Sleep Mode batch it for when you're away."
349 link={{ href: "/sleep-mode", label: "See Sleep Mode" }}
339350 />
340351 <ReasonCard
341352 icon={
358369 </svg>
359370 }
360371 title="Open the demo, watch it work"
361 body="The demo repo is real. Label an issue, hit refresh, see Claude open the PR. Inspect the diff. Approve the merge. Zero setup, zero credit card."
372 body="The demo repo is real. Label an issue, watch Claude open the PR in seconds. Inspect the diff. Approve the merge. Zero setup, zero credit card."
362373 link={{ href: "/demo", label: "Open the live demo" }}
363374 />
364375 </div>
647658 Migrate a repo
648659 </a>
649660 </div>
661 <div style="margin-top:var(--space-3);font-size:13.5px;color:var(--text-muted)">
662 Migrating from GitHub?{" "}
663 <a href="/migrate" style="color:var(--accent)">
664 Import your entire org in one click &rarr;
665 </a>
666 </div>
650667 </div>
651668 </section>
652669
21552172 }
21562173 }
21572174
2175 /* "Migrate from GitHub" CTA — secondary, but strong enough to stand
2176 alongside the primary. Uses a subtle amber/violet mix so it reads as
2177 action-oriented without competing with the green primary CTA. */
2178 .landing-cta-migrate {
2179 position: relative;
2180 background: var(--bg-elevated);
2181 color: var(--text-strong);
2182 border: 1px solid var(--border-strong);
2183 transition: border-color var(--t-base, 180ms) var(--ease, ease),
2184 transform var(--t-base, 180ms) var(--ease-spring, ease),
2185 box-shadow var(--t-base, 180ms) var(--ease, ease);
2186 }
2187 .landing-cta-migrate:hover {
2188 border-color: rgba(140,109,255,0.55);
2189 transform: translateY(-2px);
2190 box-shadow: 0 8px 22px -8px rgba(140,109,255,0.30);
2191 text-decoration: none;
2192 color: var(--text-strong);
2193 }
2194 @media (prefers-reduced-motion: reduce) {
2195 .landing-cta-migrate,
2196 .landing-cta-migrate:hover {
2197 transform: none;
2198 transition: none;
2199 }
2200 }
2201
21582202 /* L8 — free-tier reassurance link beneath the CTA row.
21592203 U1 — rhythm snapped to var(--space-6). */
21602204 .landing-hero-freenote {
28292873 color: var(--green, #34d399);
28302874 }
28312875
2832 /* ---------- L10/U1 hero "what just happened" rail ----------
2876 /* ---------- L10/U1 hero activity rail ----------
28332877 U1 — tightened into a single horizontal strip. The 1px gradient
28342878 rule on top is the same accent the headline uses, so the rail
28352879 reads as part of the hero composition rather than a stray list. */
Modifiedsrc/views/layout.tsx+62−0View fileUnifiedSplit
279279 <a href="/issues" role="menuitem" class="nav-user-item">Issues</a>
280280 <a href="/activity" role="menuitem" class="nav-user-item">Activity</a>
281281 <a href="/import" role="menuitem" class="nav-user-item">Import from GitHub</a>
282 <a href="/import/actions" role="menuitem" class="nav-user-item">Actions importer</a>
282283 <div class="nav-user-menu-sep" />
283284 <a href={`/${user.username}`} role="menuitem" class="nav-user-item">Your profile</a>
284285 <a href="/settings" role="menuitem" class="nav-user-item">Settings</a>
298299 <span class="theme-icon-dark">{"☾"}</span>
299300 <span class="theme-icon-light">{"☀"}</span>
300301 </a>
302 <a href="/import" class="nav-link nav-migrate" title="Migrate your GitHub repos to Gluecron">
303 Migrate from GitHub
304 </a>
301305 <a href="/login" class="nav-link">Sign in</a>
302306 <a href="/register" class="btn btn-sm btn-primary">Register</a>
303307 </>
329333 <div class="footer-col-title">Product</div>
330334 <a href="/features">Features</a>
331335 <a href="/pricing">Pricing</a>
336 <a href="/enterprise">Enterprise</a>
337 <a href="/changelog">Changelog</a>
332338 <a href="/explore">Explore</a>
333339 <a href="/marketplace">Marketplace</a>
340 <a href="/developer-program">Developer Program</a>
334341 </div>
335342 <div class="footer-col">
336343 <div class="footer-col-title">Platform</div>
344 <a href="/docs">Docs</a>
337345 <a href="/help">Quickstart</a>
338346 <a href="/status">Status</a>
339347 <a href="/api/graphql">GraphQL</a>
342350 <div class="footer-col">
343351 <div class="footer-col-title">Company</div>
344352 <a href="/about">About</a>
353 <a href="/blog">Blog</a>
345354 <a href="/terms">Terms</a>
346355 <a href="/privacy">Privacy</a>
347356 <a href="/acceptable-use">Acceptable use</a>
16191628 background: var(--accent-gradient);
16201629 border-radius: 2px;
16211630 }
1631 /* "Migrate from GitHub" nav link — logged-out only, slightly accented
1632 so it reads as an action affordance rather than a passive link. */
1633 .nav-migrate {
1634 color: var(--accent);
1635 font-weight: 600;
1636 border: 1px solid rgba(140,109,255,0.22);
1637 background: rgba(140,109,255,0.07);
1638 }
1639 .nav-migrate:hover {
1640 color: var(--accent-hover);
1641 background: rgba(140,109,255,0.13);
1642 border-color: rgba(140,109,255,0.40);
1643 }
1644 @media (max-width: 780px) { .nav-migrate { display: none; } }
1645
16221646 .nav-user {
16231647 color: var(--text-strong);
16241648 font-weight: 600;
23472371 }
23482372 .repo-header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
23492373
2374 /* Push Watch discoverability — live/recent indicator in the repo header */
2375 @keyframes pushWatchPulse {
2376 0%, 100% { opacity: 1; }
2377 50% { opacity: 0.3; }
2378 }
2379 .repo-header-live-badge {
2380 display: inline-flex;
2381 align-items: center;
2382 gap: 5px;
2383 padding: 2px 9px;
2384 border-radius: 999px;
2385 font-size: 12px;
2386 font-weight: 600;
2387 letter-spacing: 0.02em;
2388 text-decoration: none !important;
2389 vertical-align: 3px;
2390 transition: filter 140ms ease, opacity 140ms ease;
2391 }
2392 .repo-header-live-badge:hover { filter: brightness(1.15); text-decoration: none !important; }
2393 .repo-header-live-badge--live {
2394 background: rgba(218, 54, 51, 0.12);
2395 color: #f97171;
2396 border: 1px solid rgba(218, 54, 51, 0.35);
2397 }
2398 .repo-header-live-badge--live .repo-header-live-dot {
2399 animation: pushWatchPulse 1.2s ease-in-out infinite;
2400 display: inline-block;
2401 }
2402 .repo-header-live-badge--recent {
2403 background: rgba(140, 109, 255, 0.08);
2404 color: var(--text-muted);
2405 border: 1px solid rgba(140, 109, 255, 0.22);
2406 }
2407 .repo-header-live-badge--recent:hover { color: var(--accent); }
2408 @media (prefers-reduced-motion: reduce) {
2409 .repo-header-live-badge--live .repo-header-live-dot { animation: none; }
2410 }
2411
23502412 .repo-nav {
23512413 display: flex;
23522414 gap: 1px;
23532415