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

feat(BLOCK-I): I5 code scanning UI + I6 sponsors + I7 email digest

feat(BLOCK-I): I5 code scanning UI + I6 sponsors + I7 email digest

- I5: /:owner/:repo/security aggregates scan-related gate_runs into
  summary cards + per-scanner status + recent runs (no new tables).
- I6: sponsorship_tiers + sponsorships tables (0023). Public
  /sponsors/:user + maintainer /settings/sponsors with tier CRUD.
  Payment rails deferred; v1 captures intent + thank-you notes.
- I7: weekly email digest. Adds users.notify_email_digest_weekly +
  last_digest_sent_at (0024). composeDigest/sendDigestForUser/
  sendDigestsToAll in src/lib/email-digest.ts pull notifications +
  failed/repaired gates + merged PRs over 7d, emit escaped HTML.
  /settings/digest/preview for self-preview; /admin/digests trigger
  + single-user preview, audit-logged.

21 new tests (email-digest + sponsors + code-scanning). Full suite
at 570 pass / 0 fail. BUILD_BIBLE scorecard updated to reflect I5/I6/I7
shipping; migrations + routes added to the locked list; schema table
count bumped to 81.

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 71cd5ec
13 files changed+1518708420cd697c202c1572384583cf436047d76ef85
13 changed files+1518−7
ModifiedBUILD_BIBLE.md+15−5View fileUnifiedSplit
129129| AI PR triage | ✅ | D3 — Claude Haiku suggests labels/reviewers/priority as an AI comment on PR create; `triagePullRequest` in `src/lib/ai-generators.ts`, wired in `src/routes/pulls.tsx` |
130130| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
131131| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
132| Code scanning UI | 🟡 | data exists, no dedicated UI page |
132| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
133133| Copilot code completion | ✅ | D9 — `POST /api/copilot/completions` (PAT/OAuth/session), `GET /api/copilot/ping`. `src/lib/ai-completion.ts`, `src/routes/copilot.ts`. LRU-cached, rate-limited 60/min. |
134134| Semantic code search | ✅ | D1 — see 2.2 |
135135
152152| Packages registry (npm / docker / etc) | ✅ | `src/lib/packages.ts`, `src/routes/packages-api.ts`, `src/routes/packages.tsx`; npm protocol (packument, tarball, publish, yank); PAT (`glc_`) auth via Authorization header; container registry deferred |
153153| Pages / static hosting | ✅ | `src/lib/pages.ts`, `src/routes/pages.tsx`; serves blobs from bare git at latest `gh-pages` commit; per-repo settings (source branch/dir, custom domain); short-cache headers |
154154| Gists | ✅ | E4 — multi-file tiny repos with per-revision JSON snapshots + stars. `src/routes/gists.tsx` + `drizzle/0014_gists.sql` |
155| Sponsors | ❌ | |
155| Sponsors | ✅ | I6 — `src/routes/sponsors.tsx`, `drizzle/0023_sponsors.sql` (tables `sponsorship_tiers`, `sponsorships`). Public `/sponsors/:user` page with tier cards + recent public sponsors; maintainer view at `/settings/sponsors` with add/retire tiers. Payment rails deferred — captures intent + thank-you notes. |
156156| Marketplace | ✅ | H1 — `src/routes/marketplace.tsx` + `src/lib/marketplace.ts`, `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Public `/marketplace` directory, `/marketplace/:slug` detail + install, `/settings/apps` personal installs, `/developer/apps-new` registration, `/developer/apps/:slug/manage` event log + token issuance. |
157157| Environments / deployment tracking | ✅ | `src/routes/deployments.tsx` — grouped by env, success-rate rollup, per-deploy detail. Protected environments (`src/routes/environments.tsx`, `src/lib/environments.ts`) with reviewer-gated approval, branch-glob restrictions, approve/reject decisions recorded in `deployment_approvals` |
158158| Merge queues | ✅ | E5 — serialised merge with re-test. `src/lib/merge-queue.ts`, `src/routes/merge-queue.tsx`, `drizzle/0017_merge_queue.sql`; per `(repo, base_branch)` queue, owner-only process-next re-runs gates against latest base before merging. |
172172| Site admin panel | ✅ | F3 — `src/lib/admin.ts` + `src/routes/admin.tsx`, tables `site_admins` + `system_flags`. Bootstrap rule (oldest user wins until `site_admins` populated). Flags: registration_locked, site_banner_*, read_only_mode. |
173173| Billing + quotas | ✅ | F4 — `src/lib/billing.ts` + `src/routes/billing.tsx`, tables `billing_plans` + `user_quotas` seeded free/pro/team/enterprise. `/settings/billing` personal view + `/admin/billing` site-admin override. |
174174| Email notifications | ✅ | opt-in per kind (mention/assign/gate-fail) via `/settings`; provider-pluggable `src/lib/email.ts` (log default, resend in prod) |
175| Email digest | ❌ | |
175| Email digest | ✅ | I7 — `src/lib/email-digest.ts` + `drizzle/0024_email_digest.sql` (`users.notify_email_digest_weekly` + `last_digest_sent_at`). `composeDigest` pulls notifications + failed/repaired gates + merged PRs over last 7d, renders text + escaped HTML. `/settings/digest/preview` for self-preview; `/admin/digests` dashboard + `POST /admin/digests/run` fires `sendDigestsToAll`; `POST /admin/digests/preview` sends to one user. Never throws. |
176176| Mobile PWA | ✅ | G1 — `src/routes/pwa.ts` serves `/manifest.webmanifest` + `/sw.js` + `/icon.svg`; Layout injects manifest link + SW registration. Offline-capable (network-first for HTML). |
177177| GraphQL API | ✅ | G2 — `src/lib/graphql.ts` parser + executor, `src/routes/graphql.ts` endpoint at `POST /api/graphql`, GraphiQL-lite explorer at `GET /api/graphql`. Queries only (viewer/user/repository/search/rateLimit). |
178178| Official CLI | ✅ | G3 — `cli/gluecron.ts` Bun-compilable single binary. REST + GraphQL client, `~/.gluecron/config.json` 0600. |
271271- **I2** — Template repositories → ✅ shipped. `drizzle/0022_repo_templates.sql` adds `is_template` column + partial index. `src/routes/templates.ts` serves `POST /:owner/:repo/use-template` (git clone --bare into caller's namespace, fresh `activity_feed` entry). Settings UI gains a "Mark as template" toggle. Public repo page renders a prominent "Use this template" CTA for non-owners.
272272- **I3** — Repository transfer → ✅ shipped. `drizzle/0022_repo_templates.sql` adds `repo_transfers` audit table. `src/routes/repo-settings.tsx` `POST /:owner/:repo/settings/transfer` (validate target user exists, reject name conflicts, update `owner_id`, log to `repo_transfers`).
273273- **I4** — Generic command palette → ✅ shipped. `src/views/layout.tsx` injects a Cmd+K palette with ~20 canonical destinations (Dashboard, Explore, Notifications, Ask AI, Create repo, Marketplace, Installed apps, Register app, Shortcuts, Settings, 2FA, Passkeys, PATs, Billing, Audit, Gists, GraphQL, Admin, Theme). Fuzzy-match, arrow-key navigation, Esc/backdrop to close.
274- **I5** — Code scanning UI → ✅ shipped. `src/routes/code-scanning.tsx` `GET /:owner/:repo/security` aggregates `gate_runs` matching `%scan%`/`%security%` (last 100), computes latest-per-gate status, renders failed/repaired/total summary cards + per-scanner status list + recent-runs table. Private-repo visibility enforced. Zero new tables — pure surfacing layer.
275- **I6** — Sponsors → ✅ shipped. `drizzle/0023_sponsors.sql` adds `sponsorship_tiers` (maintainer_id, name, monthly_cents, one_time_allowed, is_active) + `sponsorships` (sponsor_id, maintainer_id, tier_id, amount_cents, kind, note, is_public, cancelled_at). `src/routes/sponsors.tsx` serves public `/sponsors/:username` (tier cards + recent public sponsors join) + maintainer `/settings/sponsors` (tier CRUD, soft-retire via is_active=false, activity list). Payment rails deferred — v1 captures intent + thank-you notes.
276- **I7** — Weekly email digest → ✅ shipped. `drizzle/0024_email_digest.sql` adds `users.notify_email_digest_weekly` + `last_digest_sent_at`. `src/lib/email-digest.ts` exposes `composeDigest`/`sendDigestForUser`/`sendDigestsToAll` (never-throws). Pulls notifications + failed/repaired gate_runs + merged PRs from the last 7d, composes escaped HTML + plaintext, and sends via the shared email provider. `/settings/digest/preview` renders the digest inline for self-preview; `/admin/digests` gives site admins a "Send now" trigger + single-user preview, audit-logged as `admin.digests.run`/`admin.digests.preview`.
274277
275278### BLOCK H — Marketplace
276279- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
286289- `src/app.tsx` — route composition, middleware order, error handlers
287290- `src/index.ts` — Bun server entry
288291- `src/lib/config.ts` — env getters (late-binding)
289- `src/db/schema.ts` — 79 tables. New tables only via new migration.
292- `src/db/schema.ts` — 81 tables. New tables only via new migration.
290293- `src/db/index.ts` — lazy proxy DB connection
291294- `src/db/migrate.ts` — migration runner
292295- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
309312- `drizzle/0020_analytics_and_admin.sql` (Block F) — migration, never edited in place. Adds `repo_traffic_events`, `system_flags`, `site_admins`, `billing_plans` (seeded free/pro/team/enterprise), `user_quotas`.
310313- `drizzle/0021_marketplace_and_apps.sql` (Block H) — migration, never edited in place. Adds `apps`, `app_installations` (partial unique index on `(app_id, target_type, target_id) WHERE uninstalled_at IS NULL`), `app_bots` (one-per-app, `<slug>[bot]` username), `app_install_tokens` (sha256 hash, expires_at, revoked_at), `app_events` (audit trail).
311314- `drizzle/0022_repo_templates.sql` (Block I2+I3) — migration, never edited in place. Adds `repositories.is_template` (partial index where true) + `repo_transfers` audit table.
315- `drizzle/0023_sponsors.sql` (Block I6) — migration, never edited in place. Adds `sponsorship_tiers` + `sponsorships` tables.
316- `drizzle/0024_email_digest.sql` (Block I7) — migration, never edited in place. Adds `users.notify_email_digest_weekly` + `users.last_digest_sent_at`.
312317
313318### 4.2 Git layer (locked)
314319- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
427432- `vscode-extension/` (Block G4) — VS Code extension with `package.json` declaring four commands (explainFile, openOnWeb, searchSemantic, generateTests) + `gluecron.host` / `gluecron.token` settings. Detects Gluecron remotes via `git config remote.origin.url`.
428433- `src/lib/marketplace.ts` (Block H1+H2) — marketplace + app identity surface. `KNOWN_PERMISSIONS` (10 scopes), `KNOWN_EVENTS` (8 kinds). Pure helpers: `slugify` (40-char cap), `botUsername` (`<slug>[bot]`), `normalisePermissions` (drops unknown, de-dupes), `parsePermissions` (JSON), `hasPermission` (write→read implication), `permissionsSubset`, `generateBearerToken` (`ghi_` prefix + 24-byte hex), `hashBearer` (sha256). DB helpers: `listPublicApps(query)`, `getAppBySlug`, `createApp` (retries slug collisions, creates matching bot row), `installApp` (idempotent soft-update), `uninstallApp` (revokes all tokens), `issueInstallToken` (1h TTL default), `verifyInstallToken` (checks revoked/expired/uninstalled/suspended), `listInstallationsForApp`, `listInstallationsForTarget`, `listEventsForApp`, `countInstalls`. Never throws into request path.
429434- `src/routes/marketplace.tsx` (Block H1+H2) — public marketplace + developer UX. `GET /marketplace` (directory + search), `GET /marketplace/:slug` (detail + install form), `POST /marketplace/:slug/install` (v1 user-target only), `POST /marketplace/installations/:id/uninstall` (installer-only), `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count, owner-only), `POST /developer/apps/:slug/tokens/new` (show-once `ghi_` token). All mutations audit-logged.
435- `src/routes/code-scanning.tsx` (Block I5) — `GET /:owner/:repo/security` (softAuth, private-repo visibility enforced). Aggregates last-100 scan-related `gate_runs`, builds `latestByName` map, renders summary cards + scanner status list + recent runs.
436- `src/routes/sponsors.tsx` (Block I6) — public `/sponsors/:username` + maintainer `/settings/sponsors` (requireAuth). Tier CRUD (`POST /settings/sponsors/tiers/new`, soft-retire via `is_active=false` on delete). Exports `sponsorshipTotalForUser(userId)` helper and `__internal.formatCents` for tests.
437- `src/lib/email-digest.ts` (Block I7) — `composeDigest(userId, since?)` (never throws, null on failure), `sendDigestForUser(userId)` (opt-out check + updates `last_digest_sent_at` on success), `sendDigestsToAll()` (iterates opted-in users). Pulls notifications + owned-repo gate_runs (failed/repaired) + merged PRs over last 7d. Builds text + escaped HTML body. Exports `__internal = { textToHtml, escapeHtml, fmtRange }` for tests.
438- `src/routes/admin.tsx` (extends Block F3 for I7) — adds `GET /admin/digests` (opted-in count + recently sent list), `POST /admin/digests/run` (calls `sendDigestsToAll`, audit-logged with counts), `POST /admin/digests/preview` (sends to one user by username, audit-logged). New "Email digests" tile on the /admin dashboard grid.
439- `src/routes/settings.tsx` (extends for I7) — adds `notify_email_digest_weekly` checkbox to email prefs + handler wiring in `POST /settings/notifications`, and `GET /settings/digest/preview` (renders `composeDigest` output inline via `raw(body.html)` with Hono's `hono/html`).
430440
431441### 4.7 Views (locked contracts)
432442- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
461471```bash
462472bun install
463473bun dev # hot reload
464bun test # 549 tests currently pass
474bun test # 570 tests currently pass
465475bun run db:migrate
466476```
467477
Addeddrizzle/0023_sponsors.sql+47−0View fileUnifiedSplit
1-- Gluecron migration 0023: Sponsors (Block I6).
2--
3-- Lightweight sponsorship model:
4-- sponsorship_tiers — the maintainer's published tiers (amount, description, benefit)
5-- sponsorships — ongoing or one-time support relationships
6--
7-- Payment rails are out of scope — we store the intent + any external
8-- provider/transaction reference. The UI surfaces a "Sponsor" button on user
9-- profiles that have at least one active tier.
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "sponsorship_tiers" (
13 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14 "maintainer_id" uuid NOT NULL,
15 "name" text NOT NULL, -- "Coffee", "Champion", "Patron"
16 "description" text NOT NULL DEFAULT '',
17 "monthly_cents" integer NOT NULL, -- 500 = $5/mo; 0 = one-time-only
18 "one_time_allowed" boolean NOT NULL DEFAULT true,
19 "is_active" boolean NOT NULL DEFAULT true,
20 "created_at" timestamp DEFAULT now() NOT NULL,
21 CONSTRAINT "sponsor_tiers_maintainer_fk" FOREIGN KEY ("maintainer_id") REFERENCES "users"("id") ON DELETE cascade
22);
23
24--> statement-breakpoint
25CREATE INDEX IF NOT EXISTS "sponsor_tiers_maintainer" ON "sponsorship_tiers" ("maintainer_id", "is_active");
26
27--> statement-breakpoint
28CREATE TABLE IF NOT EXISTS "sponsorships" (
29 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
30 "sponsor_id" uuid NOT NULL, -- the user paying
31 "maintainer_id" uuid NOT NULL, -- the user receiving
32 "tier_id" uuid, -- optional (custom amounts allowed)
33 "amount_cents" integer NOT NULL,
34 "kind" text NOT NULL, -- one_time | monthly
35 "note" text, -- public or private thank-you note
36 "is_public" boolean NOT NULL DEFAULT true,
37 "external_ref" text, -- stripe/provider txn id
38 "cancelled_at" timestamp,
39 "created_at" timestamp DEFAULT now() NOT NULL,
40 CONSTRAINT "sponsorships_sponsor_fk" FOREIGN KEY ("sponsor_id") REFERENCES "users"("id") ON DELETE cascade,
41 CONSTRAINT "sponsorships_maintainer_fk" FOREIGN KEY ("maintainer_id") REFERENCES "users"("id") ON DELETE cascade
42);
43
44--> statement-breakpoint
45CREATE INDEX IF NOT EXISTS "sponsorships_maintainer" ON "sponsorships" ("maintainer_id", "created_at");
46--> statement-breakpoint
47CREATE INDEX IF NOT EXISTS "sponsorships_sponsor" ON "sponsorships" ("sponsor_id", "created_at");
Addeddrizzle/0024_email_digest.sql+13−0View fileUnifiedSplit
1-- Gluecron migration 0024: Weekly email digest preference.
2--
3-- I7 — Opt-in weekly digest. Adds a single boolean column to `users` + a
4-- companion `last_digest_sent_at` timestamp so the cron job can skip users
5-- who've already received this week's digest (idempotent re-runs).
6
7--> statement-breakpoint
8ALTER TABLE "users"
9 ADD COLUMN IF NOT EXISTS "notify_email_digest_weekly" boolean NOT NULL DEFAULT false;
10
11--> statement-breakpoint
12ALTER TABLE "users"
13 ADD COLUMN IF NOT EXISTS "last_digest_sent_at" timestamp;
Addedsrc/__tests__/code-scanning.test.ts+22−0View fileUnifiedSplit
1/**
2 * Block I5 — Code scanning UI tests.
3 *
4 * The route uses softAuth + hits the DB on every request, so without a live
5 * DATABASE_URL we can't exercise the 404 path in unit tests. We verify the
6 * route is mounted by asserting that `/:owner/:repo/security` produces a
7 * response (any status) rather than being swallowed by an unrelated handler.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12
13describe("code-scanning — route mount", () => {
14 it("GET /:owner/:repo/security is handled (not swallowed)", async () => {
15 const res = await app.request(
16 "/__does_not_exist_user__/__nope__/security"
17 );
18 // Without a DB connection the handler 500s. With a DB it returns 404.
19 // Either proves the route was reached.
20 expect([404, 500]).toContain(res.status);
21 });
22});
Addedsrc/__tests__/email-digest.test.ts+109−0View fileUnifiedSplit
1/**
2 * Block I7 — Weekly email digest tests.
3 *
4 * Pure helper coverage for textToHtml / escapeHtml / fmtRange, plus route auth
5 * smoke on /settings/digest/preview and the admin trigger endpoints. DB-backed
6 * calls (composeDigest / sendDigestForUser / sendDigestsToAll) are exercised
7 * against the live server.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import { __internal } from "../lib/email-digest";
13
14const { textToHtml, escapeHtml, fmtRange } = __internal;
15
16describe("email-digest — escapeHtml", () => {
17 it("escapes <, >, & and quotes", () => {
18 expect(escapeHtml(`<a href="x">&</a>`)).toBe(
19 `&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;`
20 );
21 });
22
23 it("leaves plain text alone", () => {
24 expect(escapeHtml("hello world")).toBe("hello world");
25 });
26});
27
28describe("email-digest — fmtRange", () => {
29 it("returns a single date when from === to (by day)", () => {
30 const d = new Date("2025-06-01T00:00:00Z");
31 expect(fmtRange(d, d)).toBe("2025-06-01");
32 });
33
34 it("joins distinct dates with arrow", () => {
35 const a = new Date("2025-06-01T00:00:00Z");
36 const b = new Date("2025-06-08T00:00:00Z");
37 const out = fmtRange(a, b);
38 expect(out).toContain("2025-06-01");
39 expect(out).toContain("2025-06-08");
40 expect(out).toContain("\u2192");
41 });
42});
43
44describe("email-digest — textToHtml", () => {
45 it("wraps H2 headings and list items", () => {
46 const html = textToHtml("## Section\n- item 1", "https://gluecron.com");
47 expect(html).toContain("<h3");
48 expect(html).toContain("Section");
49 expect(html).toContain("<li>item 1</li>");
50 });
51
52 it("renders <hr> for --- separator", () => {
53 const html = textToHtml("hello\n---\nfooter", "https://gluecron.com");
54 expect(html).toContain("<hr");
55 });
56
57 it("escapes user-controlled text in paragraphs", () => {
58 const html = textToHtml("<script>alert(1)</script>", "https://gluecron.com");
59 expect(html).not.toContain("<script>alert");
60 expect(html).toContain("&lt;script&gt;");
61 });
62
63 it("includes the base URL footer", () => {
64 const html = textToHtml("body", "https://gluecron.com");
65 expect(html).toContain("https://gluecron.com");
66 });
67});
68
69describe("email-digest — route auth", () => {
70 it("GET /settings/digest/preview without auth → 302 /login", async () => {
71 const res = await app.request("/settings/digest/preview");
72 expect(res.status).toBe(302);
73 expect(res.headers.get("location") || "").toContain("/login");
74 });
75
76 it("POST /admin/digests/run without auth → 302 /login", async () => {
77 const res = await app.request("/admin/digests/run", { method: "POST" });
78 expect(res.status).toBe(302);
79 expect(res.headers.get("location") || "").toContain("/login");
80 });
81
82 it("POST /admin/digests/preview without auth → 302 /login", async () => {
83 const res = await app.request("/admin/digests/preview", {
84 method: "POST",
85 body: new URLSearchParams({ username: "alice" }),
86 headers: { "content-type": "application/x-www-form-urlencoded" },
87 });
88 expect(res.status).toBe(302);
89 expect(res.headers.get("location") || "").toContain("/login");
90 });
91
92 it("GET /admin/digests without auth → 302 /login", async () => {
93 const res = await app.request("/admin/digests");
94 expect(res.status).toBe(302);
95 expect(res.headers.get("location") || "").toContain("/login");
96 });
97});
98
99describe("email-digest — settings form", () => {
100 it("POST /settings/notifications without auth → 302 /login", async () => {
101 const res = await app.request("/settings/notifications", {
102 method: "POST",
103 body: new URLSearchParams({ notify_email_digest_weekly: "1" }),
104 headers: { "content-type": "application/x-www-form-urlencoded" },
105 });
106 expect(res.status).toBe(302);
107 expect(res.headers.get("location") || "").toContain("/login");
108 });
109});
Addedsrc/__tests__/sponsors.test.ts+63−0View fileUnifiedSplit
1/**
2 * Block I6 — Sponsors tests.
3 *
4 * Pure tests for formatCents + route auth smoke for the maintainer settings
5 * routes (public sponsor page is intentionally ungated for reading).
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import { __internal } from "../routes/sponsors";
11
12const { formatCents } = __internal;
13
14describe("sponsors — formatCents", () => {
15 it("prints 'Any amount' for 0 cents", () => {
16 expect(formatCents(0)).toBe("Any amount");
17 });
18
19 it("formats 500 cents as $5.00", () => {
20 expect(formatCents(500)).toBe("$5.00");
21 });
22
23 it("formats 1234 cents as $12.34", () => {
24 expect(formatCents(1234)).toBe("$12.34");
25 });
26});
27
28describe("sponsors — route auth", () => {
29 it("GET /settings/sponsors without auth → 302 /login", async () => {
30 const res = await app.request("/settings/sponsors");
31 expect(res.status).toBe(302);
32 expect(res.headers.get("location") || "").toContain("/login");
33 });
34
35 it("POST /settings/sponsors/tiers/new without auth → 302 /login", async () => {
36 const res = await app.request("/settings/sponsors/tiers/new", {
37 method: "POST",
38 body: new URLSearchParams({ name: "Silver", monthly_cents: "500" }),
39 headers: { "content-type": "application/x-www-form-urlencoded" },
40 });
41 expect(res.status).toBe(302);
42 expect(res.headers.get("location") || "").toContain("/login");
43 });
44
45 it("POST /sponsors/:username without auth → 302 /login", async () => {
46 const res = await app.request("/sponsors/alice", {
47 method: "POST",
48 body: new URLSearchParams({ amount_cents: "500" }),
49 headers: { "content-type": "application/x-www-form-urlencoded" },
50 });
51 expect(res.status).toBe(302);
52 expect(res.headers.get("location") || "").toContain("/login");
53 });
54});
55
56describe("sponsors — public page", () => {
57 it("GET /sponsors/:unknown-user is handled (not swallowed)", async () => {
58 // Without a DB connection the handler 500s. With a DB it 404s via the
59 // global notFound handler. Either proves the route was reached.
60 const res = await app.request("/sponsors/__does_not_exist_12345__");
61 expect([404, 500]).toContain(res.status);
62 });
63});
Modifiedsrc/app.tsx+8−0View fileUnifiedSplit
6464import graphqlRoutes from "./routes/graphql";
6565import marketplaceRoutes from "./routes/marketplace";
6666import templatesRoutes from "./routes/templates";
67import codeScanningRoutes from "./routes/code-scanning";
68import sponsorsRoutes from "./routes/sponsors";
6769import webRoutes from "./routes/web";
6870
6971const app = new Hono();
218220// Template repositories — POST /:owner/:repo/use-template (Block I2)
219221app.route("/", templatesRoutes);
220222
223// Code scanning UI — /:owner/:repo/security (Block I5)
224app.route("/", codeScanningRoutes);
225
226// Sponsors — /sponsors/:user + /settings/sponsors (Block I6)
227app.route("/", sponsorsRoutes);
228
221229// Insights + milestones
222230app.route("/", insightsRoutes);
223231
Modifiedsrc/db/schema.ts+56−0View fileUnifiedSplit
2222 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
2323 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
2424 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
25 // Block I7 — weekly digest opt-in.
26 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
27 lastDigestSentAt: timestamp("last_digest_sent_at"),
2528 createdAt: timestamp("created_at").defaultNow().notNull(),
2629 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2730});
19141917);
19151918
19161919export type RepoTransfer = typeof repoTransfers.$inferSelect;
1920
1921// ---------- Block I6 — Sponsors ----------
1922
1923export const sponsorshipTiers = pgTable(
1924 "sponsorship_tiers",
1925 {
1926 id: uuid("id").primaryKey().defaultRandom(),
1927 maintainerId: uuid("maintainer_id")
1928 .notNull()
1929 .references(() => users.id, { onDelete: "cascade" }),
1930 name: text("name").notNull(),
1931 description: text("description").default("").notNull(),
1932 monthlyCents: integer("monthly_cents").notNull(),
1933 oneTimeAllowed: boolean("one_time_allowed").default(true).notNull(),
1934 isActive: boolean("is_active").default(true).notNull(),
1935 createdAt: timestamp("created_at").defaultNow().notNull(),
1936 },
1937 (table) => [
1938 index("sponsor_tiers_maintainer").on(table.maintainerId, table.isActive),
1939 ]
1940);
1941
1942export type SponsorshipTier = typeof sponsorshipTiers.$inferSelect;
1943
1944export const sponsorships = pgTable(
1945 "sponsorships",
1946 {
1947 id: uuid("id").primaryKey().defaultRandom(),
1948 sponsorId: uuid("sponsor_id")
1949 .notNull()
1950 .references(() => users.id, { onDelete: "cascade" }),
1951 maintainerId: uuid("maintainer_id")
1952 .notNull()
1953 .references(() => users.id, { onDelete: "cascade" }),
1954 tierId: uuid("tier_id"),
1955 amountCents: integer("amount_cents").notNull(),
1956 kind: text("kind").notNull(), // one_time | monthly
1957 note: text("note"),
1958 isPublic: boolean("is_public").default(true).notNull(),
1959 externalRef: text("external_ref"),
1960 cancelledAt: timestamp("cancelled_at"),
1961 createdAt: timestamp("created_at").defaultNow().notNull(),
1962 },
1963 (table) => [
1964 index("sponsorships_maintainer").on(
1965 table.maintainerId,
1966 table.createdAt
1967 ),
1968 index("sponsorships_sponsor").on(table.sponsorId, table.createdAt),
1969 ]
1970);
1971
1972export type Sponsorship = typeof sponsorships.$inferSelect;
Addedsrc/lib/email-digest.ts+312−0View fileUnifiedSplit
1/**
2 * Block I7 — Weekly email digest.
3 *
4 * Composes a per-user digest of activity over the last 7 days (or a custom
5 * window) and sends it via the shared email module. Run from a cron or
6 * manually via `POST /admin/digests/run`.
7 *
8 * Data sources:
9 * - notifications (unread + read-last-7d)
10 * - gate_runs (failed / repaired)
11 * - pull_requests (merged by the user's repos)
12 *
13 * Never throws — the caller can fire-and-forget.
14 */
15
16import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
17import { db } from "./../db";
18import {
19 gateRuns,
20 notifications,
21 pullRequests,
22 repositories,
23 users,
24} from "./../db/schema";
25import { sendEmail, type EmailResult } from "./email";
26import { config } from "./config";
27
28export interface DigestInput {
29 userId: string;
30 since?: Date;
31 /** When false, skip `sendEmail` and just compose. Used for preview. */
32 send?: boolean;
33}
34
35export interface DigestBody {
36 subject: string;
37 text: string;
38 html: string;
39 counts: {
40 notifications: number;
41 failedGates: number;
42 repairedGates: number;
43 mergedPrs: number;
44 };
45}
46
47function fmtRange(from: Date, to: Date): string {
48 const f = from.toISOString().slice(0, 10);
49 const t = to.toISOString().slice(0, 10);
50 return f === t ? f : `${f} \u2192 ${t}`;
51}
52
53export async function composeDigest(
54 userId: string,
55 since?: Date
56): Promise<DigestBody | null> {
57 const now = new Date();
58 const from = since || new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
59 try {
60 const [user] = await db
61 .select()
62 .from(users)
63 .where(eq(users.id, userId))
64 .limit(1);
65 if (!user) return null;
66
67 // Pull notifications
68 const notifs = await db
69 .select()
70 .from(notifications)
71 .where(
72 and(
73 eq(notifications.userId, userId),
74 gte(notifications.createdAt, from)
75 )
76 )
77 .orderBy(desc(notifications.createdAt))
78 .limit(25);
79
80 // User's repos (owner only — org-aware digest can come later)
81 const ownedRepos = await db
82 .select({ id: repositories.id, name: repositories.name })
83 .from(repositories)
84 .where(eq(repositories.ownerId, userId));
85 const repoIds = ownedRepos.map((r) => r.id);
86
87 let failedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
88 let repairedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
89 let mergedPrs: Array<{ repoName: string; title: string }> = [];
90
91 if (repoIds.length > 0) {
92 const gates = await db
93 .select()
94 .from(gateRuns)
95 .where(
96 and(
97 inArray(gateRuns.repositoryId, repoIds),
98 gte(gateRuns.createdAt, from)
99 )
100 )
101 .orderBy(desc(gateRuns.createdAt))
102 .limit(50);
103 const byId = new Map(ownedRepos.map((r) => [r.id, r.name]));
104 for (const g of gates) {
105 const repoName = byId.get(g.repositoryId) || "?";
106 if (g.status === "failed") {
107 failedGates.push({
108 repoName,
109 gateName: g.gateName,
110 sha: g.commitSha.slice(0, 7),
111 });
112 } else if (g.status === "repaired") {
113 repairedGates.push({
114 repoName,
115 gateName: g.gateName,
116 sha: g.commitSha.slice(0, 7),
117 });
118 }
119 }
120
121 const merged = await db
122 .select()
123 .from(pullRequests)
124 .where(
125 and(
126 inArray(pullRequests.repositoryId, repoIds),
127 eq(pullRequests.state, "merged"),
128 gte(pullRequests.updatedAt, from)
129 )
130 )
131 .limit(25);
132 for (const pr of merged) {
133 mergedPrs.push({
134 repoName: byId.get(pr.repositoryId) || "?",
135 title: pr.title,
136 });
137 }
138 }
139
140 const counts = {
141 notifications: notifs.length,
142 failedGates: failedGates.length,
143 repairedGates: repairedGates.length,
144 mergedPrs: mergedPrs.length,
145 };
146
147 const base = config.appBaseUrl || "https://gluecron.com";
148 const subject = `Your Gluecron digest (${fmtRange(from, now)})`;
149 const lines: string[] = [];
150 lines.push(`Hi ${user.username},`);
151 lines.push("");
152 lines.push(`Here's what happened across your repos this week.`);
153 lines.push("");
154 lines.push(
155 `Notifications: ${counts.notifications} · Failed gates: ${counts.failedGates} · Auto-repaired: ${counts.repairedGates} · PRs merged: ${counts.mergedPrs}`
156 );
157 lines.push("");
158
159 if (notifs.length > 0) {
160 lines.push("## Notifications");
161 for (const n of notifs.slice(0, 10)) {
162 const when = new Date(n.createdAt).toLocaleDateString();
163 lines.push(`- [${n.kind}] ${n.title || "(untitled)"}${when}`);
164 }
165 lines.push("");
166 }
167
168 if (failedGates.length > 0) {
169 lines.push("## Failed gates");
170 for (const g of failedGates.slice(0, 10)) {
171 lines.push(`- ${g.repoName}${g.gateName} (${g.sha})`);
172 }
173 lines.push("");
174 }
175
176 if (repairedGates.length > 0) {
177 lines.push("## Auto-repairs");
178 for (const g of repairedGates.slice(0, 10)) {
179 lines.push(`- ${g.repoName}${g.gateName} (${g.sha})`);
180 }
181 lines.push("");
182 }
183
184 if (mergedPrs.length > 0) {
185 lines.push("## Merged PRs");
186 for (const pr of mergedPrs.slice(0, 10)) {
187 lines.push(`- ${pr.repoName}${pr.title}`);
188 }
189 lines.push("");
190 }
191
192 lines.push("---");
193 lines.push(
194 `You're receiving this because you opted into weekly digests. Manage at ${base}/settings.`
195 );
196
197 const text = lines.join("\n");
198 const html = textToHtml(text, base);
199 return { subject, text, html, counts };
200 } catch (err) {
201 console.error("[digest] composeDigest error:", err);
202 return null;
203 }
204}
205
206function textToHtml(text: string, base: string): string {
207 const lines = text.split("\n");
208 const out: string[] = [
209 `<html><body style="font-family:system-ui,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#111">`,
210 ];
211 for (const line of lines) {
212 if (line.startsWith("## ")) {
213 out.push(
214 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px">${escapeHtml(line.slice(3))}</h3>`
215 );
216 } else if (line.startsWith("- ")) {
217 out.push(`<li>${escapeHtml(line.slice(2))}</li>`);
218 } else if (line === "---") {
219 out.push(`<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />`);
220 } else if (line.trim() === "") {
221 out.push("<br>");
222 } else {
223 out.push(`<p>${escapeHtml(line)}</p>`);
224 }
225 }
226 out.push(
227 `<p style="font-size:12px;color:#777"><a href="${escapeHtml(base)}">${escapeHtml(base)}</a></p>`
228 );
229 out.push("</body></html>");
230 return out.join("\n");
231}
232
233function escapeHtml(s: string): string {
234 return s
235 .replace(/&/g, "&amp;")
236 .replace(/</g, "&lt;")
237 .replace(/>/g, "&gt;")
238 .replace(/"/g, "&quot;");
239}
240
241/** Compose + send for a single user. Records `last_digest_sent_at` on success. */
242export async function sendDigestForUser(
243 userId: string
244): Promise<EmailResult | { ok: false; provider: "none"; skipped: string }> {
245 try {
246 const [user] = await db
247 .select()
248 .from(users)
249 .where(eq(users.id, userId))
250 .limit(1);
251 if (!user) return { ok: false, provider: "none", skipped: "user not found" };
252 if (!user.notifyEmailDigestWeekly) {
253 return { ok: false, provider: "none", skipped: "opted out" };
254 }
255 const body = await composeDigest(userId);
256 if (!body) {
257 return { ok: false, provider: "none", skipped: "compose failed" };
258 }
259 const result = await sendEmail({
260 to: user.email,
261 subject: body.subject,
262 text: body.text,
263 html: body.html,
264 });
265 if (result.ok) {
266 await db
267 .update(users)
268 .set({ lastDigestSentAt: new Date() })
269 .where(eq(users.id, userId));
270 }
271 return result;
272 } catch (err) {
273 console.error("[digest] sendDigestForUser error:", err);
274 return { ok: false, provider: "none", skipped: "error" };
275 }
276}
277
278/** Iterates all opted-in users. Returns per-user results for logging. */
279export async function sendDigestsToAll(): Promise<
280 Array<{ userId: string; username: string; ok: boolean; skipped?: string }>
281> {
282 const results: Array<{
283 userId: string;
284 username: string;
285 ok: boolean;
286 skipped?: string;
287 }> = [];
288 try {
289 const opted = await db
290 .select({ id: users.id, username: users.username })
291 .from(users)
292 .where(eq(users.notifyEmailDigestWeekly, true));
293 for (const u of opted) {
294 const r = await sendDigestForUser(u.id);
295 results.push({
296 userId: u.id,
297 username: u.username,
298 ok: r.ok,
299 skipped: "skipped" in r ? r.skipped : undefined,
300 });
301 }
302 } catch (err) {
303 console.error("[digest] sendDigestsToAll error:", err);
304 }
305 return results;
306}
307
308/** Pure helper exported for tests. */
309export const __internal = { textToHtml, escapeHtml, fmtRange };
310
311// Keep sql unused-import warnings silent
312void sql;
Modifiedsrc/routes/admin.tsx+162−1View fileUnifiedSplit
3131 setFlag,
3232} from "../lib/admin";
3333import { audit } from "../lib/notify";
34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
3435
3536const admin = new Hono<AuthEnv>();
3637admin.use("*", softAuth);
99100 </div>
100101 </div>
101102
102 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:20px">
103 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:20px">
103104 <a href="/admin/users" class="btn">
104105 Manage users
105106 </a>
109110 <a href="/admin/flags" class="btn">
110111 Site flags
111112 </a>
113 <a href="/admin/digests" class="btn">
114 Email digests
115 </a>
112116 </div>
113117
114118 <h3>Recent signups</h3>
417421 return c.redirect("/admin/flags");
418422});
419423
424// ----- Email digests (Block I7) -----
425
426admin.get("/admin/digests", async (c) => {
427 const g = await gate(c);
428 if (g instanceof Response) return g;
429 const { user } = g;
430
431 const [optedRow] = await db
432 .select({ n: sql<number>`count(*)::int` })
433 .from(users)
434 .where(eq(users.notifyEmailDigestWeekly, true));
435 const opted = Number(optedRow?.n || 0);
436
437 const recentlySent = await db
438 .select({
439 id: users.id,
440 username: users.username,
441 lastDigestSentAt: users.lastDigestSentAt,
442 })
443 .from(users)
444 .where(sql`${users.lastDigestSentAt} is not null`)
445 .orderBy(desc(users.lastDigestSentAt))
446 .limit(20);
447
448 const result = c.req.query("result");
449 const error = c.req.query("error");
450
451 return c.html(
452 <Layout title="Admin — Digests" user={user}>
453 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
454 <h2>Email digests</h2>
455 <a href="/admin" class="btn btn-sm">
456 Back
457 </a>
458 </div>
459
460 {result && (
461 <div class="auth-success">{decodeURIComponent(result)}</div>
462 )}
463 {error && (
464 <div class="auth-error">{decodeURIComponent(error)}</div>
465 )}
466
467 <div class="panel" style="padding:16px;margin-bottom:20px">
468 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
469 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
470 </div>
471 <form method="POST" action="/admin/digests/run" style="margin-bottom:8px">
472 <button
473 type="submit"
474 class="btn btn-primary"
475 onclick="return confirm('Send weekly digest to all opted-in users now?')"
476 >
477 Send digests now
478 </button>
479 </form>
480 <form method="POST" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
481 <input
482 type="text"
483 name="username"
484 placeholder="username"
485 required
486 style="width:240px"
487 />
488 <button type="submit" class="btn btn-sm">
489 Send to one user
490 </button>
491 </form>
492 </div>
493
494 <h3>Recently sent</h3>
495 <div class="panel">
496 {recentlySent.length === 0 ? (
497 <div class="panel-empty">No digests have been sent yet.</div>
498 ) : (
499 recentlySent.map((u) => (
500 <div class="panel-item" style="justify-content:space-between">
501 <a href={`/${u.username}`}>{u.username}</a>
502 <span style="font-size:12px;color:var(--text-muted)">
503 {u.lastDigestSentAt
504 ? new Date(
505 u.lastDigestSentAt as unknown as string
506 ).toLocaleString()
507 : ""}
508 </span>
509 </div>
510 ))
511 )}
512 </div>
513 </Layout>
514 );
515});
516
517admin.post("/admin/digests/run", async (c) => {
518 const g = await gate(c);
519 if (g instanceof Response) return g;
520 const { user } = g;
521 const results = await sendDigestsToAll();
522 const sent = results.filter((r) => r.ok).length;
523 const skipped = results.length - sent;
524 await audit({
525 userId: user.id,
526 action: "admin.digests.run",
527 metadata: { sent, skipped, total: results.length },
528 });
529 return c.redirect(
530 `/admin/digests?result=${encodeURIComponent(
531 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
532 )}`
533 );
534});
535
536admin.post("/admin/digests/preview", async (c) => {
537 const g = await gate(c);
538 if (g instanceof Response) return g;
539 const { user } = g;
540 const body = await c.req.parseBody();
541 const username = String(body.username || "").trim();
542 if (!username) {
543 return c.redirect("/admin/digests?error=Username+required");
544 }
545 const [target] = await db
546 .select({ id: users.id, username: users.username })
547 .from(users)
548 .where(eq(users.username, username))
549 .limit(1);
550 if (!target) {
551 return c.redirect("/admin/digests?error=User+not+found");
552 }
553 const result = await sendDigestForUser(target.id);
554 await audit({
555 userId: user.id,
556 action: "admin.digests.preview",
557 targetType: "user",
558 targetId: target.id,
559 metadata: {
560 ok: result.ok,
561 skipped: "skipped" in result ? result.skipped : null,
562 },
563 });
564 if (result.ok) {
565 return c.redirect(
566 `/admin/digests?result=${encodeURIComponent(
567 `Digest sent to ${target.username}.`
568 )}`
569 );
570 }
571 return c.redirect(
572 `/admin/digests?error=${encodeURIComponent(
573 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
574 )}`
575 );
576});
577
578// Keep requireAuth import used even if some routes don't reference it here.
579void requireAuth;
580
420581export default admin;
Addedsrc/routes/code-scanning.tsx+221−0View fileUnifiedSplit
1/**
2 * Block I5 — Code scanning UI.
3 *
4 * GET /:owner/:repo/security
5 *
6 * Aggregates gate_runs where the gate name contains "scan" (Secret scan,
7 * Security scan, Dependency scan) and presents them as a clean alerts
8 * dashboard. Data already exists — this is a surfacing layer only.
9 */
10
11import { Hono } from "hono";
12import { and, desc, eq, or, sql } from "drizzle-orm";
13import { db } from "../db";
14import { gateRuns, repositories, users } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader, RepoNav } from "../views/components";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19
20const codeScanning = new Hono<AuthEnv>();
21codeScanning.use("*", softAuth);
22
23codeScanning.get("/:owner/:repo/security", async (c) => {
24 const { owner: ownerName, repo: repoName } = c.req.param();
25 const user = c.get("user");
26
27 const [ownerUser] = await db
28 .select()
29 .from(users)
30 .where(eq(users.username, ownerName))
31 .limit(1);
32 if (!ownerUser) return c.notFound();
33
34 const [repo] = await db
35 .select()
36 .from(repositories)
37 .where(
38 and(
39 eq(repositories.ownerId, ownerUser.id),
40 eq(repositories.name, repoName)
41 )
42 )
43 .limit(1);
44 if (!repo) return c.notFound();
45 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
46 return c.notFound();
47 }
48
49 // Pull the most recent 100 scan-related gate runs.
50 const runs = await db
51 .select()
52 .from(gateRuns)
53 .where(
54 and(
55 eq(gateRuns.repositoryId, repo.id),
56 or(
57 sql`lower(${gateRuns.gateName}) like '%scan%'`,
58 sql`lower(${gateRuns.gateName}) like '%security%'`
59 )!
60 )
61 )
62 .orderBy(desc(gateRuns.createdAt))
63 .limit(100);
64
65 // Summarize: latest status per gate, total alerts (failed + repaired).
66 const latestByName = new Map<
67 string,
68 { status: string; summary: string | null; sha: string; at: Date }
69 >();
70 for (const r of runs) {
71 if (!latestByName.has(r.gateName)) {
72 latestByName.set(r.gateName, {
73 status: r.status,
74 summary: r.summary,
75 sha: r.commitSha,
76 at: r.createdAt,
77 });
78 }
79 }
80
81 const failed = runs.filter((r) => r.status === "failed").length;
82 const repaired = runs.filter((r) => r.status === "repaired").length;
83
84 return c.html(
85 <Layout title={`Security — ${ownerName}/${repoName}`} user={user}>
86 <RepoHeader
87 owner={ownerName}
88 repo={repoName}
89 currentUser={user?.username}
90 archived={repo.isArchived}
91 isTemplate={repo.isTemplate}
92 />
93 <RepoNav owner={ownerName} repo={repoName} active="gates" />
94
95 <div style="display:flex;gap:12px;margin:20px 0">
96 <div
97 class="panel"
98 style="flex:1;padding:16px;text-align:center"
99 >
100 <div style="font-size:28px;font-weight:700">
101 {latestByName.size}
102 </div>
103 <div style="font-size:12px;color:var(--text-muted)">
104 Configured scanners
105 </div>
106 </div>
107 <div
108 class="panel"
109 style="flex:1;padding:16px;text-align:center"
110 >
111 <div
112 style={`font-size:28px;font-weight:700;color:${failed > 0 ? "var(--red)" : "var(--text)"}`}
113 >
114 {failed}
115 </div>
116 <div style="font-size:12px;color:var(--text-muted)">
117 Failed runs (last 100)
118 </div>
119 </div>
120 <div
121 class="panel"
122 style="flex:1;padding:16px;text-align:center"
123 >
124 <div style="font-size:28px;font-weight:700;color:var(--green)">
125 {repaired}
126 </div>
127 <div style="font-size:12px;color:var(--text-muted)">
128 Auto-repaired
129 </div>
130 </div>
131 </div>
132
133 <h3>Scanner status</h3>
134 <div class="panel" style="margin-bottom:20px">
135 {latestByName.size === 0 ? (
136 <div class="panel-empty">
137 No scan runs yet. Push a commit to trigger scanners.
138 </div>
139 ) : (
140 Array.from(latestByName.entries()).map(([name, info]) => (
141 <div class="panel-item" style="justify-content:space-between">
142 <div>
143 <div style="font-weight:600">{name}</div>
144 <div
145 style="font-size:12px;color:var(--text-muted);margin-top:2px"
146 >
147 {info.summary || "no summary"}
148 </div>
149 </div>
150 <div style="text-align:right">
151 <span
152 style={`font-size:11px;text-transform:uppercase;padding:2px 8px;border-radius:10px;background:${statusColor(info.status)};color:white`}
153 >
154 {info.status}
155 </span>
156 <div
157 style="font-size:11px;color:var(--text-muted);margin-top:4px"
158 >
159 <code>{info.sha.slice(0, 7)}</code> ·{" "}
160 {info.at.toLocaleDateString()}
161 </div>
162 </div>
163 </div>
164 ))
165 )}
166 </div>
167
168 <h3>Recent runs</h3>
169 <div class="panel">
170 {runs.length === 0 ? (
171 <div class="panel-empty">No runs.</div>
172 ) : (
173 runs.slice(0, 50).map((r) => (
174 <div class="panel-item" style="justify-content:space-between">
175 <div style="flex:1;min-width:0">
176 <code style="font-size:12px">{r.commitSha.slice(0, 7)}</code>{" "}
177 <span style="font-size:13px">{r.gateName}</span>
178 {r.summary && (
179 <div
180 style="font-size:12px;color:var(--text-muted);margin-top:2px"
181 >
182 {r.summary}
183 </div>
184 )}
185 </div>
186 <div style="text-align:right;white-space:nowrap">
187 <span
188 style={`font-size:11px;text-transform:uppercase;padding:2px 8px;border-radius:10px;background:${statusColor(r.status)};color:white`}
189 >
190 {r.status}
191 </span>
192 <div
193 style="font-size:11px;color:var(--text-muted);margin-top:2px"
194 >
195 {r.createdAt.toLocaleString()}
196 </div>
197 </div>
198 </div>
199 ))
200 )}
201 </div>
202 </Layout>
203 );
204});
205
206function statusColor(status: string): string {
207 switch (status) {
208 case "passed":
209 return "var(--green)";
210 case "failed":
211 return "var(--red)";
212 case "repaired":
213 return "var(--accent)";
214 case "skipped":
215 return "var(--text-muted)";
216 default:
217 return "var(--text-muted)";
218 }
219}
220
221export default codeScanning;
Modifiedsrc/routes/settings.tsx+58−1View fileUnifiedSplit
99import type { AuthEnv } from "../middleware/auth";
1010import { requireAuth } from "../middleware/auth";
1111import { Layout } from "../views/layout";
12import { composeDigest } from "../lib/email-digest";
13import { raw } from "hono/html";
1214
1315const settings = new Hono<AuthEnv>();
1416
108110 <span>I am assigned to an issue or PR</span>
109111 </label>
110112 <label
111 style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; font-size: 14px"
113 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
112114 >
113115 <input
114116 type="checkbox"
118120 />
119121 <span>A gate fails on one of my repositories</span>
120122 </label>
123 <label
124 style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; font-size: 14px"
125 >
126 <input
127 type="checkbox"
128 name="notify_email_digest_weekly"
129 value="1"
130 checked={user.notifyEmailDigestWeekly}
131 />
132 <span>
133 Weekly digest &mdash;{" "}
134 <a href="/settings/digest/preview">preview</a>
135 </span>
136 </label>
121137 <button type="submit" class="btn btn-primary">
122138 Save preferences
123139 </button>
127143 );
128144});
129145
146// Preview the weekly digest in-browser (rendered HTML)
147settings.get("/settings/digest/preview", async (c) => {
148 const user = c.get("user")!;
149 const body = await composeDigest(user.id);
150 if (!body) {
151 return c.html(
152 <Layout title="Digest preview" user={user}>
153 <h2>Digest preview</h2>
154 <p>Could not compose a digest right now.</p>
155 <p>
156 <a href="/settings">Back to settings</a>
157 </p>
158 </Layout>
159 );
160 }
161 return c.html(
162 <Layout title="Digest preview" user={user}>
163 <h2>Digest preview</h2>
164 <p style="color:var(--text-muted);font-size:13px">
165 Subject: <code>{body.subject}</code>
166 </p>
167 <p style="font-size:12px;color:var(--text-muted)">
168 Notifications: {body.counts.notifications} · Failed gates:{" "}
169 {body.counts.failedGates} · Repaired: {body.counts.repairedGates} ·
170 Merged PRs: {body.counts.mergedPrs}
171 </p>
172 <div
173 class="panel"
174 style="padding:20px;background:#fff;color:#111"
175 >
176 {raw(body.html)}
177 </div>
178 <p style="margin-top:20px">
179 <a href="/settings">Back to settings</a>
180 </p>
181 </Layout>
182 );
183});
184
130185settings.post("/settings/notifications", async (c) => {
131186 const user = c.get("user")!;
132187 const body = await c.req.parseBody();
137192 notifyEmailOnAssign: String(body.notify_email_on_assign || "") === "1",
138193 notifyEmailOnGateFail:
139194 String(body.notify_email_on_gate_fail || "") === "1",
195 notifyEmailDigestWeekly:
196 String(body.notify_email_digest_weekly || "") === "1",
140197 updatedAt: new Date(),
141198 })
142199 .where(eq(users.id, user.id));
Addedsrc/routes/sponsors.tsx+432−0View fileUnifiedSplit
1/**
2 * Block I6 — Sponsors.
3 *
4 * GET /sponsors/:username — public sponsor page
5 * GET /settings/sponsors — maintain your own tiers + activity
6 * POST /settings/sponsors/tiers/new — publish a tier
7 * POST /settings/sponsors/tiers/:id/delete — retire a tier
8 * POST /sponsors/:username — record a sponsorship
9 *
10 * Payment rails are out of scope — this captures intent + thank-you notes.
11 */
12
13import { Hono } from "hono";
14import { and, desc, eq, isNull, sql } from "drizzle-orm";
15import { db } from "../db";
16import {
17 sponsorships,
18 sponsorshipTiers,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24
25const sponsors = new Hono<AuthEnv>();
26sponsors.use("*", softAuth);
27
28function formatCents(cents: number): string {
29 if (cents === 0) return "Any amount";
30 const dollars = (cents / 100).toFixed(2);
31 return `$${dollars}`;
32}
33
34// ---------- Public sponsor page ----------
35
36sponsors.get("/sponsors/:username", async (c) => {
37 const user = c.get("user");
38 const targetName = c.req.param("username");
39 const [target] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, targetName))
43 .limit(1);
44 if (!target) return c.notFound();
45
46 const tiers = await db
47 .select()
48 .from(sponsorshipTiers)
49 .where(
50 and(
51 eq(sponsorshipTiers.maintainerId, target.id),
52 eq(sponsorshipTiers.isActive, true)
53 )
54 )
55 .orderBy(sponsorshipTiers.monthlyCents);
56
57 const recentPublic = await db
58 .select({
59 id: sponsorships.id,
60 amountCents: sponsorships.amountCents,
61 createdAt: sponsorships.createdAt,
62 note: sponsorships.note,
63 sponsorName: users.username,
64 })
65 .from(sponsorships)
66 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
67 .where(
68 and(
69 eq(sponsorships.maintainerId, target.id),
70 eq(sponsorships.isPublic, true),
71 isNull(sponsorships.cancelledAt)
72 )
73 )
74 .orderBy(desc(sponsorships.createdAt))
75 .limit(20);
76
77 return c.html(
78 <Layout title={`Sponsor ${targetName}`} user={user}>
79 <h2>Sponsor {targetName}</h2>
80 <p
81 style="font-size:14px;color:var(--text-muted);margin:8px 0 20px"
82 >
83 Support {targetName}'s open-source work on Gluecron.
84 </p>
85
86 {tiers.length === 0 ? (
87 <div class="panel" style="padding:16px">
88 <p style="margin-bottom:12px">
89 {targetName} hasn't published any sponsorship tiers yet.
90 </p>
91 {user ? (
92 <form method="POST" action={`/sponsors/${targetName}`}>
93 <input
94 type="number"
95 name="amount_cents"
96 placeholder="Amount in cents (e.g. 500 = $5)"
97 min="100"
98 required
99 style="width:60%"
100 />{" "}
101 <button type="submit" class="btn btn-primary">
102 Sponsor (one-time)
103 </button>
104 </form>
105 ) : (
106 <a href={`/login?next=/sponsors/${targetName}`}>
107 Sign in to sponsor
108 </a>
109 )}
110 </div>
111 ) : (
112 <div
113 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:20px"
114 >
115 {tiers.map((t) => (
116 <form
117 method="POST"
118 action={`/sponsors/${targetName}`}
119 class="panel"
120 style="padding:16px;display:flex;flex-direction:column;gap:8px"
121 >
122 <input type="hidden" name="tier_id" value={t.id} />
123 <div style="font-weight:700;font-size:16px">{t.name}</div>
124 <div style="font-size:22px;color:var(--accent);font-weight:700">
125 {formatCents(t.monthlyCents)}
126 {t.monthlyCents > 0 && (
127 <span style="font-size:13px;color:var(--text-muted);font-weight:400">
128 /mo
129 </span>
130 )}
131 </div>
132 <div
133 style="font-size:13px;color:var(--text-muted);flex:1"
134 >
135 {t.description || "\u2014"}
136 </div>
137 {user ? (
138 <>
139 <select name="kind" style="font-size:13px">
140 <option value="monthly">Monthly</option>
141 {t.oneTimeAllowed && (
142 <option value="one_time">One-time</option>
143 )}
144 </select>
145 <button type="submit" class="btn btn-primary">
146 Sponsor
147 </button>
148 </>
149 ) : (
150 <a
151 href={`/login?next=/sponsors/${targetName}`}
152 class="btn"
153 style="text-align:center"
154 >
155 Sign in to sponsor
156 </a>
157 )}
158 </form>
159 ))}
160 </div>
161 )}
162
163 <h3>Recent sponsors</h3>
164 <div class="panel">
165 {recentPublic.length === 0 ? (
166 <div class="panel-empty">Be the first to sponsor.</div>
167 ) : (
168 recentPublic.map((s) => (
169 <div class="panel-item" style="justify-content:space-between">
170 <div>
171 <a href={`/${s.sponsorName}`}>
172 <strong>{s.sponsorName}</strong>
173 </a>
174 {s.note && (
175 <span
176 style="margin-left:8px;font-size:13px;color:var(--text-muted)"
177 >
178 "{s.note}"
179 </span>
180 )}
181 </div>
182 <div
183 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
184 >
185 {formatCents(s.amountCents)} ·{" "}
186 {new Date(s.createdAt).toLocaleDateString()}
187 </div>
188 </div>
189 ))
190 )}
191 </div>
192 </Layout>
193 );
194});
195
196// Record a sponsorship
197sponsors.post("/sponsors/:username", requireAuth, async (c) => {
198 const user = c.get("user")!;
199 const targetName = c.req.param("username");
200 const [target] = await db
201 .select()
202 .from(users)
203 .where(eq(users.username, targetName))
204 .limit(1);
205 if (!target) return c.notFound();
206 if (target.id === user.id) {
207 return c.redirect(`/sponsors/${targetName}`);
208 }
209 const body = await c.req.parseBody();
210 const tierId = body.tier_id ? String(body.tier_id) : null;
211 let amountCents = 0;
212 let kind = String(body.kind || "one_time");
213 if (kind !== "monthly" && kind !== "one_time") kind = "one_time";
214
215 if (tierId) {
216 const [tier] = await db
217 .select()
218 .from(sponsorshipTiers)
219 .where(eq(sponsorshipTiers.id, tierId))
220 .limit(1);
221 if (!tier || tier.maintainerId !== target.id) {
222 return c.redirect(`/sponsors/${targetName}`);
223 }
224 amountCents = tier.monthlyCents;
225 } else {
226 amountCents = Math.max(0, parseInt(String(body.amount_cents || "0"), 10));
227 }
228 if (amountCents <= 0 && !tierId) {
229 return c.redirect(`/sponsors/${targetName}`);
230 }
231
232 await db.insert(sponsorships).values({
233 sponsorId: user.id,
234 maintainerId: target.id,
235 tierId: tierId || null,
236 amountCents,
237 kind,
238 note: body.note ? String(body.note).slice(0, 200) : null,
239 isPublic: body.is_public !== "0",
240 });
241 return c.redirect(`/sponsors/${targetName}?thanks=1`);
242});
243
244// ---------- Maintainer settings ----------
245
246sponsors.get("/settings/sponsors", requireAuth, async (c) => {
247 const user = c.get("user")!;
248 const [tiers, activity] = await Promise.all([
249 db
250 .select()
251 .from(sponsorshipTiers)
252 .where(eq(sponsorshipTiers.maintainerId, user.id))
253 .orderBy(sponsorshipTiers.monthlyCents),
254 db
255 .select({
256 id: sponsorships.id,
257 amountCents: sponsorships.amountCents,
258 kind: sponsorships.kind,
259 createdAt: sponsorships.createdAt,
260 sponsorName: users.username,
261 })
262 .from(sponsorships)
263 .innerJoin(users, eq(sponsorships.sponsorId, users.id))
264 .where(eq(sponsorships.maintainerId, user.id))
265 .orderBy(desc(sponsorships.createdAt))
266 .limit(50),
267 ]);
268 const total = activity.reduce((sum, s) => sum + s.amountCents, 0);
269 return c.html(
270 <Layout title="Sponsorship settings" user={user}>
271 <h2>Sponsorship</h2>
272 <p style="color:var(--text-muted);margin-bottom:16px">
273 Your public sponsor page is at{" "}
274 <a href={`/sponsors/${user.username}`}>/sponsors/{user.username}</a>.
275 </p>
276
277 <div class="panel" style="padding:16px;margin-bottom:20px">
278 <div style="font-size:12px;color:var(--text-muted)">
279 Total received
280 </div>
281 <div style="font-size:24px;font-weight:700">
282 {formatCents(total)}
283 </div>
284 </div>
285
286 <h3>Tiers</h3>
287 <div class="panel" style="margin-bottom:20px">
288 {tiers.length === 0 ? (
289 <div class="panel-empty">
290 No tiers yet. Add one below to start accepting support.
291 </div>
292 ) : (
293 tiers.map((t) => (
294 <div class="panel-item" style="justify-content:space-between">
295 <div>
296 <div style="font-weight:600">{t.name}</div>
297 <div
298 style="font-size:12px;color:var(--text-muted);margin-top:2px"
299 >
300 {formatCents(t.monthlyCents)}/mo ·{" "}
301 {t.description || "no description"}
302 </div>
303 </div>
304 <form
305 method="POST"
306 action={`/settings/sponsors/tiers/${t.id}/delete`}
307 onsubmit="return confirm('Retire this tier?')"
308 >
309 <button type="submit" class="btn btn-sm btn-danger">
310 Retire
311 </button>
312 </form>
313 </div>
314 ))
315 )}
316 </div>
317
318 <h3>Add a tier</h3>
319 <form
320 method="POST"
321 action="/settings/sponsors/tiers/new"
322 class="panel"
323 style="padding:16px"
324 >
325 <div class="form-group">
326 <label>Name</label>
327 <input type="text" name="name" required style="width:100%" />
328 </div>
329 <div class="form-group">
330 <label>Description</label>
331 <textarea name="description" rows="2" style="width:100%" />
332 </div>
333 <div class="form-group">
334 <label>Monthly amount (cents)</label>
335 <input
336 type="number"
337 name="monthly_cents"
338 min="0"
339 placeholder="500 = $5/mo"
340 required
341 />
342 </div>
343 <button type="submit" class="btn btn-primary">
344 Add tier
345 </button>
346 </form>
347
348 <h3 style="margin-top:24px">Recent activity</h3>
349 <div class="panel">
350 {activity.length === 0 ? (
351 <div class="panel-empty">No sponsors yet.</div>
352 ) : (
353 activity.map((a) => (
354 <div class="panel-item" style="justify-content:space-between">
355 <div>
356 <a href={`/${a.sponsorName}`}>{a.sponsorName}</a>
357 <span
358 style="margin-left:8px;font-size:12px;color:var(--text-muted)"
359 >
360 {a.kind}
361 </span>
362 </div>
363 <div
364 style="font-size:12px;color:var(--text-muted);white-space:nowrap"
365 >
366 {formatCents(a.amountCents)} ·{" "}
367 {new Date(a.createdAt).toLocaleDateString()}
368 </div>
369 </div>
370 ))
371 )}
372 </div>
373 </Layout>
374 );
375});
376
377sponsors.post("/settings/sponsors/tiers/new", requireAuth, async (c) => {
378 const user = c.get("user")!;
379 const body = await c.req.parseBody();
380 const name = String(body.name || "").trim();
381 if (!name) return c.redirect("/settings/sponsors");
382 const monthlyCents = Math.max(
383 0,
384 parseInt(String(body.monthly_cents || "0"), 10)
385 );
386 await db.insert(sponsorshipTiers).values({
387 maintainerId: user.id,
388 name,
389 description: String(body.description || ""),
390 monthlyCents,
391 });
392 return c.redirect("/settings/sponsors");
393});
394
395sponsors.post(
396 "/settings/sponsors/tiers/:id/delete",
397 requireAuth,
398 async (c) => {
399 const user = c.get("user")!;
400 const id = c.req.param("id");
401 await db
402 .update(sponsorshipTiers)
403 .set({ isActive: false })
404 .where(
405 and(
406 eq(sponsorshipTiers.id, id),
407 eq(sponsorshipTiers.maintainerId, user.id)
408 )
409 );
410 return c.redirect("/settings/sponsors");
411 }
412);
413
414// Handy stat helper for other pages
415export async function sponsorshipTotalForUser(
416 userId: string
417): Promise<number> {
418 try {
419 const [r] = await db
420 .select({ n: sql<number>`coalesce(sum(${sponsorships.amountCents}), 0)::int` })
421 .from(sponsorships)
422 .where(eq(sponsorships.maintainerId, userId));
423 return Number(r?.n || 0);
424 } catch {
425 return 0;
426 }
427}
428
429/** Test-only hook. */
430export const __internal = { formatCents };
431
432export default sponsors;
0433