Commit534f04aunknown_key
feat(BLOCK-M): live demo + mobile-class PWA + PR risk score + stale sweep
feat(BLOCK-M): live demo + mobile-class PWA + PR risk score + stale sweep
Four parallel sub-blocks shipping the next layer of "smoke GitHub for
the AI-native developer" — landing-page proof, mobile parity, reviewer
co-pilot, repo hygiene.
M1 — Live-demo no-signup hero on /
src/views/landing.tsx + src/routes/web.tsx — adds a "Live now"
4-card grid (queued for AI / recently merged by AI / AI reviews
posted / activity feed) under the existing hero. SSR populates
from the L3 demo-activity helpers; inline JS poller refreshes
every 30s + on visibility change, with relative-time updates,
number tickers, and a soft green flash on new rows. Pulse-dot
"live" indicator next to the section heading. 18 new tests
(landing-live-feed.test.ts) covering empty state, mount, time
formatter edges.
M2 — Mobile-class PWA
drizzle/0043_push_subscriptions.sql + src/lib/push.ts +
src/routes/pwa.ts + src/views/layout.tsx + src/routes/settings.tsx
+ DEPLOY.md.
• Web Push end-to-end without an npm dep: VAPID ES256 JWT signing
+ RFC 8291/8188 aes128gcm payload encryption, all via Web Crypto.
• New table `push_subscriptions` + four `notify_push_on_*` columns
on users. Idempotent subscribe by (user_id, endpoint). Stale
endpoints deleted on 410 Gone.
• Routes: GET /pwa/vapid-public-key (public), POST /pwa/subscribe
(requireAuth, 201), POST /pwa/unsubscribe (requireAuth, 204),
POST /pwa/test (requireAuth).
• Smart install banner script gated on user-authed + 3+ visits +
14-day dismiss cooldown.
• Extended SW (/sw-push.js) handles push + notificationclick +
offline HTML fallback (/offline.html).
• Per-event push prefs in /settings ("Mobile push notifications"
section with subscribe/unsubscribe/test + four kind checkboxes).
• 14 new tests (push.test.ts).
pushFromNotification helper is exported but not yet wired into
the locked notify.ts call sites — flagged as a follow-up.
M3 — AI pre-merge risk score
drizzle/0044_pr_risk_scores.sql + src/lib/pr-risk.ts +
src/routes/pulls.tsx + src/lib/auto-merge.ts + src/lib/autopilot.ts
+ src/lib/mcp-tools.ts.
• Pure formula computePrRiskScore(signals) → {score:0-10, band}.
Constants documented inline. Signals: filesChanged, lines,
teamsAffected (via CODEOWNERS), schemaMigrationTouched,
lockedPathTouched, addsNewDependency, bumpsMajorDependency,
testsAddedForNewCode, diffMinusTestRatio.
• Haiku writes a 1-3 sentence prose summary; deterministic fallback
when no API key.
• Cached in pr_risk_scores keyed on (pull_request_id, commit_sha).
• Color-coded card on PR detail page (low=green, medium=yellow,
high=orange, critical=red) with signal-breakdown disclosure.
• Auto-merge integration: critical band blocks auto-merge with
a clear reason. MCP gluecron_merge_pr soft-blocks critical
unless `confirm_high_risk: true`.
• New autopilot task `pr-risk-rescore` pre-computes scores for
PRs pushed-to in the last hour, cap 20/tick.
• 22 new tests (pr-risk.test.ts).
M5 — Stale PR / issue sweeper
drizzle/0045_auto_close_stale.sql + src/lib/stale-sweep.ts +
src/lib/autopilot.ts + src/routes/repo-settings.tsx +
src/lib/notify.ts.
• Two new autopilot tasks: stale-pr-sweep (7d-no-activity poke +
14d-no-activity-after-poke auto-close) and stale-issue-sweep
(30d / 60d).
• Four versioned markers (gluecron:stale-poke:v1,
gluecron:stale-close:v1, gluecron:stale-issue-poke:v1,
gluecron:stale-issue-close:v1) make re-runs idempotent.
• Per-repo settings checkboxes
(repositories.auto_close_stale_prs / auto_close_stale_issues,
default true).
• NotificationKind extended with "pr_stale" + "issue_stale".
• 28 new tests (stale-sweep.test.ts).
Tests: 1583 pass / 0 fail / 2 skip across 120 files, stable across
three consecutive runs. Net +82 tests over the pre-BLOCK-M baseline.
Migrations 0043, 0044, 0045 are all strictly additive. No locked
file in §4 had its semantics changed; all touches are either new
exports, new columns, or new autopilot tasks at end of defaultTasks().23 files changed+5885−8534f04aa1159c835e18784cd9326b9aa948ca618
23 changed files+5885−8
ModifiedDEPLOY.md+9−0View fileUnifiedSplit
@@ -86,6 +86,15 @@ Full reference is in [`.env.example`](./.env.example); cross-reference BUILD_BIB
8686| `AUTOPILOT_DISABLED=1` | Opt out of the 5-minute autopilot ticker (mirror sync, merge-queue processing, weekly digests, advisory rescans). Default: enabled. |
8787| `DEMO_SEED_ON_BOOT=1` | Idempotently create a `demo` user plus three public sample repos (`hello-python`, `todo-api`, `design-docs`) on server start. Safe to leave enabled — a second run is a near-instant no-op. Site admins can also trigger a reseed manually from `/admin`. |
8888
89### Web Push / PWA (Block M2)
90| Variable | Purpose |
91|---|---|
92| `VAPID_PUBLIC_KEY` | Base64url-encoded uncompressed P-256 public key (65 bytes). Sent to browsers as the `applicationServerKey` for `pushManager.subscribe()`. **If unset, a fresh keypair is generated in-memory at first use and every restart invalidates all existing subscriptions — production must set this.** |
93| `VAPID_PRIVATE_KEY` | Base64url-encoded raw 32-byte private scalar paired with `VAPID_PUBLIC_KEY`. Used to ES256-sign the per-delivery JWT. Treat as a secret. |
94| `VAPID_SUBJECT` | `mailto:` or `https:` URL identifying the app, included in every VAPID JWT. Defaults to `mailto:ops@gluecron.com`. |
95
96To generate a fresh pair locally, run a one-off Bun snippet using Web Crypto's `subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])` and base64url-encode the raw public key + the JWK private scalar (`d`). The boot log emits the generated public key on first push send when env vars are absent.
97
8998---
9099
91100## 4. Database migrations
Addeddrizzle/0043_push_subscriptions.sql+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1-- Block M2 — Mobile-class PWA: Web Push subscriptions.
2--
3-- Persists a Web Push subscription per user / per device. The `endpoint`
4-- is unique per browser instance and acts as the identifier we POST to when
5-- delivering a notification. `p256dh` + `auth` are the standard W3C keys
6-- needed to encrypt the payload for the recipient. `user_agent` is stored
7-- for the "Subscribed on this device" UI string.
8--
9-- Strictly additive. No existing table touched. Per-event push preference
10-- columns are appended to `users` so the same `notify` call site can decide
11-- whether to fan out a push based on the user's choices.
12
13CREATE TABLE IF NOT EXISTS "push_subscriptions" (
14 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
15 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
16 "endpoint" text NOT NULL,
17 "p256dh" text NOT NULL,
18 "auth" text NOT NULL,
19 "user_agent" text,
20 "created_at" timestamptz NOT NULL DEFAULT now(),
21 "last_used_at" timestamptz,
22 UNIQUE ("user_id", "endpoint")
23);
24
25CREATE INDEX IF NOT EXISTS "idx_push_subscriptions_user"
26 ON "push_subscriptions" ("user_id");
27
28ALTER TABLE "users"
29 ADD COLUMN IF NOT EXISTS "notify_push_on_mention" boolean NOT NULL DEFAULT true,
30 ADD COLUMN IF NOT EXISTS "notify_push_on_assign" boolean NOT NULL DEFAULT true,
31 ADD COLUMN IF NOT EXISTS "notify_push_on_review_request" boolean NOT NULL DEFAULT true,
32 ADD COLUMN IF NOT EXISTS "notify_push_on_deploy_failed" boolean NOT NULL DEFAULT true;
Addeddrizzle/0044_pr_risk_scores.sql+24−0View fileUnifiedSplit
@@ -0,0 +1,24 @@
1-- Block M3 — AI pre-merge risk score.
2--
3-- Cache of computed PR risk scores keyed on (pull_request_id, commit_sha).
4-- The score formula is documented in src/lib/pr-risk.ts and runs over a
5-- transparent set of signals; the AI summary is the only LLM-produced
6-- field. Storing both fields lets us re-render the badge cheaply on every
7-- PR detail view, while still letting reviewers re-trigger recomputation
8-- when a new push arrives.
9--
10-- Strictly additive: no existing tables touched.
11
12CREATE TABLE IF NOT EXISTS "pr_risk_scores" (
13 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
14 "pull_request_id" uuid NOT NULL REFERENCES "pull_requests"("id") ON DELETE CASCADE,
15 "commit_sha" text NOT NULL,
16 "score" integer NOT NULL,
17 "band" text NOT NULL,
18 "signals" jsonb NOT NULL,
19 "ai_summary" text,
20 "generated_at" timestamptz NOT NULL DEFAULT now(),
21 UNIQUE ("pull_request_id", "commit_sha")
22);
23
24CREATE INDEX IF NOT EXISTS "pr_risk_scores_pr_idx" ON "pr_risk_scores" ("pull_request_id");
Addeddrizzle/0045_auto_close_stale.sql+6−0View fileUnifiedSplit
@@ -0,0 +1,6 @@
1-- Block M5: Stale PR/issue sweeper — per-repo opt-out flags.
2-- Additive only. Defaults to `true` so the autopilot two-stage close
3-- runs on every existing repo unless the owner explicitly disables it.
4ALTER TABLE repositories
5 ADD COLUMN IF NOT EXISTS auto_close_stale_prs boolean NOT NULL DEFAULT true,
6 ADD COLUMN IF NOT EXISTS auto_close_stale_issues boolean NOT NULL DEFAULT true;
Addedsrc/__tests__/landing-live-feed.test.ts+351−0View fileUnifiedSplit
@@ -0,0 +1,351 @@
1/**
2 * Block M1 — Live-now landing feed tests.
3 *
4 * Covers:
5 * - The new "Live now" section renders with SSR data
6 * - Empty-state copy renders when all four endpoints return empty
7 * - The inline poller script is present + references the four
8 * `/api/v2/demo/*` endpoints + `setInterval`
9 * - `relativeTimeFromNow` handles <60s, <60m, <24h, >24h, future, NaN,
10 * null, undefined, Date, ISO string, number
11 *
12 * DB-mocking strategy: spread-from-real (K1 pattern). We capture the
13 * real `../lib/demo-activity` module *before* installing `mock.module`
14 * so every other export keeps working, then restore in `afterAll`.
15 * The five listing helpers are replaced with deterministic stubs that
16 * a test can mutate via `setLiveFeedStubs`.
17 */
18
19import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
20import {
21 relativeTimeFromNow,
22 LandingPage,
23 type LandingLiveFeed,
24} from "../views/landing";
25
26// ---------------------------------------------------------------------
27// Spread-from-real stubs for the demo-activity helpers.
28// ---------------------------------------------------------------------
29
30const _real_demoActivity = await import("../lib/demo-activity");
31
32let _stubQueued: any[] = [];
33let _stubMerges: any[] = [];
34let _stubReviews: any[] = [];
35let _stubReviewCount = 0;
36let _stubFeed: any[] = [];
37
38function setLiveFeedStubs(opts: {
39 queued?: any[];
40 merges?: any[];
41 reviews?: any[];
42 reviewCount?: number;
43 feed?: any[];
44}): void {
45 if (opts.queued !== undefined) _stubQueued = opts.queued;
46 if (opts.merges !== undefined) _stubMerges = opts.merges;
47 if (opts.reviews !== undefined) _stubReviews = opts.reviews;
48 if (opts.reviewCount !== undefined) _stubReviewCount = opts.reviewCount;
49 if (opts.feed !== undefined) _stubFeed = opts.feed;
50}
51
52mock.module("../lib/demo-activity", () => ({
53 ..._real_demoActivity,
54 listQueuedAiBuildIssues: async () => _stubQueued,
55 listRecentAutoMerges: async () => _stubMerges,
56 listRecentAiReviews: async () => _stubReviews,
57 countAiReviewsSince: async () => _stubReviewCount,
58 listDemoActivityFeed: async () => _stubFeed,
59}));
60
61afterAll(() => {
62 _stubQueued = [];
63 _stubMerges = [];
64 _stubReviews = [];
65 _stubReviewCount = 0;
66 _stubFeed = [];
67 // Best-effort restoration. Downstream test files that already
68 // imported demo-activity have their bindings cached — this is a
69 // hygiene measure for files using dynamic imports.
70 mock.module("../lib/demo-activity", () => _real_demoActivity);
71});
72
73beforeEach(() => {
74 _stubQueued = [];
75 _stubMerges = [];
76 _stubReviews = [];
77 _stubReviewCount = 0;
78 _stubFeed = [];
79});
80
81// ---------------------------------------------------------------------
82// SSR fixtures — used by the JSX-level renders below.
83// ---------------------------------------------------------------------
84
85const FIVE_MIN_AGO = new Date(Date.now() - 5 * 60 * 1000);
86const TWO_HOUR_AGO = new Date(Date.now() - 2 * 60 * 60 * 1000);
87
88const FULL_LIVE_FEED: LandingLiveFeed = {
89 queued: [
90 {
91 repo: "todo-api",
92 number: 42,
93 title: "Add JSON export",
94 createdAt: FIVE_MIN_AGO,
95 },
96 {
97 repo: "hello-python",
98 number: 7,
99 title: "Wire up CLI flag",
100 createdAt: FIVE_MIN_AGO,
101 },
102 ],
103 merges: [
104 {
105 repo: "todo-api",
106 number: 88,
107 title: "Fix flake in test",
108 mergedAt: FIVE_MIN_AGO,
109 },
110 ],
111 reviews: [
112 {
113 repo: "todo-api",
114 prNumber: 91,
115 commentSnippet: "Looks good, minor nit on naming.",
116 createdAt: TWO_HOUR_AGO,
117 },
118 ],
119 reviewCount: 47,
120 feed: [
121 {
122 kind: "auto_merge.merged",
123 repo: "todo-api",
124 ref: { type: "pr", number: 88 },
125 at: FIVE_MIN_AGO,
126 },
127 {
128 kind: "ai_review.posted",
129 repo: "todo-api",
130 ref: { type: "pr", number: 91 },
131 at: TWO_HOUR_AGO,
132 },
133 {
134 kind: "ai_build.dispatched",
135 repo: "hello-python",
136 ref: { type: "issue", number: 7 },
137 at: FIVE_MIN_AGO,
138 },
139 ],
140};
141
142const EMPTY_LIVE_FEED: LandingLiveFeed = {
143 queued: [],
144 merges: [],
145 reviews: [],
146 reviewCount: 0,
147 feed: [],
148};
149
150// `LandingPage` is a JSX function component; calling it directly returns
151// a hono/jsx node whose `.toString()` is the rendered HTML. We avoid
152// JSX literals in this `.ts` test file (Bun's test loader only enables
153// JSX parsing for `.tsx`) and instead invoke the component as a plain
154// function — semantically identical, no JSX transform required.
155async function renderLanding(props: {
156 liveFeed?: LandingLiveFeed | null;
157}): Promise<string> {
158 // hono/jsx components return a Promise<HtmlEscapedString> or an
159 // HtmlEscapedString — either way, awaiting + String() yields HTML.
160 // eslint-disable-next-line @typescript-eslint/no-explicit-any
161 const node: any = await (LandingPage as any)(props);
162 return String(node);
163}
164
165// ---------------------------------------------------------------------
166// Tests
167// ---------------------------------------------------------------------
168
169describe("Block M1 — Live-now section SSR", () => {
170 it("renders the Live now heading + pulse + endpoint script when liveFeed is present", async () => {
171 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
172 expect(html).toContain("Live now");
173 expect(html).toContain(
174 "Claude is working on demo repos as you read this."
175 );
176 // Pulse indicator class is the cheap "is the live block here" tell.
177 expect(html).toContain("landing-livenow-pulse");
178 expect(html).toContain("landing-livenow-grid");
179 });
180
181 it("renders the four card titles", async () => {
182 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
183 expect(html).toContain("Issues queued for AI");
184 expect(html).toContain("Recently merged by AI");
185 expect(html).toContain("AI reviews posted");
186 expect(html).toContain("Activity feed");
187 });
188
189 it("renders the queued issue + merge + review rows from the SSR snapshot", async () => {
190 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
191 expect(html).toContain("#42");
192 expect(html).toContain("Add JSON export");
193 expect(html).toContain("#88");
194 expect(html).toContain("Fix flake in test");
195 expect(html).toContain("#91");
196 expect(html).toContain("Looks good, minor nit on naming.");
197 // Review count visible.
198 expect(html).toContain("47");
199 expect(html).toContain("reviews today");
200 });
201
202 it("renders the activity feed entries with their kind labels", async () => {
203 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
204 expect(html).toContain("auto-merged");
205 expect(html).toContain("AI review posted");
206 expect(html).toContain("AI-build queued");
207 });
208
209 it("renders the CTA banner at the bottom of the section", async () => {
210 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
211 expect(html).toContain("Want this for your repos?");
212 expect(html).toContain('href="/register"');
213 expect(html).toContain('href="/demo"');
214 });
215
216 it("renders friendly empty states when all four endpoints return empty", async () => {
217 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
218 // The block still renders.
219 expect(html).toContain("Live now");
220 expect(html).toContain("landing-livenow-grid");
221 // Empty-state copy for each card.
222 expect(html).toContain("No queued AI builds");
223 expect(html).toContain("No auto-merges in the last 24h");
224 expect(html).toContain("No AI reviews in the last 24h");
225 expect(html).toContain("Quiet right now");
226 // Big-number count falls back to 0.
227 expect(html).toMatch(/data-tick-target="0"/);
228 });
229
230 it("still renders the live section when liveFeed is absent (null)", async () => {
231 const html = await renderLanding({ liveFeed: null });
232 expect(html).toContain("Live now");
233 expect(html).toContain("No queued AI builds");
234 });
235});
236
237describe("Block M1 — inline poller script", () => {
238 it("contains setInterval + the four /api/v2/demo/* endpoint URLs", async () => {
239 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
240 expect(html).toContain("setInterval");
241 expect(html).toContain("/api/v2/demo/queued");
242 expect(html).toContain("/api/v2/demo/merges");
243 expect(html).toContain("/api/v2/demo/reviews");
244 expect(html).toContain("/api/v2/demo/activity");
245 });
246
247 it("refreshes on visibilitychange (tab focus)", async () => {
248 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
249 expect(html).toContain("visibilitychange");
250 expect(html).toContain("visibilityState");
251 });
252
253 it("ticks numbers + flashes new rows", async () => {
254 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
255 // The implementation labels for the two motion effects.
256 expect(html).toContain("tickNumber");
257 expect(html).toContain("flashRow");
258 expect(html).toContain("landing-livecard-flash");
259 });
260});
261
262describe("Block M1 — relativeTimeFromNow helper edges", () => {
263 const NOW = 1_700_000_000_000;
264
265 it("returns 'just now' for deltas under 60 seconds", () => {
266 expect(relativeTimeFromNow(NOW - 5_000, NOW)).toBe("just now");
267 expect(relativeTimeFromNow(NOW - 59_000, NOW)).toBe("just now");
268 expect(relativeTimeFromNow(NOW, NOW)).toBe("just now");
269 });
270
271 it("returns 'about N minutes ago' under an hour", () => {
272 expect(relativeTimeFromNow(NOW - 60_000, NOW)).toBe("about 1 minute ago");
273 expect(relativeTimeFromNow(NOW - 5 * 60_000, NOW)).toBe(
274 "about 5 minutes ago"
275 );
276 expect(relativeTimeFromNow(NOW - 59 * 60_000, NOW)).toBe(
277 "about 59 minutes ago"
278 );
279 });
280
281 it("returns 'about N hours ago' under a day", () => {
282 expect(relativeTimeFromNow(NOW - 60 * 60_000, NOW)).toBe(
283 "about 1 hour ago"
284 );
285 expect(relativeTimeFromNow(NOW - 23 * 60 * 60_000, NOW)).toBe(
286 "about 23 hours ago"
287 );
288 });
289
290 it("returns 'about N days ago' beyond 24h", () => {
291 expect(relativeTimeFromNow(NOW - 24 * 60 * 60_000, NOW)).toBe(
292 "about 1 day ago"
293 );
294 expect(relativeTimeFromNow(NOW - 3 * 24 * 60 * 60_000, NOW)).toBe(
295 "about 3 days ago"
296 );
297 });
298
299 it("treats future timestamps as 'just now' (clock skew tolerance)", () => {
300 expect(relativeTimeFromNow(NOW + 5_000, NOW)).toBe("just now");
301 expect(relativeTimeFromNow(NOW + 10 * 60_000, NOW)).toBe("just now");
302 });
303
304 it("treats NaN / null / undefined / unparseable strings as 'just now'", () => {
305 expect(relativeTimeFromNow(NaN, NOW)).toBe("just now");
306 expect(relativeTimeFromNow(null, NOW)).toBe("just now");
307 expect(relativeTimeFromNow(undefined, NOW)).toBe("just now");
308 expect(relativeTimeFromNow("not a date", NOW)).toBe("just now");
309 });
310
311 it("accepts ISO strings + Date instances", () => {
312 expect(
313 relativeTimeFromNow(new Date(NOW - 2 * 60_000).toISOString(), NOW)
314 ).toBe("about 2 minutes ago");
315 expect(relativeTimeFromNow(new Date(NOW - 60 * 60_000), NOW)).toBe(
316 "about 1 hour ago"
317 );
318 });
319});
320
321describe("Block M1 — landing route integration", () => {
322 it("GET / renders the Live now block with stubbed demo-activity", async () => {
323 setLiveFeedStubs({
324 queued: [
325 {
326 repo: "todo-api",
327 number: 42,
328 title: "Add JSON export",
329 createdAt: new Date(),
330 },
331 ],
332 merges: [],
333 reviews: [],
334 reviewCount: 12,
335 feed: [],
336 });
337 const app = (await import("../app")).default;
338 const res = await app.request("/");
339 expect(res.status).toBe(200);
340 const body = await res.text();
341 // Section heading + pulse always render. Stubbed row data is
342 // intentionally NOT asserted: the production route may call into
343 // the real listing helpers via cached bindings rather than the
344 // mocked surface, so the universally reliable assertion is that
345 // the block + script are wired in.
346 expect(body).toContain("Live now");
347 expect(body).toContain("landing-livenow-grid");
348 expect(body).toContain("/api/v2/demo/queued");
349 expect(body).toContain("setInterval");
350 });
351});
Addedsrc/__tests__/pr-risk.test.ts+513−0View fileUnifiedSplit
@@ -0,0 +1,513 @@
1/**
2 * Block M3 — AI pre-merge risk score tests.
3 *
4 * Drives the pure helpers directly:
5 * - `computePrRiskScore` — six worked examples (one per band edge),
6 * caps, zero-floor, monotonicity.
7 * - `generatePrRiskSummary` — no-API-key fallback returns deterministic
8 * prose.
9 * - `computePrRiskForPullRequest` — null on non-existent PR (the DB
10 * surface is stubbed via the same K1-style spread-from-real pattern
11 * used in mcp-write.test.ts, so the test never touches Neon).
12 * - Cache hit path returns the same shape as cache miss.
13 *
14 * Mock policy: we spread the REAL modules first so non-overridden helpers
15 * stay live, then narrow each mock to the smallest surface. Originals are
16 * restored in `afterAll` so the mocks never bleed into sibling suites.
17 */
18
19import {
20 describe,
21 expect,
22 it,
23 mock,
24 afterAll,
25 beforeEach,
26} from "bun:test";
27
28import {
29 computePrRiskScore,
30 generatePrRiskSummary,
31 buildSignalsFromDiff,
32 isMajorBump,
33 __test,
34 type PrRiskSignals,
35} from "../lib/pr-risk";
36
37// ---------------------------------------------------------------------------
38// computePrRiskScore — formula + bands
39// ---------------------------------------------------------------------------
40
41function zeroSignals(): PrRiskSignals {
42 return {
43 filesChanged: 0,
44 linesAdded: 0,
45 linesRemoved: 0,
46 teamsAffected: 0,
47 schemaMigrationTouched: false,
48 lockedPathTouched: false,
49 addsNewDependency: false,
50 bumpsMajorDependency: false,
51 testsAddedForNewCode: false,
52 diffMinusTestRatio: 0,
53 };
54}
55
56describe("computePrRiskScore — six worked examples covering each band", () => {
57 it("LOW (1/10): tiny doc-only PR with tests", () => {
58 // 1 file × 0.3 = 0.3, 20 lines × 0.005 = 0.1, 1 team contributes 0,
59 // tests added so the test-penalty is zero. Total ≈ 0.4 → rounds to 0
60 // → band low.
61 const { score, band } = computePrRiskScore({
62 ...zeroSignals(),
63 filesChanged: 1,
64 linesAdded: 10,
65 linesRemoved: 10,
66 teamsAffected: 1,
67 testsAddedForNewCode: true,
68 diffMinusTestRatio: 0.0,
69 });
70 expect(band).toBe("low");
71 expect(score).toBeLessThanOrEqual(2);
72 });
73
74 it("LOW (2/10): moderate feature with tests", () => {
75 // 5 files × 0.3 = 1.5, 100 lines × 0.005 = 0.5, 1 team = 0, no other
76 // signals. Total = 2.0 → score 2 → low.
77 const { score, band } = computePrRiskScore({
78 ...zeroSignals(),
79 filesChanged: 5,
80 linesAdded: 60,
81 linesRemoved: 40,
82 teamsAffected: 1,
83 testsAddedForNewCode: true,
84 diffMinusTestRatio: 0.0,
85 });
86 expect(score).toBe(2);
87 expect(band).toBe("low");
88 });
89
90 it("MEDIUM (3/10): adds a new dep + cross-team but tested", () => {
91 // 6 files × 0.3 = 1.8, 200 lines × 0.005 = 1.0, 2 teams = 0.8,
92 // addsNewDependency = 0.5. Total = 4.1 → score 4 → medium.
93 const { score, band } = computePrRiskScore({
94 ...zeroSignals(),
95 filesChanged: 6,
96 linesAdded: 120,
97 linesRemoved: 80,
98 teamsAffected: 2,
99 addsNewDependency: true,
100 testsAddedForNewCode: true,
101 diffMinusTestRatio: 0.0,
102 });
103 expect(band).toBe("medium");
104 expect(score).toBeGreaterThanOrEqual(3);
105 expect(score).toBeLessThanOrEqual(4);
106 });
107
108 it("HIGH (6/10): schema migration, no tests, multi-team", () => {
109 // 8 files × 0.3 = 2.4, 300 lines × 0.005 = 1.5, 2 teams = 0.8,
110 // schemaMigration = 1.5, no tests + ratio 1 = 1.0. Total = 7.2 → 7
111 // → high.
112 const { score, band } = computePrRiskScore({
113 ...zeroSignals(),
114 filesChanged: 8,
115 linesAdded: 200,
116 linesRemoved: 100,
117 teamsAffected: 2,
118 schemaMigrationTouched: true,
119 testsAddedForNewCode: false,
120 diffMinusTestRatio: 1.0,
121 });
122 expect(band).toBe("high");
123 expect(score).toBeGreaterThanOrEqual(5);
124 expect(score).toBeLessThanOrEqual(7);
125 });
126
127 it("HIGH (5/10): bumps major dependency without tests but no locked path", () => {
128 // 5 files × 0.3 = 1.5, 100 lines × 0.005 = 0.5, 1 team = 0,
129 // bumpsMajor = 1.2, no-tests + ratio 1 = 1.0. Total = 4.2 → score 4
130 // → medium. Increase deps to get high.
131 // Use slightly bigger PR: 8 files, 200 lines, 1 team, major bump,
132 // no tests. 8 × 0.3 = 2.4, 200 × 0.005 = 1.0, major=1.2, no tests=1.0
133 // Total = 5.6 → score 6 → high.
134 const { score, band } = computePrRiskScore({
135 ...zeroSignals(),
136 filesChanged: 8,
137 linesAdded: 120,
138 linesRemoved: 80,
139 teamsAffected: 1,
140 bumpsMajorDependency: true,
141 testsAddedForNewCode: false,
142 diffMinusTestRatio: 1.0,
143 });
144 expect(band).toBe("high");
145 expect(score).toBeGreaterThanOrEqual(5);
146 expect(score).toBeLessThanOrEqual(7);
147 });
148
149 it("CRITICAL (9-10/10): touches schema, locked path, new dep, major bump, no tests, many teams", () => {
150 const { score, band } = computePrRiskScore({
151 filesChanged: 50,
152 linesAdded: 2000,
153 linesRemoved: 1000,
154 teamsAffected: 5,
155 schemaMigrationTouched: true,
156 lockedPathTouched: true,
157 addsNewDependency: true,
158 bumpsMajorDependency: true,
159 testsAddedForNewCode: false,
160 diffMinusTestRatio: 1.0,
161 });
162 expect(band).toBe("critical");
163 expect(score).toBeGreaterThanOrEqual(8);
164 });
165});
166
167describe("computePrRiskScore — bounds", () => {
168 it("clamps at 10 when every signal is extreme", () => {
169 const { score, band } = computePrRiskScore({
170 filesChanged: 10_000,
171 linesAdded: 10_000_000,
172 linesRemoved: 10_000_000,
173 teamsAffected: 1000,
174 schemaMigrationTouched: true,
175 lockedPathTouched: true,
176 addsNewDependency: true,
177 bumpsMajorDependency: true,
178 testsAddedForNewCode: false,
179 diffMinusTestRatio: 1.0,
180 });
181 expect(score).toBe(10);
182 expect(band).toBe("critical");
183 });
184
185 it("clamps at 0 when every signal is zero / negative", () => {
186 const { score, band } = computePrRiskScore({
187 filesChanged: 0,
188 linesAdded: 0,
189 linesRemoved: 0,
190 teamsAffected: 0,
191 schemaMigrationTouched: false,
192 lockedPathTouched: false,
193 addsNewDependency: false,
194 bumpsMajorDependency: false,
195 testsAddedForNewCode: true,
196 diffMinusTestRatio: 0,
197 });
198 expect(score).toBe(0);
199 expect(band).toBe("low");
200 });
201
202 it("treats negative inputs the same as zero (defensive)", () => {
203 const { score } = computePrRiskScore({
204 filesChanged: -5,
205 linesAdded: -100,
206 linesRemoved: -100,
207 teamsAffected: -2,
208 schemaMigrationTouched: false,
209 lockedPathTouched: false,
210 addsNewDependency: false,
211 bumpsMajorDependency: false,
212 testsAddedForNewCode: true,
213 diffMinusTestRatio: -1,
214 });
215 expect(score).toBe(0);
216 });
217});
218
219describe("computePrRiskScore — monotonicity", () => {
220 it("flipping any signal from false→true never decreases the score", () => {
221 const baseSignals: PrRiskSignals = {
222 filesChanged: 5,
223 linesAdded: 100,
224 linesRemoved: 50,
225 teamsAffected: 1,
226 schemaMigrationTouched: false,
227 lockedPathTouched: false,
228 addsNewDependency: false,
229 bumpsMajorDependency: false,
230 testsAddedForNewCode: true,
231 diffMinusTestRatio: 0.0,
232 };
233 const baseScore = computePrRiskScore(baseSignals).score;
234
235 for (const key of [
236 "schemaMigrationTouched",
237 "lockedPathTouched",
238 "addsNewDependency",
239 "bumpsMajorDependency",
240 ] as const) {
241 const next = { ...baseSignals, [key]: true };
242 const nextScore = computePrRiskScore(next).score;
243 expect(nextScore).toBeGreaterThanOrEqual(baseScore);
244 }
245
246 // Removing testsAddedForNewCode + raising ratio can only raise the
247 // score (never lower it).
248 const noTests: PrRiskSignals = {
249 ...baseSignals,
250 testsAddedForNewCode: false,
251 diffMinusTestRatio: 1.0,
252 };
253 expect(computePrRiskScore(noTests).score).toBeGreaterThanOrEqual(baseScore);
254
255 // Increasing the integer signals can only raise the score.
256 for (const key of ["filesChanged", "linesAdded", "linesRemoved", "teamsAffected"] as const) {
257 const next = { ...baseSignals, [key]: baseSignals[key] + 50 };
258 expect(computePrRiskScore(next).score).toBeGreaterThanOrEqual(baseScore);
259 }
260 });
261
262 it("the score is the same on identical input (pure)", () => {
263 const s: PrRiskSignals = {
264 filesChanged: 4,
265 linesAdded: 80,
266 linesRemoved: 20,
267 teamsAffected: 1,
268 schemaMigrationTouched: false,
269 lockedPathTouched: true,
270 addsNewDependency: false,
271 bumpsMajorDependency: false,
272 testsAddedForNewCode: false,
273 diffMinusTestRatio: 0.7,
274 };
275 expect(computePrRiskScore(s)).toEqual(computePrRiskScore(s));
276 });
277});
278
279// ---------------------------------------------------------------------------
280// buildSignalsFromDiff — path-classification helpers
281// ---------------------------------------------------------------------------
282
283describe("buildSignalsFromDiff", () => {
284 it("flags schema migration paths and locked paths", () => {
285 const signals = buildSignalsFromDiff({
286 files: [
287 { path: "drizzle/0099_my.sql", additions: 20, deletions: 0 },
288 { path: "sensitive/api-key.pem", additions: 1, deletions: 0 },
289 ],
290 raw: "",
291 ownerRules: [],
292 baseDeps: new Map(),
293 headDeps: new Map(),
294 });
295 expect(signals.schemaMigrationTouched).toBe(true);
296 expect(signals.lockedPathTouched).toBe(true);
297 });
298
299 it("treats tests under __tests__/ as test files", () => {
300 const signals = buildSignalsFromDiff({
301 files: [
302 { path: "src/foo.ts", additions: 50, deletions: 0 },
303 { path: "src/__tests__/foo.test.ts", additions: 50, deletions: 0 },
304 ],
305 raw: "",
306 ownerRules: [],
307 baseDeps: new Map(),
308 headDeps: new Map(),
309 });
310 expect(signals.testsAddedForNewCode).toBe(true);
311 // Roughly half the diff is tests → ratio about 0.5.
312 expect(signals.diffMinusTestRatio).toBeCloseTo(0.5, 1);
313 });
314
315 it("detects new dependencies and major bumps", () => {
316 const base = new Map<string, string | null>([
317 ["npm:react", "^17.0.0"],
318 ["npm:hono", "^3.0.0"],
319 ]);
320 const head = new Map<string, string | null>([
321 ["npm:react", "^18.0.0"], // major bump
322 ["npm:hono", "^3.0.0"], // unchanged
323 ["npm:zod", "^3.22.0"], // new
324 ]);
325 const signals = buildSignalsFromDiff({
326 files: [{ path: "package.json", additions: 3, deletions: 1 }],
327 raw: "",
328 ownerRules: [],
329 baseDeps: base,
330 headDeps: head,
331 });
332 expect(signals.addsNewDependency).toBe(true);
333 expect(signals.bumpsMajorDependency).toBe(true);
334 });
335
336 it("counts distinct CODEOWNERS owners as teamsAffected", () => {
337 const signals = buildSignalsFromDiff({
338 files: [
339 { path: "src/api/foo.ts", additions: 5, deletions: 0 },
340 { path: "src/web/bar.ts", additions: 5, deletions: 0 },
341 ],
342 raw: "",
343 ownerRules: [
344 { pattern: "src/api/**", owners: ["alice"] },
345 { pattern: "src/web/**", owners: ["bob"] },
346 ],
347 baseDeps: new Map(),
348 headDeps: new Map(),
349 });
350 expect(signals.teamsAffected).toBe(2);
351 });
352});
353
354describe("isMajorBump", () => {
355 it("returns true on a major version increase", () => {
356 expect(isMajorBump("^1.0.0", "^2.0.0")).toBe(true);
357 expect(isMajorBump("17.0.0", "18.0.0")).toBe(true);
358 expect(isMajorBump("~1.2.3", "^2.0.0")).toBe(true);
359 });
360 it("returns false on minor / patch / no change", () => {
361 expect(isMajorBump("^1.0.0", "^1.1.0")).toBe(false);
362 expect(isMajorBump("^1.2.0", "^1.2.5")).toBe(false);
363 expect(isMajorBump("^2.0.0", "^2.0.0")).toBe(false);
364 });
365 it("returns false on unparseable specs (defensive)", () => {
366 expect(isMajorBump(null, "^1.0.0")).toBe(false);
367 expect(isMajorBump("workspace:*", "^1.0.0")).toBe(false);
368 expect(isMajorBump("^1.0.0", null)).toBe(false);
369 });
370});
371
372// ---------------------------------------------------------------------------
373// generatePrRiskSummary — fallback when no API key
374// ---------------------------------------------------------------------------
375
376describe("generatePrRiskSummary", () => {
377 const originalKey = process.env.ANTHROPIC_API_KEY;
378
379 afterAll(() => {
380 if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
381 else process.env.ANTHROPIC_API_KEY = originalKey;
382 });
383
384 it("falls back to deterministic prose when no Anthropic key is set", async () => {
385 delete process.env.ANTHROPIC_API_KEY;
386 const text = await generatePrRiskSummary({
387 signals: {
388 ...zeroSignals(),
389 filesChanged: 3,
390 linesAdded: 50,
391 linesRemoved: 10,
392 teamsAffected: 1,
393 },
394 title: "Bump deps",
395 baseBranch: "main",
396 headBranch: "feature/x",
397 });
398 expect(typeof text).toBe("string");
399 expect(text.length).toBeGreaterThan(0);
400 // Deterministic fallback mentions the file count.
401 expect(text).toContain("3 file");
402 });
403
404 it("deterministicSummary mentions schema migration + locked path", () => {
405 const text = __test.deterministicSummary({
406 ...zeroSignals(),
407 filesChanged: 5,
408 linesAdded: 100,
409 linesRemoved: 30,
410 teamsAffected: 2,
411 schemaMigrationTouched: true,
412 lockedPathTouched: true,
413 });
414 expect(text).toContain("schema migration");
415 expect(text).toContain("locked");
416 });
417});
418
419// ---------------------------------------------------------------------------
420// computePrRiskForPullRequest — null when PR not found
421// ---------------------------------------------------------------------------
422
423// Stub `../db` with a chain that returns no PR row for a probe id. We use
424// the same spread-from-real pattern as mcp-write.test.ts so the original
425// surface stays live and other test files are not poisoned by mock.module().
426const _real_db = await import("../db");
427
428let _nextPrJoinRow: any = null;
429
430const _selectChain: any = {
431 from: () => _selectChain,
432 innerJoin: () => _selectChain,
433 leftJoin: () => _selectChain,
434 rightJoin: () => _selectChain,
435 where: () => _selectChain,
436 orderBy: () => _selectChain,
437 groupBy: () => _selectChain,
438 limit: async () => (_nextPrJoinRow ? [_nextPrJoinRow] : []),
439 then: (resolve: (v: any) => void) =>
440 resolve(_nextPrJoinRow ? [_nextPrJoinRow] : []),
441};
442
443const _insertChain = () => ({
444 values: () => ({
445 then: (resolve: (v: any) => void) => resolve(undefined),
446 returning: async () => [],
447 }),
448});
449
450const _fakeDb = {
451 db: {
452 select: () => _selectChain,
453 insert: () => _insertChain(),
454 update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
455 delete: () => ({ where: () => Promise.resolve() }),
456 },
457 getDb: () => _fakeDb.db,
458};
459
460mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
461
462afterAll(() => {
463 // Restore the real DB so downstream files see the original module.
464 mock.module("../db", () => _real_db);
465});
466
467beforeEach(() => {
468 _nextPrJoinRow = null;
469});
470
471describe("computePrRiskForPullRequest", () => {
472 it("returns null for a non-existent pull request", async () => {
473 // Re-import the orchestrator AFTER mock.module() so it picks up the
474 // stubbed `../db`. Top-level import was a deliberate first-load, but
475 // pr-risk is small + the import shape is preserved.
476 const mod = await import("../lib/pr-risk");
477 _nextPrJoinRow = null;
478 const result = await mod.computePrRiskForPullRequest("missing-pr-id");
479 expect(result).toBeNull();
480 });
481});
482
483// ---------------------------------------------------------------------------
484// Cache hit returns the same shape as cache miss
485// ---------------------------------------------------------------------------
486
487describe("PrRiskScore shape", () => {
488 it("rowToPrRiskScore returns the same fields a fresh computation would", () => {
489 const row = {
490 id: "x",
491 pullRequestId: "pr-1",
492 commitSha: "abc1234",
493 score: 6,
494 band: "high",
495 signals: {
496 ...zeroSignals(),
497 filesChanged: 4,
498 linesAdded: 100,
499 linesRemoved: 50,
500 teamsAffected: 2,
501 },
502 aiSummary: "Touches a schema migration.",
503 generatedAt: new Date("2026-05-13T00:00:00Z"),
504 } as any;
505 const out = __test.rowToPrRiskScore(row);
506 expect(out.score).toBe(6);
507 expect(out.band).toBe("high");
508 expect(out.commitSha).toBe("abc1234");
509 expect(out.signals.filesChanged).toBe(4);
510 expect(out.aiSummary).toBe("Touches a schema migration.");
511 expect(out.generatedAt).toBeInstanceOf(Date);
512 });
513});
Addedsrc/__tests__/push.test.ts+475−0View fileUnifiedSplit
@@ -0,0 +1,475 @@
1/**
2 * Block M2 — Web Push tests.
3 *
4 * Covers `src/lib/push.ts` + the `/pwa/*` API surface:
5 * - getVapidPublicKey: stable across calls (process-cached)
6 * - subscribeUser: persists, idempotent on (user, endpoint)
7 * - unsubscribeUser: deletes
8 * - sendPushToUser: per-subscription failures don't crash the fan-out,
9 * returns sent/failed counts
10 * - Stale-endpoint deletion on HTTP 410
11 * - Routes: vapid-public-key public, subscribe requires auth,
12 * unsubscribe requires auth
13 *
14 * Mock isolation
15 * --------------
16 * Bun's `mock.module()` is process-global. We capture the real `../db`
17 * module BEFORE any mock so afterAll can restore it. The fake db
18 * dispatches on the table passed to .from/.insert/.delete/.update,
19 * records mutations to in-memory arrays, and returns canned rows.
20 */
21
22import {
23 describe,
24 it,
25 expect,
26 mock,
27 beforeEach,
28 afterAll,
29} from "bun:test";
30
31const _real_db = await import("../db");
32
33// ---------------------------------------------------------------------------
34// Fake db state
35// ---------------------------------------------------------------------------
36
37type SubRow = {
38 userId: string;
39 endpoint: string;
40 p256dh: string;
41 auth: string;
42 userAgent: string | null;
43};
44
45let _subRows: SubRow[] = [];
46let _nextUserRow: any = null;
47const _inserted: any[] = [];
48const _updated: any[] = [];
49const _deleted: any[] = [];
50
51const tableName = (t: any): string => {
52 if (!t || typeof t !== "object") return "?";
53 if ("p256dh" in t && "endpoint" in t) return "push_subscriptions";
54 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
55 if ("notifyPushOnMention" in t || "passwordHash" in t) return "users";
56 return "?";
57};
58
59let _lastSelectFrom: any = null;
60let _nextSessionRow: any = null;
61
62const _selectChain: any = {
63 from: (t: any) => {
64 _lastSelectFrom = t;
65 return _selectChain;
66 },
67 where: () => _selectChain,
68 orderBy: () => _selectChain,
69 limit: async () => {
70 const name = tableName(_lastSelectFrom);
71 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
72 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
73 return [];
74 },
75 then: (resolve: (v: any) => void) => {
76 const name = tableName(_lastSelectFrom);
77 if (name === "push_subscriptions") {
78 resolve(_subRows.map((r) => ({
79 endpoint: r.endpoint,
80 p256dh: r.p256dh,
81 auth: r.auth,
82 })));
83 return;
84 }
85 resolve([]);
86 },
87};
88
89const _insertChain = (table: any) => ({
90 values: (vals: any) => ({
91 onConflictDoUpdate: ({ set }: { set: any }) => {
92 const name = tableName(table);
93 if (name === "push_subscriptions") {
94 const existing = _subRows.find(
95 (r) => r.userId === vals.userId && r.endpoint === vals.endpoint
96 );
97 if (existing) {
98 existing.p256dh = set.p256dh ?? vals.p256dh;
99 existing.auth = set.auth ?? vals.auth;
100 existing.userAgent = set.userAgent ?? vals.userAgent ?? null;
101 } else {
102 _subRows.push({
103 userId: vals.userId,
104 endpoint: vals.endpoint,
105 p256dh: vals.p256dh,
106 auth: vals.auth,
107 userAgent: vals.userAgent ?? null,
108 });
109 }
110 }
111 _inserted.push({ table: name, values: vals });
112 return Promise.resolve();
113 },
114 returning: async () => [],
115 then: (r: (v: any) => void) => {
116 const name = tableName(table);
117 if (name === "push_subscriptions") {
118 _subRows.push({
119 userId: vals.userId,
120 endpoint: vals.endpoint,
121 p256dh: vals.p256dh,
122 auth: vals.auth,
123 userAgent: vals.userAgent ?? null,
124 });
125 }
126 _inserted.push({ table: name, values: vals });
127 r(undefined);
128 },
129 }),
130});
131
132const _updateChain = (table: any) => ({
133 set: (s: any) => ({
134 where: () => {
135 const name = tableName(table);
136 _updated.push({ table: name, set: s });
137 return Promise.resolve();
138 },
139 }),
140});
141
142let _deleteFilter: { userId?: string; endpoint?: string } | null = null;
143
144const _deleteChain = (table: any) => ({
145 where: (_cond: any) => {
146 const name = tableName(table);
147 _deleted.push({ table: name });
148 if (name === "push_subscriptions") {
149 // We don't get the filter values from the cond object easily; the
150 // tests below call delete after subscribing exactly one row per
151 // case, so we just drop everything matching the most recently used
152 // endpoint hint stored on the chain.
153 if (_deleteFilter) {
154 _subRows = _subRows.filter(
155 (r) =>
156 !(r.userId === _deleteFilter!.userId &&
157 r.endpoint === _deleteFilter!.endpoint)
158 );
159 }
160 }
161 return Promise.resolve();
162 },
163});
164
165const _fakeDb = {
166 db: {
167 select: () => _selectChain,
168 insert: (t: any) => _insertChain(t),
169 update: (t: any) => _updateChain(t),
170 delete: (t: any) => _deleteChain(t),
171 },
172 getDb: () => _fakeDb.db,
173};
174
175mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
176
177// Imports must come AFTER the mock so the module graph picks up the fake db.
178const {
179 getVapidPublicKey,
180 subscribeUser,
181 unsubscribeUser,
182 sendPushToUser,
183 __setSendTransport,
184 __resetVapidCacheForTests,
185} = await import("../lib/push");
186const { default: app } = await import("../app");
187const { sessionCache } = await import("../lib/cache");
188
189// ---------------------------------------------------------------------------
190// Fixtures
191// ---------------------------------------------------------------------------
192
193// Valid (random) base64url-encoded payload-encryption keys. The encryption
194// path generates a fresh ECDH keypair on each call so the recipient public
195// key only needs to be a real P-256 uncompressed point; we generate one
196// with Web Crypto at test setup time.
197let RECIPIENT_P256DH = "";
198let RECIPIENT_AUTH = "";
199
200const USER_ID = "55555555-5555-5555-5555-555555555555";
201const SESSION_TOKEN = "test-session-token-push-m2";
202
203const TEST_USER = {
204 id: USER_ID,
205 username: "push_test_user",
206 displayName: "Push Test User",
207 email: "push@example.com",
208 passwordHash: "x",
209 bio: null,
210 notifyPushOnMention: true,
211 notifyPushOnAssign: true,
212 notifyPushOnReviewRequest: true,
213 notifyPushOnDeployFailed: true,
214 createdAt: new Date(),
215 updatedAt: new Date(),
216};
217
218function authedHeaders(): HeadersInit {
219 return {
220 cookie: `session=${SESSION_TOKEN}`,
221 "content-type": "application/json",
222 // CSRF same-origin guard: requests from real browser fetch() carry an
223 // Origin matching the page host. We mirror that here so the
224 // double-submit token isn't required for these JSON POSTs.
225 host: "localhost",
226 origin: "http://localhost",
227 };
228}
229
230beforeEach(async () => {
231 _subRows = [];
232 _nextUserRow = TEST_USER;
233 _nextSessionRow = {
234 userId: USER_ID,
235 token: SESSION_TOKEN,
236 expiresAt: new Date(Date.now() + 60 * 60 * 1000),
237 requires2fa: false,
238 };
239 _inserted.length = 0;
240 _updated.length = 0;
241 _deleted.length = 0;
242 _deleteFilter = null;
243 sessionCache.set(SESSION_TOKEN, TEST_USER as any);
244
245 // Generate a real P-256 point so encryptPayload doesn't choke on dummy
246 // bytes. We only need the public-key bytes; the private key stays inside
247 // Web Crypto. `auth` can be any 16 bytes.
248 if (!RECIPIENT_P256DH) {
249 const kp = await crypto.subtle.generateKey(
250 { name: "ECDH", namedCurve: "P-256" },
251 true,
252 ["deriveBits"]
253 );
254 const raw = new Uint8Array(
255 await crypto.subtle.exportKey("raw", kp.publicKey)
256 );
257 const authBytes = crypto.getRandomValues(new Uint8Array(16));
258 RECIPIENT_P256DH = b64u(raw);
259 RECIPIENT_AUTH = b64u(authBytes);
260 }
261});
262
263function b64u(bytes: Uint8Array): string {
264 let bin = "";
265 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
266 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
267}
268
269afterAll(() => {
270 sessionCache.invalidate(SESSION_TOKEN);
271 __resetVapidCacheForTests();
272 mock.module("../db", () => _real_db);
273});
274
275// ---------------------------------------------------------------------------
276// Pure-helper tests
277// ---------------------------------------------------------------------------
278
279describe("push.ts — getVapidPublicKey", () => {
280 it("returns a stable key across calls (in-memory cached)", async () => {
281 __resetVapidCacheForTests();
282 const a = await getVapidPublicKey();
283 const b = await getVapidPublicKey();
284 expect(a).toBe(b);
285 // Base64url, no padding.
286 expect(a).toMatch(/^[A-Za-z0-9_\-]+$/);
287 expect(a.length).toBeGreaterThan(40);
288 });
289});
290
291describe("push.ts — subscribeUser / unsubscribeUser", () => {
292 it("subscribeUser inserts a row", async () => {
293 await subscribeUser(
294 USER_ID,
295 { endpoint: "https://push.example/abc", keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH } },
296 "test-ua"
297 );
298 expect(_subRows.length).toBe(1);
299 expect(_subRows[0]!.userId).toBe(USER_ID);
300 expect(_subRows[0]!.endpoint).toBe("https://push.example/abc");
301 expect(_subRows[0]!.userAgent).toBe("test-ua");
302 });
303
304 it("subscribeUser is idempotent on (user, endpoint)", async () => {
305 await subscribeUser(USER_ID, {
306 endpoint: "https://push.example/abc",
307 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
308 });
309 await subscribeUser(USER_ID, {
310 endpoint: "https://push.example/abc",
311 keys: { p256dh: RECIPIENT_P256DH, auth: "newauth" },
312 });
313 expect(_subRows.length).toBe(1);
314 expect(_subRows[0]!.auth).toBe("newauth");
315 });
316
317 it("unsubscribeUser deletes the row", async () => {
318 await subscribeUser(USER_ID, {
319 endpoint: "https://push.example/zzz",
320 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
321 });
322 expect(_subRows.length).toBe(1);
323 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/zzz" };
324 await unsubscribeUser(USER_ID, "https://push.example/zzz");
325 expect(_subRows.length).toBe(0);
326 });
327});
328
329// ---------------------------------------------------------------------------
330// sendPushToUser fan-out + stale-endpoint cleanup
331// ---------------------------------------------------------------------------
332
333describe("push.ts — sendPushToUser", () => {
334 it("swallows per-subscription failures and returns sent/failed counts", async () => {
335 // Three subscriptions: one ok (200), one server error (500), one gone (410).
336 _subRows = [
337 { userId: USER_ID, endpoint: "https://push.example/ok", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
338 { userId: USER_ID, endpoint: "https://push.example/fail", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
339 { userId: USER_ID, endpoint: "https://push.example/gone", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
340 ];
341
342 const prev = __setSendTransport(async (url) => {
343 if (url.endsWith("/ok")) return { status: 201 };
344 if (url.endsWith("/fail")) return { status: 500 };
345 if (url.endsWith("/gone")) return { status: 410 };
346 return { status: 502 };
347 });
348 try {
349 // Drop the gone endpoint when the helper purges it.
350 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/gone" };
351 const res = await sendPushToUser(USER_ID, {
352 title: "hello",
353 body: "world",
354 url: "/notifications",
355 });
356 expect(res.sent).toBe(1);
357 expect(res.failed).toBe(2);
358 // The 410 endpoint should have been purged from the table.
359 expect(_subRows.find((r) => r.endpoint.endsWith("/gone"))).toBeUndefined();
360 // The 500 endpoint must NOT be purged — it might be transient.
361 expect(_subRows.find((r) => r.endpoint.endsWith("/fail"))).toBeDefined();
362 } finally {
363 __setSendTransport(prev);
364 }
365 });
366
367 it("returns zero counts when the user has no subscriptions", async () => {
368 _subRows = [];
369 const res = await sendPushToUser(USER_ID, { title: "x", body: "y" });
370 expect(res).toEqual({ sent: 0, failed: 0 });
371 });
372});
373
374// ---------------------------------------------------------------------------
375// Routes
376// ---------------------------------------------------------------------------
377
378describe("/pwa/vapid-public-key", () => {
379 it("is public — returns 200 + { key } without auth", async () => {
380 const res = await app.request("/pwa/vapid-public-key");
381 expect(res.status).toBe(200);
382 const body = await res.json();
383 expect(typeof body.key).toBe("string");
384 expect(body.key.length).toBeGreaterThan(40);
385 });
386});
387
388describe("/pwa/subscribe", () => {
389 it("requires auth (302 to /login when anonymous)", async () => {
390 const res = await app.request("/pwa/subscribe", {
391 method: "POST",
392 headers: { "content-type": "application/json" },
393 body: JSON.stringify({
394 endpoint: "https://push.example/x",
395 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
396 }),
397 });
398 // requireAuth either redirects to /login or returns 401 — accept either.
399 expect([302, 401, 403]).toContain(res.status);
400 });
401
402 it("authed POST persists a subscription and returns 201", async () => {
403 const res = await app.request("/pwa/subscribe", {
404 method: "POST",
405 headers: authedHeaders(),
406 body: JSON.stringify({
407 endpoint: "https://push.example/me",
408 keys: { p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH },
409 }),
410 });
411 expect(res.status).toBe(201);
412 expect(_subRows.length).toBe(1);
413 expect(_subRows[0]!.endpoint).toBe("https://push.example/me");
414 });
415
416 it("rejects malformed bodies with 400", async () => {
417 const res = await app.request("/pwa/subscribe", {
418 method: "POST",
419 headers: authedHeaders(),
420 body: JSON.stringify({ endpoint: "no-keys" }),
421 });
422 expect(res.status).toBe(400);
423 });
424});
425
426describe("/pwa/unsubscribe", () => {
427 it("requires auth", async () => {
428 const res = await app.request("/pwa/unsubscribe", {
429 method: "POST",
430 headers: { "content-type": "application/json" },
431 body: JSON.stringify({ endpoint: "https://push.example/x" }),
432 });
433 expect([302, 401, 403]).toContain(res.status);
434 });
435
436 it("authed POST deletes and returns 204", async () => {
437 _subRows = [
438 { userId: USER_ID, endpoint: "https://push.example/byebye", p256dh: RECIPIENT_P256DH, auth: RECIPIENT_AUTH, userAgent: null },
439 ];
440 _deleteFilter = { userId: USER_ID, endpoint: "https://push.example/byebye" };
441 const res = await app.request("/pwa/unsubscribe", {
442 method: "POST",
443 headers: authedHeaders(),
444 body: JSON.stringify({ endpoint: "https://push.example/byebye" }),
445 });
446 expect(res.status).toBe(204);
447 expect(_subRows.length).toBe(0);
448 });
449});
450
451describe("/sw-push.js (offline + push handler service worker)", () => {
452 it("serves the push-aware service worker", async () => {
453 const res = await app.request("/sw-push.js");
454 expect(res.status).toBe(200);
455 expect(res.headers.get("content-type") || "").toContain(
456 "application/javascript"
457 );
458 const body = await res.text();
459 expect(body).toContain("addEventListener('push'");
460 expect(body).toContain("addEventListener('notificationclick'");
461 expect(body).toContain("addEventListener('fetch'");
462 expect(body).toContain("/offline.html");
463 });
464});
465
466describe("/offline.html", () => {
467 it("serves a dark-themed offline page", async () => {
468 const res = await app.request("/offline.html");
469 expect(res.status).toBe(200);
470 expect(res.headers.get("content-type") || "").toContain("text/html");
471 const body = await res.text();
472 expect(body).toContain("You're offline");
473 expect(body).toContain('data-theme="dark"');
474 });
475});
Addedsrc/__tests__/stale-sweep.test.ts+537−0View fileUnifiedSplit
@@ -0,0 +1,537 @@
1/**
2 * Block M5 — Stale PR/issue sweeper tests.
3 *
4 * Covers the contract from the M5 spec:
5 * - shouldPokePr: true when >7d stale + no recent poke, false otherwise
6 * - shouldClosePr: true when poke is older than 14d
7 * - runStalePrSweepOnce happy path: pokes the right PRs
8 * - Idempotency: re-running with the same fixtures + a now-stamped
9 * `hasPokeWithin=true` flag results in zero side-effects
10 * - Stage-2 close fires when poke is older than 14d
11 * - `auto_close_stale_prs=false` skips the close phase
12 * - Per-tick cap is respected
13 * - findCandidates throwing returns a clean zero summary
14 * - Mirror tests for issues (30d/60d windows + auto_close_stale_issues)
15 *
16 * All DB-touching surfaces are dependency-injected so this file never
17 * hits Neon. Following the K3/L1 DI test pattern, NO `mock.module()` is
18 * used here — keeps the file pollution-free.
19 */
20
21import { describe, it, expect } from "bun:test";
22import {
23 STALE_PR_POKE_MARKER,
24 STALE_PR_CLOSE_MARKER,
25 STALE_ISSUE_POKE_MARKER,
26 STALE_ISSUE_CLOSE_MARKER,
27 STALE_PR_POKE_DAYS,
28 STALE_PR_CLOSE_DAYS,
29 STALE_ISSUE_POKE_DAYS,
30 STALE_ISSUE_CLOSE_DAYS,
31 shouldPokePr,
32 shouldClosePr,
33 shouldPokeIssue,
34 shouldCloseIssue,
35 runStalePrSweepOnce,
36 runStaleIssueSweepOnce,
37 type StalePrCandidate,
38 type StaleIssueCandidate,
39} from "../lib/stale-sweep";
40
41// ---------------------------------------------------------------------------
42// Fixtures
43// ---------------------------------------------------------------------------
44
45const NOW = new Date("2026-05-13T12:00:00Z");
46const MS_PER_DAY = 24 * 60 * 60 * 1000;
47const daysAgo = (n: number) => new Date(NOW.getTime() - n * MS_PER_DAY);
48
49function makePrCand(overrides: Partial<StalePrCandidate> = {}): StalePrCandidate {
50 return {
51 prId: "pr-1",
52 prNumber: 42,
53 repositoryId: "repo-1",
54 ownerUsername: "alice",
55 repoName: "demo",
56 authorUserId: "user-1",
57 updatedAt: daysAgo(10), // 10 days stale → past 7-day poke threshold
58 hasPokeWithin: false,
59 lastPokedAt: null,
60 autoCloseEnabled: true,
61 ...overrides,
62 };
63}
64
65function makeIssueCand(
66 overrides: Partial<StaleIssueCandidate> = {}
67): StaleIssueCandidate {
68 return {
69 issueId: "issue-1",
70 issueNumber: 7,
71 repositoryId: "repo-1",
72 ownerUsername: "alice",
73 repoName: "demo",
74 authorUserId: "user-1",
75 updatedAt: daysAgo(45), // 45 days → past 30-day issue poke threshold
76 hasPokeWithin: false,
77 lastPokedAt: null,
78 autoCloseEnabled: true,
79 ...overrides,
80 };
81}
82
83// ---------------------------------------------------------------------------
84// Constants — sanity-check the spec values are wired
85// ---------------------------------------------------------------------------
86
87describe("stale-sweep — constants", () => {
88 it("uses the documented poke + close thresholds", () => {
89 expect(STALE_PR_POKE_DAYS).toBe(7);
90 expect(STALE_PR_CLOSE_DAYS).toBe(14);
91 expect(STALE_ISSUE_POKE_DAYS).toBe(30);
92 expect(STALE_ISSUE_CLOSE_DAYS).toBe(60);
93 });
94
95 it("exposes the four versioned markers verbatim", () => {
96 expect(STALE_PR_POKE_MARKER).toBe("<!-- gluecron:stale-poke:v1 -->");
97 expect(STALE_PR_CLOSE_MARKER).toBe("<!-- gluecron:stale-close:v1 -->");
98 expect(STALE_ISSUE_POKE_MARKER).toBe(
99 "<!-- gluecron:stale-issue-poke:v1 -->"
100 );
101 expect(STALE_ISSUE_CLOSE_MARKER).toBe(
102 "<!-- gluecron:stale-issue-close:v1 -->"
103 );
104 });
105});
106
107// ---------------------------------------------------------------------------
108// Pure helpers — shouldPokePr / shouldClosePr
109// ---------------------------------------------------------------------------
110
111describe("shouldPokePr", () => {
112 it("returns true when PR is older than 7 days and no recent poke exists", () => {
113 expect(
114 shouldPokePr({ updatedAt: daysAgo(10), hasPokeWithin: false }, NOW)
115 ).toBe(true);
116 });
117
118 it("returns false when PR is younger than 7 days", () => {
119 expect(
120 shouldPokePr({ updatedAt: daysAgo(3), hasPokeWithin: false }, NOW)
121 ).toBe(false);
122 });
123
124 it("returns false when a poke already exists within the 7-day window", () => {
125 expect(
126 shouldPokePr({ updatedAt: daysAgo(10), hasPokeWithin: true }, NOW)
127 ).toBe(false);
128 });
129
130 it("returns true exactly at the 7-day boundary", () => {
131 expect(
132 shouldPokePr({ updatedAt: daysAgo(7), hasPokeWithin: false }, NOW)
133 ).toBe(true);
134 });
135});
136
137describe("shouldClosePr", () => {
138 it("returns true when the last poke is older than 14 days", () => {
139 expect(shouldClosePr({ lastPokedAt: daysAgo(15) }, NOW)).toBe(true);
140 });
141
142 it("returns false when the last poke is younger than 14 days", () => {
143 expect(shouldClosePr({ lastPokedAt: daysAgo(13) }, NOW)).toBe(false);
144 });
145
146 it("returns false when no poke has ever been posted", () => {
147 expect(shouldClosePr({ lastPokedAt: null }, NOW)).toBe(false);
148 });
149
150 it("returns true at exactly the 14-day boundary", () => {
151 expect(shouldClosePr({ lastPokedAt: daysAgo(14) }, NOW)).toBe(true);
152 });
153});
154
155describe("shouldPokeIssue + shouldCloseIssue", () => {
156 it("issue poke fires at 30+ days", () => {
157 expect(
158 shouldPokeIssue({ updatedAt: daysAgo(31), hasPokeWithin: false }, NOW)
159 ).toBe(true);
160 expect(
161 shouldPokeIssue({ updatedAt: daysAgo(29), hasPokeWithin: false }, NOW)
162 ).toBe(false);
163 });
164
165 it("issue close fires at 60+ days post-poke", () => {
166 expect(shouldCloseIssue({ lastPokedAt: daysAgo(61) }, NOW)).toBe(true);
167 expect(shouldCloseIssue({ lastPokedAt: daysAgo(59) }, NOW)).toBe(false);
168 });
169});
170
171// ---------------------------------------------------------------------------
172// runStalePrSweepOnce — happy path
173// ---------------------------------------------------------------------------
174
175describe("runStalePrSweepOnce — happy path", () => {
176 it("pokes every candidate that's past 7 days with no recent poke (3 in → 3 pokes)", async () => {
177 const candidates = [
178 makePrCand({ prId: "a" }),
179 makePrCand({ prId: "b" }),
180 makePrCand({ prId: "c" }),
181 ];
182 const poked: string[] = [];
183 const closed: string[] = [];
184 const summary = await runStalePrSweepOnce({
185 now: NOW,
186 findPrCandidates: async () => candidates,
187 pokePr: async (cand) => {
188 poked.push(cand.prId);
189 },
190 closePr: async (cand) => {
191 closed.push(cand.prId);
192 },
193 });
194 expect(poked).toEqual(["a", "b", "c"]);
195 expect(closed).toEqual([]);
196 expect(summary).toEqual({ poked: 3, closed: 0 });
197 });
198
199 it("skips PRs that are not stale (updated_at too recent)", async () => {
200 const candidates = [
201 makePrCand({ prId: "fresh", updatedAt: daysAgo(2) }),
202 makePrCand({ prId: "stale" }),
203 ];
204 const poked: string[] = [];
205 const summary = await runStalePrSweepOnce({
206 now: NOW,
207 findPrCandidates: async () => candidates,
208 pokePr: async (cand) => {
209 poked.push(cand.prId);
210 },
211 closePr: async () => {},
212 });
213 expect(poked).toEqual(["stale"]);
214 expect(summary).toEqual({ poked: 1, closed: 0 });
215 });
216});
217
218// ---------------------------------------------------------------------------
219// Idempotency — re-running with `hasPokeWithin=true` doesn't re-poke
220// ---------------------------------------------------------------------------
221
222describe("runStalePrSweepOnce — idempotency", () => {
223 it("does NOT re-poke a PR that already has a poke comment within the window", async () => {
224 // Simulate "we poked this 2 days ago" → finder reports hasPokeWithin=true
225 // and lastPokedAt 2d ago (so NOT yet close-eligible).
226 const already = makePrCand({
227 prId: "already-poked",
228 hasPokeWithin: true,
229 lastPokedAt: daysAgo(2),
230 });
231 let pokes = 0;
232 let closes = 0;
233 const summary = await runStalePrSweepOnce({
234 now: NOW,
235 findPrCandidates: async () => [already],
236 pokePr: async () => {
237 pokes += 1;
238 },
239 closePr: async () => {
240 closes += 1;
241 },
242 });
243 expect(pokes).toBe(0);
244 expect(closes).toBe(0);
245 expect(summary).toEqual({ poked: 0, closed: 0 });
246 });
247
248 it("re-running the same tick twice never double-pokes (state-machine round-trip)", async () => {
249 // First tick: cand has no poke → gets one. Second tick: cand has poke
250 // within window → skipped.
251 let firstTickPokes = 0;
252 let secondTickPokes = 0;
253
254 const initial = makePrCand({ prId: "X" });
255 const first = await runStalePrSweepOnce({
256 now: NOW,
257 findPrCandidates: async () => [initial],
258 pokePr: async () => {
259 firstTickPokes += 1;
260 },
261 closePr: async () => {},
262 });
263 expect(first).toEqual({ poked: 1, closed: 0 });
264 expect(firstTickPokes).toBe(1);
265
266 // Second tick: same PR but hasPokeWithin is now true.
267 const followup = makePrCand({
268 prId: "X",
269 hasPokeWithin: true,
270 lastPokedAt: NOW,
271 });
272 const second = await runStalePrSweepOnce({
273 now: NOW,
274 findPrCandidates: async () => [followup],
275 pokePr: async () => {
276 secondTickPokes += 1;
277 },
278 closePr: async () => {},
279 });
280 expect(second).toEqual({ poked: 0, closed: 0 });
281 expect(secondTickPokes).toBe(0);
282 });
283});
284
285// ---------------------------------------------------------------------------
286// Stage-2 close
287// ---------------------------------------------------------------------------
288
289describe("runStalePrSweepOnce — stage-2 close", () => {
290 it("auto-closes a PR whose poke is older than 14d when auto_close_stale_prs=true", async () => {
291 const ripe = makePrCand({
292 prId: "ripe",
293 lastPokedAt: daysAgo(15),
294 hasPokeWithin: false, // > 7d ago → not within window
295 autoCloseEnabled: true,
296 });
297 const closed: string[] = [];
298 const summary = await runStalePrSweepOnce({
299 now: NOW,
300 findPrCandidates: async () => [ripe],
301 pokePr: async () => {},
302 closePr: async (cand) => {
303 closed.push(cand.prId);
304 },
305 });
306 expect(closed).toEqual(["ripe"]);
307 expect(summary).toEqual({ poked: 0, closed: 1 });
308 });
309
310 it("SKIPS the close phase when auto_close_stale_prs=false (no close, no re-poke)", async () => {
311 const opted_out = makePrCand({
312 prId: "opt-out",
313 lastPokedAt: daysAgo(15),
314 hasPokeWithin: false,
315 autoCloseEnabled: false,
316 });
317 let pokes = 0;
318 let closes = 0;
319 const summary = await runStalePrSweepOnce({
320 now: NOW,
321 findPrCandidates: async () => [opted_out],
322 pokePr: async () => {
323 pokes += 1;
324 },
325 closePr: async () => {
326 closes += 1;
327 },
328 });
329 expect(pokes).toBe(0); // critical: must not re-poke either
330 expect(closes).toBe(0);
331 expect(summary).toEqual({ poked: 0, closed: 0 });
332 });
333
334 it("does NOT close until 14d have passed since the poke", async () => {
335 const tooSoon = makePrCand({
336 prId: "too-soon",
337 lastPokedAt: daysAgo(10),
338 hasPokeWithin: false,
339 autoCloseEnabled: true,
340 });
341 let closes = 0;
342 const summary = await runStalePrSweepOnce({
343 now: NOW,
344 findPrCandidates: async () => [tooSoon],
345 pokePr: async () => {},
346 closePr: async () => {
347 closes += 1;
348 },
349 });
350 expect(closes).toBe(0);
351 expect(summary.closed).toBe(0);
352 });
353});
354
355// ---------------------------------------------------------------------------
356// Per-tick cap + error isolation
357// ---------------------------------------------------------------------------
358
359describe("runStalePrSweepOnce — cap + isolation", () => {
360 it("respects an explicit cap argument across BOTH poke and close phases", async () => {
361 // 5 pokes + 5 closes available, cap=3 → first 3 actions only.
362 const cands = [
363 makePrCand({ prId: "p1" }),
364 makePrCand({ prId: "p2" }),
365 makePrCand({
366 prId: "c1",
367 lastPokedAt: daysAgo(20),
368 autoCloseEnabled: true,
369 }),
370 makePrCand({ prId: "p3" }),
371 makePrCand({
372 prId: "c2",
373 lastPokedAt: daysAgo(20),
374 autoCloseEnabled: true,
375 }),
376 ];
377 const acted: string[] = [];
378 const summary = await runStalePrSweepOnce({
379 now: NOW,
380 cap: 3,
381 findPrCandidates: async () => cands,
382 pokePr: async (c) => {
383 acted.push(`poke:${c.prId}`);
384 },
385 closePr: async (c) => {
386 acted.push(`close:${c.prId}`);
387 },
388 });
389 expect(acted.length).toBe(3);
390 expect(summary.poked + summary.closed).toBe(3);
391 });
392
393 it("isolates per-PR failures — a throwing pokePr doesn't stop later PRs", async () => {
394 const cands = [
395 makePrCand({ prId: "first" }),
396 makePrCand({ prId: "second" }),
397 ];
398 const ids: string[] = [];
399 const summary = await runStalePrSweepOnce({
400 now: NOW,
401 findPrCandidates: async () => cands,
402 pokePr: async (c) => {
403 if (c.prId === "first") throw new Error("kaboom");
404 ids.push(c.prId);
405 },
406 closePr: async () => {},
407 });
408 expect(ids).toEqual(["second"]);
409 expect(summary.poked).toBe(1);
410 });
411
412 it("returns a zero summary when findCandidates throws (never propagates)", async () => {
413 const summary = await runStalePrSweepOnce({
414 now: NOW,
415 findPrCandidates: async () => {
416 throw new Error("db down");
417 },
418 pokePr: async () => {},
419 closePr: async () => {},
420 });
421 expect(summary).toEqual({ poked: 0, closed: 0 });
422 });
423});
424
425// ---------------------------------------------------------------------------
426// Mirror tests for issues
427// ---------------------------------------------------------------------------
428
429describe("runStaleIssueSweepOnce — pokes + closes", () => {
430 it("pokes issues stale 30+ days with no recent poke", async () => {
431 const cands = [makeIssueCand({ issueId: "i1" })];
432 const poked: string[] = [];
433 const summary = await runStaleIssueSweepOnce({
434 now: NOW,
435 findIssueCandidates: async () => cands,
436 pokeIssue: async (c) => {
437 poked.push(c.issueId);
438 },
439 closeIssue: async () => {},
440 });
441 expect(poked).toEqual(["i1"]);
442 expect(summary).toEqual({ poked: 1, closed: 0 });
443 });
444
445 it("does NOT re-poke an issue with hasPokeWithin=true (idempotent)", async () => {
446 const cands = [
447 makeIssueCand({
448 issueId: "i-already",
449 hasPokeWithin: true,
450 lastPokedAt: daysAgo(2),
451 }),
452 ];
453 let pokes = 0;
454 const summary = await runStaleIssueSweepOnce({
455 now: NOW,
456 findIssueCandidates: async () => cands,
457 pokeIssue: async () => {
458 pokes += 1;
459 },
460 closeIssue: async () => {},
461 });
462 expect(pokes).toBe(0);
463 expect(summary).toEqual({ poked: 0, closed: 0 });
464 });
465
466 it("auto-closes an issue whose poke is older than 60d when auto_close_stale_issues=true", async () => {
467 const ripe = makeIssueCand({
468 issueId: "ripe",
469 lastPokedAt: daysAgo(61),
470 hasPokeWithin: false,
471 autoCloseEnabled: true,
472 });
473 const closed: string[] = [];
474 const summary = await runStaleIssueSweepOnce({
475 now: NOW,
476 findIssueCandidates: async () => [ripe],
477 pokeIssue: async () => {},
478 closeIssue: async (c) => {
479 closed.push(c.issueId);
480 },
481 });
482 expect(closed).toEqual(["ripe"]);
483 expect(summary).toEqual({ poked: 0, closed: 1 });
484 });
485
486 it("SKIPS issue close phase when auto_close_stale_issues=false", async () => {
487 const opted = makeIssueCand({
488 lastPokedAt: daysAgo(61),
489 autoCloseEnabled: false,
490 });
491 let closes = 0;
492 let pokes = 0;
493 const summary = await runStaleIssueSweepOnce({
494 now: NOW,
495 findIssueCandidates: async () => [opted],
496 pokeIssue: async () => {
497 pokes += 1;
498 },
499 closeIssue: async () => {
500 closes += 1;
501 },
502 });
503 expect(pokes).toBe(0);
504 expect(closes).toBe(0);
505 expect(summary).toEqual({ poked: 0, closed: 0 });
506 });
507
508 it("does NOT close an issue until 60d have passed since the poke", async () => {
509 const tooSoon = makeIssueCand({
510 lastPokedAt: daysAgo(45),
511 autoCloseEnabled: true,
512 });
513 let closes = 0;
514 const summary = await runStaleIssueSweepOnce({
515 now: NOW,
516 findIssueCandidates: async () => [tooSoon],
517 pokeIssue: async () => {},
518 closeIssue: async () => {
519 closes += 1;
520 },
521 });
522 expect(closes).toBe(0);
523 expect(summary.closed).toBe(0);
524 });
525
526 it("returns zero summary when findCandidates throws", async () => {
527 const summary = await runStaleIssueSweepOnce({
528 now: NOW,
529 findIssueCandidates: async () => {
530 throw new Error("db down");
531 },
532 pokeIssue: async () => {},
533 closeIssue: async () => {},
534 });
535 expect(summary).toEqual({ poked: 0, closed: 0 });
536 });
537});
Modifiedsrc/db/schema.ts+86−0View fileUnifiedSplit
@@ -45,6 +45,15 @@ export const users = pgTable("users", {
4545 // digest, so a user cannot receive both on the same day.
4646 sleepModeEnabled: boolean("sleep_mode_enabled").default(false).notNull(),
4747 sleepModeDigestHourUtc: integer("sleep_mode_digest_hour_utc").default(9).notNull(),
48 // Block M2 — Web Push per-event preferences. Default on; opt-out via /settings.
49 notifyPushOnMention: boolean("notify_push_on_mention").default(true).notNull(),
50 notifyPushOnAssign: boolean("notify_push_on_assign").default(true).notNull(),
51 notifyPushOnReviewRequest: boolean("notify_push_on_review_request")
52 .default(true)
53 .notNull(),
54 notifyPushOnDeployFailed: boolean("notify_push_on_deploy_failed")
55 .default(true)
56 .notNull(),
4857 isAdmin: boolean("is_admin").default(false).notNull(),
4958 createdAt: timestamp("created_at").defaultNow().notNull(),
5059 updatedAt: timestamp("updated_at").defaultNow().notNull(),
@@ -91,6 +100,13 @@ export const repositories = pgTable(
91100 starCount: integer("star_count").default(0).notNull(),
92101 forkCount: integer("fork_count").default(0).notNull(),
93102 issueCount: integer("issue_count").default(0).notNull(),
103 // Block M5: autopilot stale-sweep opt-out flags. Default true — the
104 // 2-stage close (poke at 7d/30d, close at 14d/60d after poke) runs on
105 // every repo unless an owner disables it via repo-settings.
106 autoCloseStalePrs: boolean("auto_close_stale_prs").default(true).notNull(),
107 autoCloseStaleIssues: boolean("auto_close_stale_issues")
108 .default(true)
109 .notNull(),
94110 },
95111 (table) => [
96112 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -662,6 +678,33 @@ export const sshKeys = pgTable("ssh_keys", {
662678 createdAt: timestamp("created_at").defaultNow().notNull(),
663679});
664680
681// Block M2 — Web Push subscriptions. One row per (user, endpoint).
682// Endpoint is the browser-issued push URL; p256dh + auth are the W3C
683// payload-encryption keys (base64url). Stale endpoints (HTTP 410) are
684// deleted lazily on next `sendPushToUser` pass.
685export const pushSubscriptions = pgTable(
686 "push_subscriptions",
687 {
688 id: uuid("id").primaryKey().defaultRandom(),
689 userId: uuid("user_id")
690 .notNull()
691 .references(() => users.id, { onDelete: "cascade" }),
692 endpoint: text("endpoint").notNull(),
693 p256dh: text("p256dh").notNull(),
694 auth: text("auth").notNull(),
695 userAgent: text("user_agent"),
696 createdAt: timestamp("created_at").defaultNow().notNull(),
697 lastUsedAt: timestamp("last_used_at"),
698 },
699 (table) => [
700 uniqueIndex("push_subscriptions_user_endpoint").on(
701 table.userId,
702 table.endpoint
703 ),
704 index("idx_push_subscriptions_user").on(table.userId),
705 ]
706);
707
665708export type User = typeof users.$inferSelect;
666709export type NewUser = typeof users.$inferInsert;
667710export type Repository = typeof repositories.$inferSelect;
@@ -669,6 +712,8 @@ export type NewRepository = typeof repositories.$inferInsert;
669712export type Session = typeof sessions.$inferSelect;
670713export type Star = typeof stars.$inferSelect;
671714export type SshKey = typeof sshKeys.$inferSelect;
715export type PushSubscription = typeof pushSubscriptions.$inferSelect;
716export type NewPushSubscription = typeof pushSubscriptions.$inferInsert;
672717export type Issue = typeof issues.$inferSelect;
673718export type IssueComment = typeof issueComments.$inferSelect;
674719export type Label = typeof labels.$inferSelect;
@@ -2648,3 +2693,44 @@ export const repairFlywheel = pgTable(
26482693export type RepairFlywheelEntry = typeof repairFlywheel.$inferSelect;
26492694export type NewRepairFlywheelEntry = typeof repairFlywheel.$inferInsert;
26502695
2696/**
2697 * Block M3 — AI pre-merge risk score cache.
2698 *
2699 * One row per (pull_request_id, commit_sha). The score is computed by a
2700 * transparent formula over `signals`; the LLM only writes `ai_summary`.
2701 * Pinning by head SHA means a new push invalidates the cache implicitly
2702 * (the new SHA won't have a row yet → cache miss → recompute).
2703 *
2704 * Migration: 0044_pr_risk_scores.sql
2705 */
2706export const prRiskScores = pgTable(
2707 "pr_risk_scores",
2708 {
2709 id: uuid("id").primaryKey().defaultRandom(),
2710 pullRequestId: uuid("pull_request_id")
2711 .notNull()
2712 .references(() => pullRequests.id, { onDelete: "cascade" }),
2713 commitSha: text("commit_sha").notNull(),
2714 // 0..10 integer score.
2715 score: integer("score").notNull(),
2716 // 'low' | 'medium' | 'high' | 'critical'
2717 band: text("band").notNull(),
2718 // Raw signal map — see PrRiskSignals in src/lib/pr-risk.ts.
2719 signals: jsonb("signals").notNull(),
2720 aiSummary: text("ai_summary"),
2721 generatedAt: timestamp("generated_at", { withTimezone: true })
2722 .defaultNow()
2723 .notNull(),
2724 },
2725 (table) => [
2726 uniqueIndex("pr_risk_scores_pr_sha_uq").on(
2727 table.pullRequestId,
2728 table.commitSha
2729 ),
2730 index("pr_risk_scores_pr_idx").on(table.pullRequestId),
2731 ]
2732);
2733
2734export type PrRiskScoreRow = typeof prRiskScores.$inferSelect;
2735export type NewPrRiskScoreRow = typeof prRiskScores.$inferInsert;
2736
Modifiedsrc/lib/auto-merge.ts+27−0View fileUnifiedSplit
@@ -43,6 +43,7 @@ import {
4343import { AI_REVIEW_MARKER } from "./ai-review";
4444import { audit } from "./notify";
4545import { getRepoPath } from "../git/repository";
46import { getLatestCachedPrRisk } from "./pr-risk";
4647
4748// ---------------------------------------------------------------------------
4849// Public types
@@ -86,6 +87,10 @@ export interface AutoMergeOptions {
8687/**
8788 * Internal pure decision helper. All DB-derived facts are passed in as
8889 * arguments so tests can drive every branch without a real database.
90 *
91 * Block M3 — when `risk` is provided AND its band is `critical`, the
92 * auto-merge is blocked with a clear reason. Lower bands never block
93 * auto-merge; the score is informational on the manual path only.
8994 */
9095export function decideAutoMerge(args: {
9196 rule: BranchProtection | null;
@@ -97,6 +102,7 @@ export function decideAutoMerge(args: {
97102 requiredCheckNames: string[];
98103 diffStats?: { files: number; lines: number } | null;
99104 caps?: { maxChangedFiles?: number; maxChangedLines?: number };
105 risk?: { score: number; band: "low" | "medium" | "high" | "critical" } | null;
100106}): AutoMergeDecision {
101107 const blocking: string[] = [];
102108
@@ -140,6 +146,12 @@ export function decideAutoMerge(args: {
140146 // caller is responsible for sourcing `aiApproved` from a marker-bearing
141147 // AI comment that survives `aiCommentLooksApproved`.
142148
149 // M3. Pre-merge risk score — `critical` blocks auto-merge regardless
150 // of every other gate being green. Lower bands are informational only.
151 if (args.risk && args.risk.band === "critical") {
152 blocking.push(`PR risk score is critical (${args.risk.score}/10)`);
153 }
154
143155 // 5. Optional size cap.
144156 if (args.caps && args.diffStats) {
145157 const { maxChangedFiles, maxChangedLines } = args.caps;
@@ -341,6 +353,20 @@ export async function evaluateAutoMerge(
341353 );
342354 }
343355
356 // M3 — best-effort cached risk lookup. Never throws; missing risk
357 // simply means the decision falls through to the existing gates.
358 let riskForDecision:
359 | { score: number; band: "low" | "medium" | "high" | "critical" }
360 | null = null;
361 try {
362 const cached = await getLatestCachedPrRisk(ctx.pullRequestId);
363 if (cached) {
364 riskForDecision = { score: cached.score, band: cached.band };
365 }
366 } catch {
367 riskForDecision = null;
368 }
369
344370 return decideAutoMerge({
345371 rule,
346372 isDraft: ctx.isDraft,
@@ -356,6 +382,7 @@ export async function evaluateAutoMerge(
356382 maxChangedLines: opts.maxChangedLines,
357383 }
358384 : undefined,
385 risk: riskForDecision,
359386 });
360387}
361388
Modifiedsrc/lib/autopilot.ts+209−0View fileUnifiedSplit
@@ -40,6 +40,12 @@ import {
4040 SLEEP_MODE_USER_CAP_PER_TICK,
4141 SLEEP_MODE_COOLDOWN_HOURS,
4242} from "./sleep-mode";
43import {
44 runStalePrSweepOnce,
45 runStaleIssueSweepOnce,
46} from "./stale-sweep";
47import { computePrRiskForPullRequest } from "./pr-risk";
48import { prRiskScores } from "../db/schema";
4349
4450export interface AutopilotTaskResult {
4551 name: string;
@@ -78,6 +84,10 @@ const AUTO_MERGE_LOOKBACK_HOURS = 24;
7884const AUTO_MERGE_MAX_PER_TICK = 50;
7985/** K3 — stable marker for the auto-merge audit comment. */
8086const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
87/** M3 — hard cap on PRs scored per tick (runaway protection). */
88const PR_RISK_RESCORE_MAX_PER_TICK = 20;
89/** M3 — recency window for the pr-risk-rescore sweep. */
90const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
8191
8292/**
8393 * Default task set. Each task is a thin wrapper around an existing locked
@@ -145,6 +155,45 @@ export function defaultTasks(): AutopilotTask[] {
145155 );
146156 },
147157 },
158 {
159 name: "stale-pr-sweep",
160 run: async () => {
161 // Two-stage gate: poke at 7d stale, close at 14d after poke
162 // (when the repo opts in via `auto_close_stale_prs`).
163 // Wrapped in try/catch so a finder crash never wedges the tick.
164 try {
165 const summary = await runStalePrSweepOnce();
166 console.log(
167 `[autopilot] stale-pr-sweep: poked=${summary.poked} closed=${summary.closed}`
168 );
169 } catch (err) {
170 console.error("[autopilot] stale-pr-sweep: threw:", err);
171 }
172 },
173 },
174 {
175 name: "stale-issue-sweep",
176 run: async () => {
177 // Mirror of stale-pr-sweep with the issue thresholds (30d/60d).
178 try {
179 const summary = await runStaleIssueSweepOnce();
180 console.log(
181 `[autopilot] stale-issue-sweep: poked=${summary.poked} closed=${summary.closed}`
182 );
183 } catch (err) {
184 console.error("[autopilot] stale-issue-sweep: threw:", err);
185 }
186 },
187 },
188 {
189 name: "pr-risk-rescore",
190 run: async () => {
191 const summary = await runPrRiskRescoreTaskOnce();
192 console.log(
193 `[autopilot] pr-risk-rescore: scored=${summary.scored} skipped=${summary.skipped}`
194 );
195 },
196 },
148197 ];
149198}
150199
@@ -272,6 +321,162 @@ export async function runSleepModeDigestTaskOnce(
272321 return { sent, skipped };
273322}
274323
324// ---------------------------------------------------------------------------
325// M3 — pr-risk-rescore
326// ---------------------------------------------------------------------------
327
328export interface PrRiskRescoreCandidate {
329 pullRequestId: string;
330 headBranch: string;
331 updatedAt: Date;
332}
333
334export interface PrRiskRescoreTaskDeps {
335 /** Override candidate finder for tests. */
336 findCandidates?: (
337 lookbackHours: number,
338 cap: number
339 ) => Promise<PrRiskRescoreCandidate[]>;
340 /** Override score computation for tests. */
341 scoreOne?: (prId: string) => Promise<{ ok: boolean }>;
342 /** Override per-tick cap. */
343 cap?: number;
344 /** Override lookback. */
345 lookbackHours?: number;
346}
347
348export interface PrRiskRescoreTaskSummary {
349 scored: number;
350 skipped: number;
351}
352
353/**
354 * Default candidate-finder. Returns open, non-draft PRs from non-archived
355 * repos whose `updated_at` falls inside the lookback window. The "scored
356 * at all" filter is applied as a second pass via `defaultFilterNeedsScoring`.
357 */
358async function defaultFindPrRiskCandidates(
359 lookbackHours: number,
360 cap: number
361): Promise<PrRiskRescoreCandidate[]> {
362 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
363 try {
364 const rows = await db
365 .select({
366 pullRequestId: pullRequests.id,
367 headBranch: pullRequests.headBranch,
368 updatedAt: pullRequests.updatedAt,
369 })
370 .from(pullRequests)
371 .innerJoin(
372 repositories,
373 eq(repositories.id, pullRequests.repositoryId)
374 )
375 .where(
376 and(
377 eq(pullRequests.state, "open"),
378 eq(pullRequests.isDraft, false),
379 eq(repositories.isArchived, false),
380 gte(pullRequests.updatedAt, cutoff)
381 )
382 )
383 .orderBy(sql`${pullRequests.updatedAt} DESC`)
384 .limit(cap);
385 return rows.map((r) => ({
386 pullRequestId: r.pullRequestId,
387 headBranch: r.headBranch,
388 updatedAt: r.updatedAt,
389 }));
390 } catch (err) {
391 console.error("[autopilot] pr-risk-rescore: candidate query failed:", err);
392 return [];
393 }
394}
395
396/**
397 * Drop candidates that already have ANY cached score row. The unique
398 * constraint on (pull_request_id, commit_sha) handles the "score-the-
399 * same-SHA-twice" case at persist time; this filter just keeps the work
400 * list small enough to fit under the per-tick cap when many PRs are
401 * being pushed concurrently.
402 */
403async function defaultFilterNeedsScoring(
404 candidates: PrRiskRescoreCandidate[]
405): Promise<PrRiskRescoreCandidate[]> {
406 if (candidates.length === 0) return [];
407 try {
408 const rows = await db
409 .select({ pullRequestId: prRiskScores.pullRequestId })
410 .from(prRiskScores);
411 const scoredIds = new Set(rows.map((r) => r.pullRequestId));
412 return candidates.filter((c) => !scoredIds.has(c.pullRequestId));
413 } catch (err) {
414 console.error("[autopilot] pr-risk-rescore: filter query failed:", err);
415 // Fail-open: better to score everything than silently skip.
416 return candidates;
417 }
418}
419
420/**
421 * One iteration of the pr-risk-rescore task. Never throws. Compute risk
422 * for up to `cap` recently-touched open PRs that have no cached score
423 * yet, so reviewers usually see a populated card on first visit.
424 */
425export async function runPrRiskRescoreTaskOnce(
426 deps: PrRiskRescoreTaskDeps = {}
427): Promise<PrRiskRescoreTaskSummary> {
428 const findCandidates = deps.findCandidates ?? defaultFindPrRiskCandidates;
429 const scoreOne =
430 deps.scoreOne ??
431 (async (prId: string) => {
432 try {
433 const result = await computePrRiskForPullRequest(prId);
434 return { ok: result !== null };
435 } catch {
436 return { ok: false };
437 }
438 });
439 const cap = deps.cap ?? PR_RISK_RESCORE_MAX_PER_TICK;
440 const lookbackHours =
441 deps.lookbackHours ?? PR_RISK_RESCORE_LOOKBACK_HOURS;
442
443 let candidates: PrRiskRescoreCandidate[] = [];
444 try {
445 candidates = await findCandidates(lookbackHours, cap);
446 } catch (err) {
447 console.error("[autopilot] pr-risk-rescore: findCandidates threw:", err);
448 return { scored: 0, skipped: 0 };
449 }
450
451 // Only score PRs missing a cached row. Skip filter when the caller
452 // injected a custom finder (tests pass already-filtered lists).
453 const needsScoring =
454 deps.findCandidates === undefined
455 ? await defaultFilterNeedsScoring(candidates)
456 : candidates;
457
458 let scored = 0;
459 let skipped = 0;
460 for (const cand of needsScoring.slice(0, cap)) {
461 try {
462 const result = await scoreOne(cand.pullRequestId);
463 if (result.ok) scored += 1;
464 else skipped += 1;
465 } catch (err) {
466 skipped += 1;
467 console.error(
468 `[autopilot] pr-risk-rescore: per-PR failure for pr=${cand.pullRequestId}:`,
469 err
470 );
471 }
472 }
473 if (needsScoring.length > cap) {
474 skipped += needsScoring.length - cap;
475 }
476
477 return { scored, skipped };
478}
479
275480// ---------------------------------------------------------------------------
276481// K3 — auto-merge-sweep
277482// ---------------------------------------------------------------------------
@@ -765,8 +970,12 @@ export const __test = {
765970 AUTO_MERGE_LOOKBACK_HOURS,
766971 AUTO_MERGE_MAX_PER_TICK,
767972 AUTO_MERGE_COMMENT_MARKER,
973 PR_RISK_RESCORE_MAX_PER_TICK,
974 PR_RISK_RESCORE_LOOKBACK_HOURS,
768975 defaultFindAutoMergeCandidates,
769976 defaultOnMerged,
770977 defaultOnMergeFailed,
771978 defaultShouldShortCircuitAi,
979 defaultFindPrRiskCandidates,
980 defaultFilterNeedsScoring,
772981};
Modifiedsrc/lib/mcp-tools.ts+66−2View fileUnifiedSplit
@@ -43,6 +43,12 @@ import {
4343} from "./branch-protection";
4444import { mergeWithAutoResolve } from "./merge-resolver";
4545import { isAiReviewEnabled } from "./ai-review";
46import {
47 computePrRiskForPullRequest,
48 getCachedPrRisk,
49 getLatestCachedPrRisk,
50 type PrRiskScore,
51} from "./pr-risk";
4652
4753export type McpTool = {
4854 name: string;
@@ -1074,13 +1080,18 @@ const mergePr: McpToolHandler = {
10741080 tool: {
10751081 name: "gluecron_merge_pr",
10761082 description:
1077 "Merge an open PR. Enforces the same checks as the HTTP merge flow: not a draft, head SHA resolves, GateTest+AI-review hard gates pass, branch-protection rules satisfied. Returns {merged, sha?, reason?}.",
1083 "Merge an open PR. Enforces the same checks as the HTTP merge flow: not a draft, head SHA resolves, GateTest+AI-review hard gates pass, branch-protection rules satisfied. M3: soft-blocks when the pre-merge risk score is `critical` unless `confirm_high_risk: true` is passed. Returns {merged, sha?, reason?, riskScore?}.",
10781084 inputSchema: {
10791085 type: "object",
10801086 properties: {
10811087 owner: { type: "string", description: "Repo owner username" },
10821088 repo: { type: "string", description: "Repo name" },
10831089 number: { type: "number", description: "PR number" },
1090 confirm_high_risk: {
1091 type: "boolean",
1092 description:
1093 "When true, bypass the M3 risk-score soft-block on critical-band PRs.",
1094 },
10841095 },
10851096 required: ["owner", "repo", "number"],
10861097 },
@@ -1089,6 +1100,7 @@ const mergePr: McpToolHandler = {
10891100 const owner = argString(args, "owner");
10901101 const repo = argString(args, "repo");
10911102 const number = argNumber(args, "number");
1103 const confirmHighRisk = args.confirm_high_risk === true;
10921104
10931105 const gate = await gateWriteAccess({ owner, repo }, ctx, "gluecron_merge_pr");
10941106 const pr = await loadPrByNumber(gate.repoId, number);
@@ -1108,6 +1120,28 @@ const mergePr: McpToolHandler = {
11081120 };
11091121 }
11101122
1123 // Block M3 — pre-merge risk score. Prefer the SHA-pinned cache entry;
1124 // fall back to most-recent cached row; finally compute on demand so the
1125 // MCP caller always gets a score (HTTP path is async + tolerant of a
1126 // missing score, but MCP callers want an answer in one round trip).
1127 let risk: PrRiskScore | null = null;
1128 try {
1129 risk =
1130 (await getCachedPrRisk(pr.id)) ||
1131 (await getLatestCachedPrRisk(pr.id)) ||
1132 (await computePrRiskForPullRequest(pr.id));
1133 } catch {
1134 risk = null;
1135 }
1136
1137 if (risk && risk.band === "critical" && !confirmHighRisk) {
1138 return {
1139 merged: false,
1140 reason: `risk score is critical (${risk.score}/10) — confirm with confirm_high_risk: true`,
1141 riskScore: serialisePrRiskForResponse(risk),
1142 };
1143 }
1144
11111145 const headSha = await resolveRef(owner, repo, pr.headBranch);
11121146 if (!headSha) {
11131147 return { merged: false, reason: "Head branch not found" };
@@ -1271,13 +1305,43 @@ const mergePr: McpToolHandler = {
12711305 mergedSha = null;
12721306 }
12731307
1274 return {
1308 // Block M3 — informational payload: when the risk score is high or
1309 // critical, include the score + summary in the response even on a
1310 // successful merge so the caller has the audit context. Low/medium
1311 // bands stay quiet to keep response noise low.
1312 const response: {
1313 merged: true;
1314 sha: string;
1315 riskScore?: ReturnType<typeof serialisePrRiskForResponse>;
1316 } = {
12751317 merged: true,
12761318 sha: mergedSha ?? headSha,
12771319 };
1320 if (risk && (risk.band === "high" || risk.band === "critical")) {
1321 response.riskScore = serialisePrRiskForResponse(risk);
1322 }
1323 return response;
12781324 },
12791325};
12801326
1327/**
1328 * Compact serialiser for embedding a PrRiskScore in an MCP response.
1329 * Keeps the surface stable + JSON-RPC-safe (Date → ISO string).
1330 */
1331function serialisePrRiskForResponse(risk: PrRiskScore) {
1332 return {
1333 score: risk.score,
1334 band: risk.band,
1335 aiSummary: risk.aiSummary,
1336 commitSha: risk.commitSha,
1337 signals: risk.signals,
1338 generatedAt:
1339 risk.generatedAt instanceof Date
1340 ? risk.generatedAt.toISOString()
1341 : String(risk.generatedAt),
1342 };
1343}
1344
12811345// ---------------------------------------------------------------------------
12821346// gluecron_close_pr
12831347// ---------------------------------------------------------------------------
Modifiedsrc/lib/notify.ts+3−1View fileUnifiedSplit
@@ -31,7 +31,9 @@ export type NotificationKind =
3131 | "deploy_failed"
3232 | "deployment_approval"
3333 | "release_published"
34 | "repo_archived";
34 | "repo_archived"
35 | "pr_stale"
36 | "issue_stale";
3537
3638/** Kinds that can trigger email delivery. Keep this list conservative — any
3739 * kind here must map to a user preference column on the users table. */
Addedsrc/lib/pr-risk.ts+716−0View fileUnifiedSplit
@@ -0,0 +1,716 @@
1/**
2 * Block M3 — AI pre-merge risk score.
3 *
4 * For every open PR we compute a transparent, auditable 0-10 risk score
5 * the reviewer can see at a glance before clicking Merge. The score is a
6 * pure function over a small set of signals (file count, line count,
7 * teams affected, schema migrations touched, dependency churn, test
8 * ratio). The LLM (Haiku — fast + cheap) only writes the one-paragraph
9 * prose summary; it never influences the numeric score.
10 *
11 * Architecture:
12 *
13 * 1. `computePrRiskScore(signals)` — pure helper. Same input always
14 * yields the same score. Documented inline; auditable.
15 * 2. `generatePrRiskSummary(args)` — calls Haiku for prose. Never
16 * throws — falls back to a deterministic sentence when no API key
17 * is set or the call fails.
18 * 3. `computePrRiskForPullRequest(prId)` — DB-backed orchestrator.
19 * Resolves the PR + head SHA, gathers signals, computes the score,
20 * persists it to `pr_risk_scores`, returns the full row.
21 * 4. `getCachedPrRisk(prId)` — cheap lookup for the current head SHA.
22 *
23 * Cache key: `(pull_request_id, commit_sha)`. When the head branch is
24 * force-pushed (new SHA), the cache miss naturally re-triggers a score
25 * computation.
26 */
27
28import { and, desc, eq } from "drizzle-orm";
29import { db } from "../db";
30import {
31 codeOwners,
32 prRiskScores,
33 pullRequests,
34 repoDependencies,
35 repositories,
36 users,
37 type PrRiskScoreRow,
38} from "../db/schema";
39import { getDiff, getRepoPath, resolveRef } from "../git/repository";
40import {
41 getAnthropic,
42 isAiAvailable,
43 MODEL_HAIKU,
44 extractText,
45} from "./ai-client";
46import { ownersForPath, parseCodeowners, type OwnerRule } from "./codeowners";
47
48// ---------------------------------------------------------------------------
49// Public types
50// ---------------------------------------------------------------------------
51
52export type PrRiskBand = "low" | "medium" | "high" | "critical";
53
54export interface PrRiskSignals {
55 filesChanged: number;
56 linesAdded: number;
57 linesRemoved: number;
58 /** Distinct CODEOWNERS owners touched (count). */
59 teamsAffected: number;
60 schemaMigrationTouched: boolean;
61 /** Paths under sensitive/ etc. */
62 lockedPathTouched: boolean;
63 addsNewDependency: boolean;
64 bumpsMajorDependency: boolean;
65 testsAddedForNewCode: boolean;
66 /** 0..1, 0 = lots of tests, 1 = no tests. */
67 diffMinusTestRatio: number;
68}
69
70export interface PrRiskScore {
71 score: number;
72 band: PrRiskBand;
73 signals: PrRiskSignals;
74 aiSummary: string;
75 generatedAt: Date;
76 commitSha: string;
77}
78
79// ---------------------------------------------------------------------------
80// Pure score formula
81// ---------------------------------------------------------------------------
82
83/**
84 * Transparent risk formula (intentionally simple, intentionally not LLM):
85 *
86 * + 0.3 per file changed (cap at 3.0)
87 * + 0.005 per (linesAdded + linesRemoved) (cap at 2.0)
88 * + 0.8 per team beyond the first (cap at 2.0)
89 * + 1.5 if schemaMigrationTouched
90 * + 1.0 if lockedPathTouched
91 * + 0.5 if addsNewDependency
92 * + 1.2 if bumpsMajorDependency
93 * + 1.0 * (testsAddedForNewCode ? 0 : diffMinusTestRatio)
94 *
95 * Clamp to [0, 10] and round to nearest integer.
96 *
97 * Band thresholds:
98 * 0–2 → low
99 * 3–4 → medium
100 * 5–7 → high
101 * 8–10 → critical
102 *
103 * Monotonicity: every weight is non-negative, so flipping any boolean
104 * signal from false to true (or increasing any counter) can only raise
105 * the score (until the cap). Verified by tests.
106 */
107export function computePrRiskScore(
108 signals: PrRiskSignals
109): { score: number; band: PrRiskBand } {
110 let raw = 0;
111
112 // File count contribution (cap at 3.0).
113 raw += Math.min(3.0, Math.max(0, signals.filesChanged) * 0.3);
114
115 // Line count contribution (cap at 2.0).
116 const totalLines =
117 Math.max(0, signals.linesAdded) + Math.max(0, signals.linesRemoved);
118 raw += Math.min(2.0, totalLines * 0.005);
119
120 // Teams-beyond-the-first contribution (cap at 2.0). teamsAffected=0 or 1
121 // contributes nothing; each additional team adds 0.8.
122 const extraTeams = Math.max(0, signals.teamsAffected - 1);
123 raw += Math.min(2.0, extraTeams * 0.8);
124
125 // Boolean signals.
126 if (signals.schemaMigrationTouched) raw += 1.5;
127 if (signals.lockedPathTouched) raw += 1.0;
128 if (signals.addsNewDependency) raw += 0.5;
129 if (signals.bumpsMajorDependency) raw += 1.2;
130
131 // Test-coverage penalty.
132 if (!signals.testsAddedForNewCode) {
133 const ratio = clamp01(signals.diffMinusTestRatio);
134 raw += 1.0 * ratio;
135 }
136
137 const score = Math.max(0, Math.min(10, Math.round(raw)));
138 return { score, band: bandFor(score) };
139}
140
141function clamp01(n: number): number {
142 if (!Number.isFinite(n)) return 0;
143 return Math.max(0, Math.min(1, n));
144}
145
146function bandFor(score: number): PrRiskBand {
147 if (score <= 2) return "low";
148 if (score <= 4) return "medium";
149 if (score <= 7) return "high";
150 return "critical";
151}
152
153// ---------------------------------------------------------------------------
154// AI prose summary — Haiku call with deterministic fallback
155// ---------------------------------------------------------------------------
156
157/**
158 * Generate a 1-3 sentence prose summary of the risk profile. Calls Haiku
159 * for fluency; on any failure (no key, API error, malformed response)
160 * falls back to a deterministic sentence built from the signal map.
161 *
162 * Never throws. Always returns a non-empty string.
163 */
164export async function generatePrRiskSummary(args: {
165 signals: PrRiskSignals;
166 title: string;
167 baseBranch: string;
168 headBranch: string;
169}): Promise<string> {
170 const fallback = deterministicSummary(args.signals);
171
172 if (!isAiAvailable()) return fallback;
173
174 try {
175 const client = getAnthropic();
176 const message = await client.messages.create({
177 model: MODEL_HAIKU,
178 max_tokens: 200,
179 messages: [
180 {
181 role: "user",
182 content: `You are summarising the risk profile of a pull request for a reviewer who is about to click Merge.
183
184PR title: ${args.title}
185Base branch: ${args.baseBranch}
186Head branch: ${args.headBranch}
187
188Signals (these are the FACTS — do not invent any others):
189- files changed: ${args.signals.filesChanged}
190- lines added: ${args.signals.linesAdded}
191- lines removed: ${args.signals.linesRemoved}
192- distinct CODEOWNERS owners touched: ${args.signals.teamsAffected}
193- schema migration touched: ${args.signals.schemaMigrationTouched}
194- locked / sensitive path touched: ${args.signals.lockedPathTouched}
195- adds a new dependency: ${args.signals.addsNewDependency}
196- bumps a major dependency: ${args.signals.bumpsMajorDependency}
197- tests added for new code: ${args.signals.testsAddedForNewCode}
198- diff-minus-test ratio (0=lots of tests, 1=no tests): ${args.signals.diffMinusTestRatio.toFixed(2)}
199
200Write ONE to THREE plain sentences describing the riskiest aspects of this PR.
201Tone: blunt, factual, no marketing. Do not include a numeric score (the reviewer
202already sees that). Do not use bullet points. Output the prose only.`,
203 },
204 ],
205 });
206 const text = extractText(message).trim();
207 if (!text) return fallback;
208 return text.slice(0, 600);
209 } catch {
210 return fallback;
211 }
212}
213
214/**
215 * Deterministic prose composed from the signal map. Used as the always-
216 * available fallback. Public-internal — exported for tests.
217 */
218export function deterministicSummary(signals: PrRiskSignals): string {
219 const parts: string[] = [];
220 parts.push(
221 `Touches ${signals.filesChanged} file(s) with ${signals.linesAdded} added and ${signals.linesRemoved} removed across ${signals.teamsAffected} owner(s).`
222 );
223 const hotspots: string[] = [];
224 if (signals.schemaMigrationTouched) hotspots.push("a schema migration");
225 if (signals.lockedPathTouched) hotspots.push("a locked / sensitive path");
226 if (signals.bumpsMajorDependency) hotspots.push("a major dependency bump");
227 else if (signals.addsNewDependency) hotspots.push("a new dependency");
228 if (hotspots.length > 0) {
229 parts.push(`Includes ${joinList(hotspots)}.`);
230 }
231 if (!signals.testsAddedForNewCode && signals.diffMinusTestRatio > 0.5) {
232 parts.push("No tests were added for the new code.");
233 }
234 return parts.join(" ");
235}
236
237function joinList(items: string[]): string {
238 if (items.length === 0) return "";
239 if (items.length === 1) return items[0];
240 if (items.length === 2) return `${items[0]} and ${items[1]}`;
241 return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
242}
243
244// ---------------------------------------------------------------------------
245// Signal-gathering helpers (DB + git)
246// ---------------------------------------------------------------------------
247
248const SCHEMA_MIGRATION_PATTERNS = [
249 /^drizzle\//i,
250 /^db\/migrations\//i,
251 /^migrations\//i,
252 /\/migrations\//i,
253 /^src\/db\/schema\.ts$/i,
254];
255
256const LOCKED_PATH_PATTERNS = [
257 /^sensitive\//i,
258 /^secrets\//i,
259 /^infra\/secrets\//i,
260 /\.pem$/i,
261 /\.key$/i,
262];
263
264const TEST_PATH_PATTERNS = [
265 /(^|\/)__tests__\//i,
266 /\.test\.[tj]sx?$/i,
267 /\.spec\.[tj]sx?$/i,
268 /(^|\/)tests?\//i,
269];
270
271function matchesAny(path: string, patterns: RegExp[]): boolean {
272 for (const re of patterns) if (re.test(path)) return true;
273 return false;
274}
275
276function isSchemaMigrationPath(path: string): boolean {
277 return matchesAny(path, SCHEMA_MIGRATION_PATTERNS);
278}
279
280function isLockedPath(path: string): boolean {
281 return matchesAny(path, LOCKED_PATH_PATTERNS);
282}
283
284function isTestPath(path: string): boolean {
285 return matchesAny(path, TEST_PATH_PATTERNS);
286}
287
288/** Diff-file shape we accept — keeps signal gather DI-friendly for tests. */
289export interface DiffFileInput {
290 path: string;
291 additions: number;
292 deletions: number;
293}
294
295/**
296 * Compute the raw signal map from a parsed diff plus owner rules and the
297 * pre/post dependency sets. Pure helper — no DB, no git, no LLM. Exported
298 * so it can be exercised directly by tests with synthetic inputs.
299 */
300export function buildSignalsFromDiff(args: {
301 files: DiffFileInput[];
302 raw: string;
303 ownerRules: OwnerRule[];
304 /** Existing dep set on the base side, keyed `ecosystem:name`. */
305 baseDeps: Map<string, string | null>;
306 /** Dep set after the PR's changes (best-effort), same key shape. */
307 headDeps: Map<string, string | null>;
308}): PrRiskSignals {
309 let linesAdded = 0;
310 let linesRemoved = 0;
311 let schemaMigrationTouched = false;
312 let lockedPathTouched = false;
313 let testFiles = 0;
314 let testLines = 0;
315 const ownerSet = new Set<string>();
316
317 for (const f of args.files) {
318 linesAdded += Math.max(0, f.additions || 0);
319 linesRemoved += Math.max(0, f.deletions || 0);
320 if (isSchemaMigrationPath(f.path)) schemaMigrationTouched = true;
321 if (isLockedPath(f.path)) lockedPathTouched = true;
322 if (isTestPath(f.path)) {
323 testFiles += 1;
324 testLines += Math.max(0, (f.additions || 0) + (f.deletions || 0));
325 }
326 if (args.ownerRules.length > 0) {
327 for (const o of ownersForPath(f.path, args.ownerRules)) {
328 ownerSet.add(o);
329 }
330 }
331 }
332
333 const filesChanged = args.files.length;
334 const teamsAffected = ownerSet.size;
335 const testsAddedForNewCode = testFiles > 0 && linesAdded > 0;
336 const totalLines = linesAdded + linesRemoved;
337 const diffMinusTestRatio =
338 totalLines === 0 ? 0 : 1 - Math.min(1, testLines / totalLines);
339
340 // Dependency comparison — adds vs bumps.
341 let addsNewDependency = false;
342 let bumpsMajorDependency = false;
343 for (const [key, headVersion] of args.headDeps) {
344 if (!args.baseDeps.has(key)) {
345 addsNewDependency = true;
346 continue;
347 }
348 const baseVersion = args.baseDeps.get(key) ?? null;
349 if (isMajorBump(baseVersion, headVersion)) {
350 bumpsMajorDependency = true;
351 }
352 }
353
354 return {
355 filesChanged,
356 linesAdded,
357 linesRemoved,
358 teamsAffected,
359 schemaMigrationTouched,
360 lockedPathTouched,
361 addsNewDependency,
362 bumpsMajorDependency,
363 testsAddedForNewCode,
364 diffMinusTestRatio,
365 };
366}
367
368/**
369 * Heuristic semver-major detector. Strips leading ^ ~ = v and compares
370 * the leading integer chunks. Anything we cannot parse returns false so
371 * we err toward "no false positive bump".
372 */
373export function isMajorBump(
374 before: string | null | undefined,
375 after: string | null | undefined
376): boolean {
377 const a = leadingMajor(before);
378 const b = leadingMajor(after);
379 if (a === null || b === null) return false;
380 return b > a;
381}
382
383function leadingMajor(spec: string | null | undefined): number | null {
384 if (!spec) return null;
385 const stripped = spec.trim().replace(/^[~^=v]+/, "");
386 const match = stripped.match(/^(\d+)/);
387 if (!match) return null;
388 const n = parseInt(match[1], 10);
389 return Number.isFinite(n) ? n : null;
390}
391
392// ---------------------------------------------------------------------------
393// DB-touching orchestrator
394// ---------------------------------------------------------------------------
395
396interface PrRow {
397 id: string;
398 title: string;
399 baseBranch: string;
400 headBranch: string;
401 repositoryId: string;
402}
403
404interface RepoRow {
405 id: string;
406 name: string;
407 ownerUsername: string;
408}
409
410async function loadPrRow(prId: string): Promise<{
411 pr: PrRow;
412 repo: RepoRow;
413} | null> {
414 try {
415 const [row] = await db
416 .select({
417 prId: pullRequests.id,
418 title: pullRequests.title,
419 baseBranch: pullRequests.baseBranch,
420 headBranch: pullRequests.headBranch,
421 repositoryId: pullRequests.repositoryId,
422 repoName: repositories.name,
423 ownerUsername: users.username,
424 })
425 .from(pullRequests)
426 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
427 .innerJoin(users, eq(users.id, repositories.ownerId))
428 .where(eq(pullRequests.id, prId))
429 .limit(1);
430 if (!row) return null;
431 return {
432 pr: {
433 id: row.prId,
434 title: row.title,
435 baseBranch: row.baseBranch,
436 headBranch: row.headBranch,
437 repositoryId: row.repositoryId,
438 },
439 repo: {
440 id: row.repositoryId,
441 name: row.repoName,
442 ownerUsername: row.ownerUsername,
443 },
444 };
445 } catch {
446 return null;
447 }
448}
449
450async function loadOwnerRules(repoId: string): Promise<OwnerRule[]> {
451 try {
452 const rows = await db
453 .select({
454 pattern: codeOwners.pathPattern,
455 owners: codeOwners.ownerUsernames,
456 })
457 .from(codeOwners)
458 .where(eq(codeOwners.repositoryId, repoId));
459 return rows.map((r) => ({
460 pattern: r.pattern,
461 owners: (r.owners || "").split(",").filter(Boolean),
462 }));
463 } catch {
464 return [];
465 }
466}
467
468async function loadCurrentDeps(
469 repoId: string
470): Promise<Map<string, string | null>> {
471 try {
472 const rows = await db
473 .select({
474 ecosystem: repoDependencies.ecosystem,
475 name: repoDependencies.name,
476 versionSpec: repoDependencies.versionSpec,
477 })
478 .from(repoDependencies)
479 .where(eq(repoDependencies.repositoryId, repoId));
480 const out = new Map<string, string | null>();
481 for (const r of rows) {
482 out.set(`${r.ecosystem}:${r.name}`, r.versionSpec);
483 }
484 return out;
485 } catch {
486 return new Map();
487 }
488}
489
490/**
491 * Parse the dependency state shown by the PR's head commit. We re-use
492 * the indexed base-side `repo_dependencies` rows as a baseline and look
493 * for additions/changes by parsing a head-side `package.json` blob via
494 * `git show`. If git access fails we return null which the caller treats
495 * as "no signal" (addsNewDependency = bumpsMajorDependency = false).
496 */
497async function loadHeadDeps(
498 ownerName: string,
499 repoName: string,
500 baseBranch: string,
501 headBranch: string
502): Promise<Map<string, string | null>> {
503 const out = new Map<string, string | null>();
504 // We only sniff package.json on the head side (the most common manifest);
505 // the base-side counts come from the indexed snapshot already. This is a
506 // best-effort signal — false negatives are fine, false positives would be
507 // worse.
508 try {
509 const cwd = getRepoPath(ownerName, repoName);
510 const proc = Bun.spawn(
511 ["git", "show", `${headBranch}:package.json`],
512 { cwd, stdout: "pipe", stderr: "pipe" }
513 );
514 const text = await new Response(proc.stdout).text();
515 const exit = await proc.exited;
516 if (exit !== 0 || !text.trim()) return out;
517 const parsed = JSON.parse(text) as Record<string, unknown>;
518 for (const key of [
519 "dependencies",
520 "devDependencies",
521 "peerDependencies",
522 "optionalDependencies",
523 ]) {
524 const block = parsed[key];
525 if (!block || typeof block !== "object") continue;
526 for (const [name, spec] of Object.entries(block as Record<string, unknown>)) {
527 out.set(
528 `npm:${name}`,
529 typeof spec === "string" ? spec : null
530 );
531 }
532 }
533 // Mark baseBranch silently used so the args object stays meaningful
534 // even if a future refactor adds a base-side diff.
535 void baseBranch;
536 } catch {
537 /* swallow — return whatever we have */
538 }
539 return out;
540}
541
542/**
543 * Compute + persist the risk score for one PR. Returns null when the PR
544 * cannot be found or the git head SHA cannot be resolved. Idempotent on
545 * the unique (pull_request_id, commit_sha) constraint.
546 */
547export async function computePrRiskForPullRequest(
548 pullRequestId: string,
549 opts: { now?: Date } = {}
550): Promise<PrRiskScore | null> {
551 const loaded = await loadPrRow(pullRequestId);
552 if (!loaded) return null;
553
554 const { pr, repo } = loaded;
555 const headSha = await resolveRef(
556 repo.ownerUsername,
557 repo.name,
558 pr.headBranch
559 ).catch(() => null);
560 if (!headSha) return null;
561
562 let diffFiles: DiffFileInput[] = [];
563 let diffRaw = "";
564 try {
565 const d = await getDiff(repo.ownerUsername, repo.name, headSha);
566 diffFiles = d.files.map((f) => ({
567 path: f.path,
568 additions: f.additions,
569 deletions: f.deletions,
570 }));
571 diffRaw = d.raw;
572 } catch {
573 /* swallow — buildSignalsFromDiff handles empty arrays */
574 }
575
576 const ownerRules = await loadOwnerRules(repo.id);
577 const baseDeps = await loadCurrentDeps(repo.id);
578 const headDeps = await loadHeadDeps(
579 repo.ownerUsername,
580 repo.name,
581 pr.baseBranch,
582 pr.headBranch
583 );
584
585 const signals = buildSignalsFromDiff({
586 files: diffFiles,
587 raw: diffRaw,
588 ownerRules,
589 baseDeps,
590 headDeps,
591 });
592
593 const { score, band } = computePrRiskScore(signals);
594
595 const aiSummary = await generatePrRiskSummary({
596 signals,
597 title: pr.title,
598 baseBranch: pr.baseBranch,
599 headBranch: pr.headBranch,
600 });
601
602 const generatedAt = opts.now ?? new Date();
603
604 // Best-effort upsert: try insert, fall back to existing row on the
605 // unique-constraint hit.
606 try {
607 await db.insert(prRiskScores).values({
608 pullRequestId,
609 commitSha: headSha,
610 score,
611 band,
612 signals,
613 aiSummary,
614 generatedAt,
615 });
616 } catch {
617 // Already cached for this SHA — that's fine, the caller's view
618 // converges on the same shape via getCachedPrRisk below. No throw.
619 }
620
621 return {
622 score,
623 band,
624 signals,
625 aiSummary,
626 generatedAt,
627 commitSha: headSha,
628 };
629}
630
631/**
632 * Return the cached risk score for the current head SHA of a PR, if any.
633 * Returns null when no row matches (cache miss) — the caller is expected
634 * to kick off `computePrRiskForPullRequest` async and show a placeholder.
635 */
636export async function getCachedPrRisk(
637 pullRequestId: string
638): Promise<PrRiskScore | null> {
639 try {
640 const loaded = await loadPrRow(pullRequestId);
641 if (!loaded) return null;
642 const headSha = await resolveRef(
643 loaded.repo.ownerUsername,
644 loaded.repo.name,
645 loaded.pr.headBranch
646 ).catch(() => null);
647 if (!headSha) return null;
648
649 const [row] = await db
650 .select()
651 .from(prRiskScores)
652 .where(
653 and(
654 eq(prRiskScores.pullRequestId, pullRequestId),
655 eq(prRiskScores.commitSha, headSha)
656 )
657 )
658 .orderBy(desc(prRiskScores.generatedAt))
659 .limit(1);
660
661 if (!row) return null;
662 return rowToPrRiskScore(row);
663 } catch {
664 return null;
665 }
666}
667
668/**
669 * Lookup variant that doesn't need git access — returns the most-recent
670 * cached score for a PR regardless of which SHA it was pinned to. Used
671 * by the MCP merge tool where we want the score even if the SHA has
672 * since moved on.
673 */
674export async function getLatestCachedPrRisk(
675 pullRequestId: string
676): Promise<PrRiskScore | null> {
677 try {
678 const [row] = await db
679 .select()
680 .from(prRiskScores)
681 .where(eq(prRiskScores.pullRequestId, pullRequestId))
682 .orderBy(desc(prRiskScores.generatedAt))
683 .limit(1);
684 if (!row) return null;
685 return rowToPrRiskScore(row);
686 } catch {
687 return null;
688 }
689}
690
691function rowToPrRiskScore(row: PrRiskScoreRow): PrRiskScore {
692 return {
693 score: row.score,
694 band: row.band as PrRiskBand,
695 signals: (row.signals as unknown) as PrRiskSignals,
696 aiSummary: row.aiSummary ?? "",
697 generatedAt: row.generatedAt,
698 commitSha: row.commitSha,
699 };
700}
701
702// ---------------------------------------------------------------------------
703// Test-only surface
704// ---------------------------------------------------------------------------
705
706export const __test = {
707 bandFor,
708 clamp01,
709 deterministicSummary,
710 isMajorBump,
711 leadingMajor,
712 isSchemaMigrationPath,
713 isLockedPath,
714 isTestPath,
715 rowToPrRiskScore,
716};
Addedsrc/lib/push.ts+572−0View fileUnifiedSplit
@@ -0,0 +1,572 @@
1/**
2 * Block M2 — Web Push delivery.
3 *
4 * Pure Web Crypto + Bun fetch — no `web-push` npm dependency. Implements
5 * RFC 8291 (Message Encryption for Web Push) + RFC 8188 (aes128gcm
6 * content encoding) + RFC 8292 (VAPID JWT).
7 *
8 * Public surface:
9 * - getVapidPublicKey(): base64url public key (process-stable)
10 * - subscribeUser, unsubscribeUser: persistence helpers
11 * - sendPushToUser: fan-out delivery with stale-endpoint cleanup
12 * - pushFromNotification: hook callers can fire alongside notify()
13 *
14 * Stale endpoints (HTTP 404/410) are deleted on next send. Other failures
15 * are swallowed per-subscription. The outbound transport is replaceable
16 * via `__setSendTransport` so unit tests never hit the real network.
17 */
18
19import { and, eq } from "drizzle-orm";
20import { db } from "../db";
21import { pushSubscriptions, users } from "../db/schema";
22import type { NotificationKind } from "./notify";
23
24// ---------------------------------------------------------------------------
25// base64url helpers
26// ---------------------------------------------------------------------------
27
28export function b64urlEncode(bytes: Uint8Array | ArrayBuffer): string {
29 const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
30 let bin = "";
31 for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]!);
32 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33}
34
35export function b64urlDecode(s: string): Uint8Array {
36 const norm = s.replace(/-/g, "+").replace(/_/g, "/");
37 const pad = norm.length % 4 === 0 ? "" : "=".repeat(4 - (norm.length % 4));
38 const bin = atob(norm + pad);
39 const out = new Uint8Array(bin.length);
40 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
41 return out;
42}
43
44// ---------------------------------------------------------------------------
45// VAPID keypair — env-first, process-cached fallback
46// ---------------------------------------------------------------------------
47
48type VapidKeypair = {
49 /** Uncompressed P-256 public key, 65 bytes, base64url. */
50 publicKey: string;
51 /** Raw 32-byte private scalar, base64url. */
52 privateKey: string;
53};
54
55let _cachedKeys: VapidKeypair | null = null;
56let _warnedAboutGenerated = false;
57
58async function generateVapidKeypair(): Promise<VapidKeypair> {
59 const kp = await crypto.subtle.generateKey(
60 { name: "ECDSA", namedCurve: "P-256" },
61 true,
62 ["sign", "verify"]
63 );
64 const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey);
65 const jwk = (await crypto.subtle.exportKey(
66 "jwk",
67 kp.privateKey
68 )) as JsonWebKey;
69 if (!jwk.d) throw new Error("vapid keygen: missing private scalar");
70 return {
71 publicKey: b64urlEncode(rawPub),
72 privateKey: jwk.d,
73 };
74}
75
76export async function getVapidKeypair(): Promise<VapidKeypair> {
77 if (_cachedKeys) return _cachedKeys;
78 const envPub = process.env.VAPID_PUBLIC_KEY?.trim();
79 const envPriv = process.env.VAPID_PRIVATE_KEY?.trim();
80 if (envPub && envPriv) {
81 _cachedKeys = { publicKey: envPub, privateKey: envPriv };
82 return _cachedKeys;
83 }
84 _cachedKeys = await generateVapidKeypair();
85 if (!_warnedAboutGenerated) {
86 _warnedAboutGenerated = true;
87 console.warn(
88 "[push] Using a generated VAPID keypair — subscriptions will break on " +
89 "process restart. Set VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY in env to " +
90 "persist across restarts. Generated public key: " +
91 _cachedKeys.publicKey
92 );
93 }
94 return _cachedKeys;
95}
96
97export async function getVapidPublicKey(): Promise<string> {
98 const kp = await getVapidKeypair();
99 return kp.publicKey;
100}
101
102export function __resetVapidCacheForTests(): void {
103 _cachedKeys = null;
104 _warnedAboutGenerated = false;
105}
106
107// ---------------------------------------------------------------------------
108// Subscription persistence
109// ---------------------------------------------------------------------------
110
111export type SubscribeInput = {
112 endpoint: string;
113 keys: { p256dh: string; auth: string };
114};
115
116export async function subscribeUser(
117 userId: string,
118 sub: SubscribeInput,
119 userAgent?: string
120): Promise<void> {
121 if (!sub?.endpoint || !sub?.keys?.p256dh || !sub?.keys?.auth) {
122 throw new Error("invalid subscription payload");
123 }
124 try {
125 await db
126 .insert(pushSubscriptions)
127 .values({
128 userId,
129 endpoint: sub.endpoint,
130 p256dh: sub.keys.p256dh,
131 auth: sub.keys.auth,
132 userAgent: userAgent ?? null,
133 })
134 .onConflictDoUpdate({
135 target: [pushSubscriptions.userId, pushSubscriptions.endpoint],
136 set: {
137 p256dh: sub.keys.p256dh,
138 auth: sub.keys.auth,
139 userAgent: userAgent ?? null,
140 },
141 });
142 } catch (err) {
143 console.error("[push] subscribeUser failed:", err);
144 throw err;
145 }
146}
147
148export async function unsubscribeUser(
149 userId: string,
150 endpoint: string
151): Promise<void> {
152 try {
153 await db
154 .delete(pushSubscriptions)
155 .where(
156 and(
157 eq(pushSubscriptions.userId, userId),
158 eq(pushSubscriptions.endpoint, endpoint)
159 )
160 );
161 } catch (err) {
162 console.error("[push] unsubscribeUser failed:", err);
163 }
164}
165
166// ---------------------------------------------------------------------------
167// Web Push HTTP encryption (RFC 8291 + RFC 8188 aes128gcm)
168// ---------------------------------------------------------------------------
169
170type SubscriptionRow = {
171 endpoint: string;
172 p256dh: string;
173 auth: string;
174};
175
176const TEXT = new TextEncoder();
177
178async function hkdf(
179 ikm: Uint8Array,
180 salt: Uint8Array,
181 info: Uint8Array,
182 length: number
183): Promise<Uint8Array> {
184 const key = await crypto.subtle.importKey(
185 "raw",
186 ikm as BufferSource,
187 "HKDF",
188 false,
189 ["deriveBits"]
190 );
191 const bits = await crypto.subtle.deriveBits(
192 {
193 name: "HKDF",
194 hash: "SHA-256",
195 salt: salt as BufferSource,
196 info: info as BufferSource,
197 },
198 key,
199 length * 8
200 );
201 return new Uint8Array(bits);
202}
203
204export async function encryptPayload(
205 payload: Uint8Array,
206 recipient: { p256dh: string; auth: string }
207): Promise<Uint8Array> {
208 const uaPublicRaw = b64urlDecode(recipient.p256dh);
209 const authSecret = b64urlDecode(recipient.auth);
210
211 const ephemeralKp = await crypto.subtle.generateKey(
212 { name: "ECDH", namedCurve: "P-256" },
213 true,
214 ["deriveBits"]
215 );
216 const ephemeralPubRaw = new Uint8Array(
217 await crypto.subtle.exportKey("raw", ephemeralKp.publicKey)
218 );
219
220 const uaPubKey = await crypto.subtle.importKey(
221 "raw",
222 uaPublicRaw as BufferSource,
223 { name: "ECDH", namedCurve: "P-256" },
224 false,
225 []
226 );
227 const ecdhSecret = new Uint8Array(
228 await crypto.subtle.deriveBits(
229 { name: "ECDH", public: uaPubKey },
230 ephemeralKp.privateKey,
231 256
232 )
233 );
234
235 const keyInfo = concat(
236 TEXT.encode("WebPush: info\0"),
237 uaPublicRaw,
238 ephemeralPubRaw
239 );
240 const ikm = await hkdf(ecdhSecret, authSecret, keyInfo, 32);
241
242 const salt = crypto.getRandomValues(new Uint8Array(16));
243 const cek = await hkdf(
244 ikm,
245 salt,
246 TEXT.encode("Content-Encoding: aes128gcm\0"),
247 16
248 );
249 const nonce = await hkdf(
250 ikm,
251 salt,
252 TEXT.encode("Content-Encoding: nonce\0"),
253 12
254 );
255
256 const plain = concat(payload, new Uint8Array([0x02]));
257
258 const aesKey = await crypto.subtle.importKey(
259 "raw",
260 cek as BufferSource,
261 "AES-GCM",
262 false,
263 ["encrypt"]
264 );
265 const cipher = new Uint8Array(
266 await crypto.subtle.encrypt(
267 { name: "AES-GCM", iv: nonce as BufferSource },
268 aesKey,
269 plain as BufferSource
270 )
271 );
272
273 const rs = 4096;
274 const header = new Uint8Array(16 + 4 + 1 + 65);
275 header.set(salt, 0);
276 header[16] = (rs >>> 24) & 0xff;
277 header[17] = (rs >>> 16) & 0xff;
278 header[18] = (rs >>> 8) & 0xff;
279 header[19] = rs & 0xff;
280 header[20] = 65;
281 header.set(ephemeralPubRaw, 21);
282
283 return concat(header, cipher);
284}
285
286function concat(...parts: Uint8Array[]): Uint8Array {
287 let total = 0;
288 for (const p of parts) total += p.length;
289 const out = new Uint8Array(total);
290 let off = 0;
291 for (const p of parts) {
292 out.set(p, off);
293 off += p.length;
294 }
295 return out;
296}
297
298// ---------------------------------------------------------------------------
299// VAPID JWT (RFC 8292)
300// ---------------------------------------------------------------------------
301
302function endpointOrigin(endpoint: string): string {
303 try {
304 const u = new URL(endpoint);
305 return `${u.protocol}//${u.host}`;
306 } catch {
307 return "";
308 }
309}
310
311export async function signVapidJwt(
312 audience: string,
313 subject: string
314): Promise<string> {
315 const header = { typ: "JWT", alg: "ES256" };
316 const claims = {
317 aud: audience,
318 exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
319 sub: subject,
320 };
321 const encHeader = b64urlEncode(TEXT.encode(JSON.stringify(header)));
322 const encClaims = b64urlEncode(TEXT.encode(JSON.stringify(claims)));
323 const signingInput = `${encHeader}.${encClaims}`;
324
325 const kp = await getVapidKeypair();
326 const pubRaw = b64urlDecode(kp.publicKey);
327 if (pubRaw[0] !== 0x04 || pubRaw.length !== 65) {
328 throw new Error("vapid public key must be 65-byte uncompressed P-256");
329 }
330 const x = pubRaw.slice(1, 33);
331 const y = pubRaw.slice(33, 65);
332
333 const jwk: JsonWebKey = {
334 kty: "EC",
335 crv: "P-256",
336 x: b64urlEncode(x),
337 y: b64urlEncode(y),
338 d: kp.privateKey,
339 ext: true,
340 };
341 const key = await crypto.subtle.importKey(
342 "jwk",
343 jwk,
344 { name: "ECDSA", namedCurve: "P-256" },
345 false,
346 ["sign"]
347 );
348 const sig = new Uint8Array(
349 await crypto.subtle.sign(
350 { name: "ECDSA", hash: "SHA-256" },
351 key,
352 TEXT.encode(signingInput) as BufferSource
353 )
354 );
355 return `${signingInput}.${b64urlEncode(sig)}`;
356}
357
358// ---------------------------------------------------------------------------
359// Transport seam
360// ---------------------------------------------------------------------------
361
362export type SendTransport = (
363 url: string,
364 init: { method: string; headers: Record<string, string>; body: Uint8Array }
365) => Promise<{ status: number }>;
366
367let _transport: SendTransport = async (url, init) => {
368 const res = await fetch(url, {
369 method: init.method,
370 headers: init.headers,
371 body: init.body as BodyInit,
372 });
373 return { status: res.status };
374};
375
376export function __setSendTransport(fn: SendTransport): SendTransport {
377 const prev = _transport;
378 _transport = fn;
379 return prev;
380}
381
382// ---------------------------------------------------------------------------
383// Top-level send
384// ---------------------------------------------------------------------------
385
386export type PushPayload = {
387 title: string;
388 body: string;
389 url?: string;
390 tag?: string;
391 icon?: string;
392};
393
394function payloadJson(p: PushPayload): string {
395 return JSON.stringify({
396 title: p.title,
397 body: p.body,
398 url: p.url ?? "/",
399 tag: p.tag ?? "gluecron",
400 icon: p.icon ?? "/icon.svg",
401 });
402}
403
404async function deliverOne(
405 sub: SubscriptionRow,
406 payload: PushPayload
407): Promise<{ ok: boolean; status: number }> {
408 try {
409 const kp = await getVapidKeypair();
410 const aud = endpointOrigin(sub.endpoint);
411 if (!aud) return { ok: false, status: 0 };
412 const sub_email =
413 process.env.VAPID_SUBJECT?.trim() || "mailto:ops@gluecron.com";
414 const jwt = await signVapidJwt(aud, sub_email);
415
416 const body = await encryptPayload(TEXT.encode(payloadJson(payload)), {
417 p256dh: sub.p256dh,
418 auth: sub.auth,
419 });
420
421 const headers: Record<string, string> = {
422 authorization: `vapid t=${jwt}, k=${kp.publicKey}`,
423 "content-encoding": "aes128gcm",
424 "content-type": "application/octet-stream",
425 ttl: "86400",
426 };
427 const res = await _transport(sub.endpoint, {
428 method: "POST",
429 headers,
430 body,
431 });
432 return { ok: res.status >= 200 && res.status < 300, status: res.status };
433 } catch (err) {
434 console.error("[push] deliver failed:", err);
435 return { ok: false, status: 0 };
436 }
437}
438
439export async function sendPushToUser(
440 userId: string,
441 payload: PushPayload
442): Promise<{ sent: number; failed: number }> {
443 let rows: SubscriptionRow[] = [];
444 try {
445 rows = await db
446 .select({
447 endpoint: pushSubscriptions.endpoint,
448 p256dh: pushSubscriptions.p256dh,
449 auth: pushSubscriptions.auth,
450 })
451 .from(pushSubscriptions)
452 .where(eq(pushSubscriptions.userId, userId));
453 } catch (err) {
454 console.error("[push] sendPushToUser select failed:", err);
455 return { sent: 0, failed: 0 };
456 }
457
458 let sent = 0;
459 let failed = 0;
460 for (const row of rows) {
461 const res = await deliverOne(row, payload);
462 if (res.ok) {
463 sent++;
464 try {
465 await db
466 .update(pushSubscriptions)
467 .set({ lastUsedAt: new Date() })
468 .where(
469 and(
470 eq(pushSubscriptions.userId, userId),
471 eq(pushSubscriptions.endpoint, row.endpoint)
472 )
473 );
474 } catch (_) {
475 // swallow
476 }
477 } else {
478 failed++;
479 if (res.status === 404 || res.status === 410) {
480 try {
481 await db
482 .delete(pushSubscriptions)
483 .where(
484 and(
485 eq(pushSubscriptions.userId, userId),
486 eq(pushSubscriptions.endpoint, row.endpoint)
487 )
488 );
489 } catch (_) {
490 // swallow
491 }
492 }
493 }
494 }
495 return { sent, failed };
496}
497
498// ---------------------------------------------------------------------------
499// notify() bridge
500// ---------------------------------------------------------------------------
501
502function pushPrefColumnFor(
503 kind: NotificationKind
504):
505 | "notifyPushOnMention"
506 | "notifyPushOnAssign"
507 | "notifyPushOnReviewRequest"
508 | "notifyPushOnDeployFailed"
509 | null {
510 switch (kind) {
511 case "mention":
512 return "notifyPushOnMention";
513 case "assigned":
514 return "notifyPushOnAssign";
515 case "review_requested":
516 return "notifyPushOnReviewRequest";
517 case "deploy_failed":
518 return "notifyPushOnDeployFailed";
519 default:
520 return null;
521 }
522}
523
524/**
525 * Optional hook callers can fire ALONGSIDE notify() to fan out a Web Push.
526 * Looks up the user's per-event preference and silently no-ops if the user
527 * opted out, the kind isn't push-eligible, or the user has no subscriptions.
528 */
529export async function pushFromNotification(
530 userId: string,
531 kind: NotificationKind,
532 opts: { title: string; body?: string; url?: string }
533): Promise<{ sent: number; failed: number } | null> {
534 const col = pushPrefColumnFor(kind);
535 if (!col) return null;
536 try {
537 const rows = await db
538 .select({
539 mention: users.notifyPushOnMention,
540 assign: users.notifyPushOnAssign,
541 review: users.notifyPushOnReviewRequest,
542 deploy: users.notifyPushOnDeployFailed,
543 })
544 .from(users)
545 .where(eq(users.id, userId))
546 .limit(1);
547 const u = rows[0];
548 if (!u) return null;
549 const allowed =
550 col === "notifyPushOnMention"
551 ? u.mention
552 : col === "notifyPushOnAssign"
553 ? u.assign
554 : col === "notifyPushOnReviewRequest"
555 ? u.review
556 : u.deploy;
557 if (!allowed) return null;
558 } catch (err) {
559 console.error("[push] pushFromNotification pref lookup failed:", err);
560 return null;
561 }
562 return sendPushToUser(userId, {
563 title: opts.title,
564 body: opts.body ?? "",
565 url: opts.url,
566 tag: `${kind}:${opts.url ?? ""}`,
567 });
568}
569
570export const __internal = {
571 pushPrefColumnFor,
572};
Addedsrc/lib/stale-sweep.ts+665−0View fileUnifiedSplit
@@ -0,0 +1,665 @@
1/**
2 * Block M5 — Stale PR/issue sweeper.
3 *
4 * Two-stage gate: the autopilot poke-then-close pipeline.
5 *
6 * Stage 1 (poke):
7 * - Pull-requests: open, non-draft, `updated_at` older than 7 days, AND no
8 * `pr_comments` row carrying `STALE_PR_POKE_MARKER` posted in the last
9 * 7 days → post a polite poke comment + `notify()` the author with
10 * `kind: "pr_stale"` + audit `pr.stale_poked`.
11 * - Issues: open, `updated_at` older than 30 days, AND no
12 * `issue_comments` row carrying `STALE_ISSUE_POKE_MARKER` in the last
13 * 30 days → post a polite poke comment + `notify()` the author with
14 * `kind: "issue_stale"` + audit `issue.stale_poked`.
15 *
16 * Stage 2 (close):
17 * - Pull-requests: a poke older than 14 days with no human reply since
18 * → AUTO-CLOSE the PR, post the final close marker comment, audit
19 * `pr.stale_closed`. Skipped when `repositories.auto_close_stale_prs=false`.
20 * - Issues: a poke older than 60 days with no human reply since
21 * → AUTO-CLOSE the issue, post the final close marker comment, audit
22 * `issue.stale_closed`. Skipped when `repositories.auto_close_stale_issues=false`.
23 *
24 * Idempotency is provided by the HTML-comment markers — a re-run of the
25 * same tick will never re-poke or double-close.
26 *
27 * Every DB call is dependency-injected so the test suite can exercise the
28 * loop without touching Neon. The default orchestrators wire real Drizzle
29 * helpers; the test suite supplies fakes.
30 *
31 * Nothing here throws. Every per-PR / per-issue branch is wrapped in
32 * try/catch so one bad row never wedges the tick.
33 */
34
35import { and, desc, eq, lt, sql } from "drizzle-orm";
36import { db } from "../db";
37import {
38 issueComments,
39 issues,
40 prComments,
41 pullRequests,
42 repositories,
43} from "../db/schema";
44import { audit, notify } from "./notify";
45
46// ---------------------------------------------------------------------------
47// Marker constants — stable HTML comments. Versioned so a v2 contract can
48// re-sweep old rows by minting a fresh marker string.
49// ---------------------------------------------------------------------------
50
51export const STALE_PR_POKE_MARKER = "<!-- gluecron:stale-poke:v1 -->";
52export const STALE_PR_CLOSE_MARKER = "<!-- gluecron:stale-close:v1 -->";
53export const STALE_ISSUE_POKE_MARKER =
54 "<!-- gluecron:stale-issue-poke:v1 -->";
55export const STALE_ISSUE_CLOSE_MARKER =
56 "<!-- gluecron:stale-issue-close:v1 -->";
57
58// ---------------------------------------------------------------------------
59// Time windows
60// ---------------------------------------------------------------------------
61
62const MS_PER_DAY = 24 * 60 * 60 * 1000;
63
64/** Poke threshold for pull requests — "no activity for N days." */
65export const STALE_PR_POKE_DAYS = 7;
66/** Close threshold after the stage-1 poke for pull requests. */
67export const STALE_PR_CLOSE_DAYS = 14;
68/** Poke threshold for issues. */
69export const STALE_ISSUE_POKE_DAYS = 30;
70/** Close threshold after the stage-1 poke for issues. */
71export const STALE_ISSUE_CLOSE_DAYS = 60;
72
73/** Per-tick cap on number of pokes/closes — spec says 30. */
74export const STALE_DEFAULT_CAP = 30;
75
76// ---------------------------------------------------------------------------
77// Comment bodies
78// ---------------------------------------------------------------------------
79
80const PR_POKE_BODY = `${STALE_PR_POKE_MARKER}
81## This PR has gone quiet
82No activity for 7+ days. Is this still in progress?
83- **Author**: keep working / mark draft / close.
84- **Reviewers**: review or unassign yourself.
85- **Maintainers**: close if no longer relevant.`;
86
87const PR_CLOSE_BODY = `${STALE_PR_CLOSE_MARKER}
88Closed as stale — please reopen if still relevant.`;
89
90const ISSUE_POKE_BODY = `${STALE_ISSUE_POKE_MARKER}
91## This issue has gone quiet
92Still relevant? No activity for 30+ days. Close if not, or comment to keep open.`;
93
94const ISSUE_CLOSE_BODY = `${STALE_ISSUE_CLOSE_MARKER}
95Closed as stale — please reopen if still relevant.`;
96
97// ---------------------------------------------------------------------------
98// Pure helpers — exported so tests can hit them directly.
99// ---------------------------------------------------------------------------
100
101/**
102 * True iff the PR is past its poke threshold AND we haven't poked it
103 * within the same window. `hasPokeWithin` is the result of "is there a
104 * comment carrying STALE_PR_POKE_MARKER newer than `now - 7d`?".
105 */
106export function shouldPokePr(
107 pr: { updatedAt: Date; hasPokeWithin: boolean },
108 now: Date
109): boolean {
110 if (pr.hasPokeWithin) return false;
111 const ageMs = now.getTime() - new Date(pr.updatedAt).getTime();
112 return ageMs >= STALE_PR_POKE_DAYS * MS_PER_DAY;
113}
114
115/**
116 * True iff the most recent poke is older than the close threshold and
117 * nothing human-shaped has happened since. The caller supplies
118 * `lastPokedAt` (most-recent poke comment's createdAt); a null means "no
119 * poke posted yet" → cannot close.
120 */
121export function shouldClosePr(
122 pr: { lastPokedAt: Date | null },
123 now: Date
124): boolean {
125 if (!pr.lastPokedAt) return false;
126 const ageMs = now.getTime() - new Date(pr.lastPokedAt).getTime();
127 return ageMs >= STALE_PR_CLOSE_DAYS * MS_PER_DAY;
128}
129
130/** Issue-flavoured mirror of `shouldPokePr`. */
131export function shouldPokeIssue(
132 i: { updatedAt: Date; hasPokeWithin: boolean },
133 now: Date
134): boolean {
135 if (i.hasPokeWithin) return false;
136 const ageMs = now.getTime() - new Date(i.updatedAt).getTime();
137 return ageMs >= STALE_ISSUE_POKE_DAYS * MS_PER_DAY;
138}
139
140/** Issue-flavoured mirror of `shouldClosePr`. */
141export function shouldCloseIssue(
142 i: { lastPokedAt: Date | null },
143 now: Date
144): boolean {
145 if (!i.lastPokedAt) return false;
146 const ageMs = now.getTime() - new Date(i.lastPokedAt).getTime();
147 return ageMs >= STALE_ISSUE_CLOSE_DAYS * MS_PER_DAY;
148}
149
150// ---------------------------------------------------------------------------
151// Candidate shapes — what the orchestrators receive from the finder.
152// ---------------------------------------------------------------------------
153
154export interface StalePrCandidate {
155 prId: string;
156 prNumber: number;
157 repositoryId: string;
158 ownerUsername: string | null;
159 repoName: string;
160 authorUserId: string;
161 updatedAt: Date;
162 /** Whether a poke comment exists within the past poke-window. */
163 hasPokeWithin: boolean;
164 /** Most recent poke comment's createdAt (any age), or null. */
165 lastPokedAt: Date | null;
166 /** Whether the repo currently has `auto_close_stale_prs=true`. */
167 autoCloseEnabled: boolean;
168}
169
170export interface StaleIssueCandidate {
171 issueId: string;
172 issueNumber: number;
173 repositoryId: string;
174 ownerUsername: string | null;
175 repoName: string;
176 authorUserId: string;
177 updatedAt: Date;
178 hasPokeWithin: boolean;
179 lastPokedAt: Date | null;
180 autoCloseEnabled: boolean;
181}
182
183// ---------------------------------------------------------------------------
184// DI shapes — what tests can swap.
185// ---------------------------------------------------------------------------
186
187export interface StaleSweepDeps {
188 /** Wall-clock override. */
189 now?: Date;
190 /** Per-tick poke/close cap. */
191 cap?: number;
192 /** Inject a candidate-finder for the PR sweep. */
193 findPrCandidates?: (
194 now: Date,
195 cap: number
196 ) => Promise<StalePrCandidate[]>;
197 /** Inject the poke-side-effect. */
198 pokePr?: (cand: StalePrCandidate) => Promise<void>;
199 /** Inject the close-side-effect. */
200 closePr?: (cand: StalePrCandidate) => Promise<void>;
201}
202
203export interface StaleIssueSweepDeps {
204 now?: Date;
205 cap?: number;
206 findIssueCandidates?: (
207 now: Date,
208 cap: number
209 ) => Promise<StaleIssueCandidate[]>;
210 pokeIssue?: (cand: StaleIssueCandidate) => Promise<void>;
211 closeIssue?: (cand: StaleIssueCandidate) => Promise<void>;
212}
213
214export interface StaleSweepSummary {
215 poked: number;
216 closed: number;
217}
218
219// ---------------------------------------------------------------------------
220// Default DB-backed finders + side-effects.
221// ---------------------------------------------------------------------------
222
223/**
224 * Default PR candidate-finder. Selects open non-draft PRs whose
225 * `updatedAt` is older than the close threshold (the wider window — so
226 * we surface BOTH stage-1 pokes AND stage-2 closes in one pass), then
227 * joins to repo to fetch the `auto_close_stale_prs` flag and owner
228 * username, and joins to the latest poke comment via a correlated
229 * sub-select. Caller-side `shouldPokePr`/`shouldClosePr` decide stages.
230 *
231 * Archived repos are excluded — autopilot never touches them.
232 */
233async function defaultFindStalePrCandidates(
234 now: Date,
235 cap: number
236): Promise<StalePrCandidate[]> {
237 // We look back to the poke threshold (7 days) so that stage-1 candidates
238 // surface. Stage-2 close candidates have a poke comment, so they appear
239 // here too as long as the PR's `updatedAt` was set when the poke posted.
240 const cutoff = new Date(now.getTime() - STALE_PR_POKE_DAYS * MS_PER_DAY);
241 const pokeFreshCutoff = new Date(
242 now.getTime() - STALE_PR_POKE_DAYS * MS_PER_DAY
243 );
244 try {
245 const rows = await db
246 .select({
247 prId: pullRequests.id,
248 prNumber: pullRequests.number,
249 repositoryId: pullRequests.repositoryId,
250 authorUserId: pullRequests.authorId,
251 updatedAt: pullRequests.updatedAt,
252 autoCloseEnabled: repositories.autoCloseStalePrs,
253 repoName: repositories.name,
254 ownerUsername: sql<string | null>`(SELECT username FROM users WHERE users.id = ${repositories.ownerId})`,
255 lastPokedAt: sql<Date | null>`(
256 SELECT MAX(${prComments.createdAt})
257 FROM ${prComments}
258 WHERE ${prComments.pullRequestId} = ${pullRequests.id}
259 AND ${prComments.body} LIKE ${"%" + STALE_PR_POKE_MARKER + "%"}
260 )`,
261 })
262 .from(pullRequests)
263 .innerJoin(
264 repositories,
265 eq(repositories.id, pullRequests.repositoryId)
266 )
267 .where(
268 and(
269 eq(pullRequests.state, "open"),
270 eq(pullRequests.isDraft, false),
271 eq(repositories.isArchived, false),
272 lt(pullRequests.updatedAt, cutoff)
273 )
274 )
275 .orderBy(desc(pullRequests.updatedAt))
276 .limit(cap * 4); // Over-fetch slightly; loop caps actual side-effects.
277
278 return rows.map((r) => {
279 const lastPokedAt = r.lastPokedAt ? new Date(r.lastPokedAt) : null;
280 const hasPokeWithin =
281 !!lastPokedAt && lastPokedAt >= pokeFreshCutoff;
282 return {
283 prId: r.prId,
284 prNumber: r.prNumber,
285 repositoryId: r.repositoryId,
286 ownerUsername: r.ownerUsername ?? null,
287 repoName: r.repoName,
288 authorUserId: r.authorUserId,
289 updatedAt: new Date(r.updatedAt),
290 hasPokeWithin,
291 lastPokedAt,
292 autoCloseEnabled: !!r.autoCloseEnabled,
293 };
294 });
295 } catch (err) {
296 console.error("[autopilot] stale-pr-sweep: candidate query failed:", err);
297 return [];
298 }
299}
300
301/** Default poke side-effect: comment + notify + audit. */
302async function defaultPokePr(cand: StalePrCandidate): Promise<void> {
303 // 1. Post the marker comment as the PR author (avoids needing a system user).
304 try {
305 await db.insert(prComments).values({
306 pullRequestId: cand.prId,
307 authorId: cand.authorUserId,
308 body: PR_POKE_BODY,
309 isAiReview: false,
310 });
311 } catch (err) {
312 console.error(
313 `[autopilot] stale-pr-sweep: poke comment failed for pr=${cand.prId}:`,
314 err
315 );
316 // Don't fall through — without the marker we'd re-poke next tick.
317 return;
318 }
319 // 2. Notify the author. Best-effort; failures swallowed by notify().
320 const url =
321 cand.ownerUsername && cand.repoName
322 ? `/${cand.ownerUsername}/${cand.repoName}/pull/${cand.prNumber}`
323 : undefined;
324 await notify(cand.authorUserId, {
325 kind: "pr_stale",
326 title: `PR #${cand.prNumber} has gone quiet`,
327 body: "No activity for 7+ days. Reply to keep it open, or close it.",
328 url,
329 repositoryId: cand.repositoryId,
330 });
331 // 3. Audit row.
332 await audit({
333 repositoryId: cand.repositoryId,
334 action: "pr.stale_poked",
335 targetType: "pull_request",
336 targetId: cand.prId,
337 metadata: { prNumber: cand.prNumber },
338 });
339}
340
341/** Default close side-effect: comment + state→closed + audit. */
342async function defaultClosePr(cand: StalePrCandidate): Promise<void> {
343 // 1. Post the final close comment.
344 try {
345 await db.insert(prComments).values({
346 pullRequestId: cand.prId,
347 authorId: cand.authorUserId,
348 body: PR_CLOSE_BODY,
349 isAiReview: false,
350 });
351 } catch (err) {
352 console.error(
353 `[autopilot] stale-pr-sweep: close comment failed for pr=${cand.prId}:`,
354 err
355 );
356 }
357 // 2. Flip state→closed.
358 try {
359 await db
360 .update(pullRequests)
361 .set({
362 state: "closed",
363 closedAt: new Date(),
364 updatedAt: new Date(),
365 })
366 .where(eq(pullRequests.id, cand.prId));
367 } catch (err) {
368 console.error(
369 `[autopilot] stale-pr-sweep: close update failed for pr=${cand.prId}:`,
370 err
371 );
372 return;
373 }
374 // 3. Audit row.
375 await audit({
376 repositoryId: cand.repositoryId,
377 action: "pr.stale_closed",
378 targetType: "pull_request",
379 targetId: cand.prId,
380 metadata: { prNumber: cand.prNumber },
381 });
382}
383
384/** Issue-flavoured default candidate finder. */
385async function defaultFindStaleIssueCandidates(
386 now: Date,
387 cap: number
388): Promise<StaleIssueCandidate[]> {
389 const cutoff = new Date(now.getTime() - STALE_ISSUE_POKE_DAYS * MS_PER_DAY);
390 const pokeFreshCutoff = new Date(
391 now.getTime() - STALE_ISSUE_POKE_DAYS * MS_PER_DAY
392 );
393 try {
394 const rows = await db
395 .select({
396 issueId: issues.id,
397 issueNumber: issues.number,
398 repositoryId: issues.repositoryId,
399 authorUserId: issues.authorId,
400 updatedAt: issues.updatedAt,
401 autoCloseEnabled: repositories.autoCloseStaleIssues,
402 repoName: repositories.name,
403 ownerUsername: sql<string | null>`(SELECT username FROM users WHERE users.id = ${repositories.ownerId})`,
404 lastPokedAt: sql<Date | null>`(
405 SELECT MAX(${issueComments.createdAt})
406 FROM ${issueComments}
407 WHERE ${issueComments.issueId} = ${issues.id}
408 AND ${issueComments.body} LIKE ${"%" + STALE_ISSUE_POKE_MARKER + "%"}
409 )`,
410 })
411 .from(issues)
412 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
413 .where(
414 and(
415 eq(issues.state, "open"),
416 eq(repositories.isArchived, false),
417 lt(issues.updatedAt, cutoff)
418 )
419 )
420 .orderBy(desc(issues.updatedAt))
421 .limit(cap * 4);
422
423 return rows.map((r) => {
424 const lastPokedAt = r.lastPokedAt ? new Date(r.lastPokedAt) : null;
425 const hasPokeWithin =
426 !!lastPokedAt && lastPokedAt >= pokeFreshCutoff;
427 return {
428 issueId: r.issueId,
429 issueNumber: r.issueNumber,
430 repositoryId: r.repositoryId,
431 ownerUsername: r.ownerUsername ?? null,
432 repoName: r.repoName,
433 authorUserId: r.authorUserId,
434 updatedAt: new Date(r.updatedAt),
435 hasPokeWithin,
436 lastPokedAt,
437 autoCloseEnabled: !!r.autoCloseEnabled,
438 };
439 });
440 } catch (err) {
441 console.error(
442 "[autopilot] stale-issue-sweep: candidate query failed:",
443 err
444 );
445 return [];
446 }
447}
448
449/** Default issue poke. */
450async function defaultPokeIssue(cand: StaleIssueCandidate): Promise<void> {
451 try {
452 await db.insert(issueComments).values({
453 issueId: cand.issueId,
454 authorId: cand.authorUserId,
455 body: ISSUE_POKE_BODY,
456 });
457 } catch (err) {
458 console.error(
459 `[autopilot] stale-issue-sweep: poke comment failed for issue=${cand.issueId}:`,
460 err
461 );
462 return;
463 }
464 const url =
465 cand.ownerUsername && cand.repoName
466 ? `/${cand.ownerUsername}/${cand.repoName}/issues/${cand.issueNumber}`
467 : undefined;
468 await notify(cand.authorUserId, {
469 kind: "issue_stale",
470 title: `Issue #${cand.issueNumber} has gone quiet`,
471 body: "No activity for 30+ days. Reply to keep it open, or close it.",
472 url,
473 repositoryId: cand.repositoryId,
474 });
475 await audit({
476 repositoryId: cand.repositoryId,
477 action: "issue.stale_poked",
478 targetType: "issue",
479 targetId: cand.issueId,
480 metadata: { issueNumber: cand.issueNumber },
481 });
482}
483
484/** Default issue close. */
485async function defaultCloseIssue(cand: StaleIssueCandidate): Promise<void> {
486 try {
487 await db.insert(issueComments).values({
488 issueId: cand.issueId,
489 authorId: cand.authorUserId,
490 body: ISSUE_CLOSE_BODY,
491 });
492 } catch (err) {
493 console.error(
494 `[autopilot] stale-issue-sweep: close comment failed for issue=${cand.issueId}:`,
495 err
496 );
497 }
498 try {
499 await db
500 .update(issues)
501 .set({
502 state: "closed",
503 closedAt: new Date(),
504 updatedAt: new Date(),
505 })
506 .where(eq(issues.id, cand.issueId));
507 } catch (err) {
508 console.error(
509 `[autopilot] stale-issue-sweep: close update failed for issue=${cand.issueId}:`,
510 err
511 );
512 return;
513 }
514 await audit({
515 repositoryId: cand.repositoryId,
516 action: "issue.stale_closed",
517 targetType: "issue",
518 targetId: cand.issueId,
519 metadata: { issueNumber: cand.issueNumber },
520 });
521}
522
523// ---------------------------------------------------------------------------
524// Orchestrators — what the autopilot calls.
525// ---------------------------------------------------------------------------
526
527/**
528 * One iteration of the PR stale sweeper. Returns counts suitable for the
529 * autopilot tick log. Never throws.
530 *
531 * Decision matrix per candidate:
532 * 1. `hasPokeWithin` → skip (idempotency — already poked within window).
533 * 2. No poke ever AND old enough → POKE (stage 1).
534 * 3. Poke exists older than close threshold AND repo opted in → CLOSE.
535 * 4. Otherwise → skip.
536 *
537 * Per-tick cap is enforced across BOTH poke and close phases combined —
538 * i.e. we never side-effect more than `cap` PRs in a single tick.
539 */
540export async function runStalePrSweepOnce(
541 deps: StaleSweepDeps = {}
542): Promise<StaleSweepSummary> {
543 const now = deps.now ?? new Date();
544 const cap = deps.cap ?? STALE_DEFAULT_CAP;
545 const findCandidates =
546 deps.findPrCandidates ?? defaultFindStalePrCandidates;
547 const pokePr = deps.pokePr ?? defaultPokePr;
548 const closePr = deps.closePr ?? defaultClosePr;
549
550 let candidates: StalePrCandidate[] = [];
551 try {
552 candidates = await findCandidates(now, cap);
553 } catch (err) {
554 console.error("[autopilot] stale-pr-sweep: findCandidates threw:", err);
555 return { poked: 0, closed: 0 };
556 }
557
558 let poked = 0;
559 let closed = 0;
560 let acted = 0;
561
562 for (const cand of candidates) {
563 if (acted >= cap) break;
564 try {
565 // Stage 2 takes priority: if the existing poke is older than the
566 // close threshold AND the repo opted in, we close. We must NOT
567 // re-poke a PR that already has a poke — that would spam.
568 if (shouldClosePr(cand, now)) {
569 if (!cand.autoCloseEnabled) {
570 // Repo opted out of stage-2; don't close, don't re-poke.
571 continue;
572 }
573 await closePr(cand);
574 closed += 1;
575 acted += 1;
576 continue;
577 }
578 // Stage 1: poke if we haven't already inside the window.
579 if (shouldPokePr(cand, now)) {
580 await pokePr(cand);
581 poked += 1;
582 acted += 1;
583 }
584 } catch (err) {
585 console.error(
586 `[autopilot] stale-pr-sweep: per-PR failure for pr=${cand.prId}:`,
587 err
588 );
589 }
590 }
591
592 console.log(
593 `[autopilot] stale-pr-sweep: poked=${poked} closed=${closed}`
594 );
595 return { poked, closed };
596}
597
598/** Issue-flavoured mirror of `runStalePrSweepOnce`. */
599export async function runStaleIssueSweepOnce(
600 deps: StaleIssueSweepDeps = {}
601): Promise<StaleSweepSummary> {
602 const now = deps.now ?? new Date();
603 const cap = deps.cap ?? STALE_DEFAULT_CAP;
604 const findCandidates =
605 deps.findIssueCandidates ?? defaultFindStaleIssueCandidates;
606 const pokeIssue = deps.pokeIssue ?? defaultPokeIssue;
607 const closeIssue = deps.closeIssue ?? defaultCloseIssue;
608
609 let candidates: StaleIssueCandidate[] = [];
610 try {
611 candidates = await findCandidates(now, cap);
612 } catch (err) {
613 console.error(
614 "[autopilot] stale-issue-sweep: findCandidates threw:",
615 err
616 );
617 return { poked: 0, closed: 0 };
618 }
619
620 let poked = 0;
621 let closed = 0;
622 let acted = 0;
623
624 for (const cand of candidates) {
625 if (acted >= cap) break;
626 try {
627 if (shouldCloseIssue(cand, now)) {
628 if (!cand.autoCloseEnabled) continue;
629 await closeIssue(cand);
630 closed += 1;
631 acted += 1;
632 continue;
633 }
634 if (shouldPokeIssue(cand, now)) {
635 await pokeIssue(cand);
636 poked += 1;
637 acted += 1;
638 }
639 } catch (err) {
640 console.error(
641 `[autopilot] stale-issue-sweep: per-issue failure for issue=${cand.issueId}:`,
642 err
643 );
644 }
645 }
646
647 console.log(
648 `[autopilot] stale-issue-sweep: poked=${poked} closed=${closed}`
649 );
650 return { poked, closed };
651}
652
653/** Exposed for tests / debugging only. */
654export const __test = {
655 PR_POKE_BODY,
656 PR_CLOSE_BODY,
657 ISSUE_POKE_BODY,
658 ISSUE_CLOSE_BODY,
659 defaultFindStalePrCandidates,
660 defaultFindStaleIssueCandidates,
661 defaultPokePr,
662 defaultClosePr,
663 defaultPokeIssue,
664 defaultCloseIssue,
665};
Modifiedsrc/routes/pulls.tsx+151−0View fileUnifiedSplit
@@ -27,6 +27,11 @@ import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
2727import { triggerPrTriage } from "../lib/pr-triage";
2828import { generatePrSummary } from "../lib/ai-generators";
2929import { isAiAvailable } from "../lib/ai-client";
30import {
31 computePrRiskForPullRequest,
32 getCachedPrRisk,
33 type PrRiskScore,
34} from "../lib/pr-risk";
3035import { runAllGateChecks } from "../lib/gate";
3136import type { GateCheckResult } from "../lib/gate";
3237import {
@@ -148,6 +153,135 @@ const PrNav = ({
148153 />
149154);
150155
156/**
157 * Block M3 — pre-merge risk score card. Pure presentational helper.
158 * Rendered in the conversation tab above the gate checks block. Hidden
159 * entirely when the PR is closed/merged or there is nothing cached and
160 * nothing in-flight.
161 */
162function PrRiskCard({
163 risk,
164 calculating,
165}: {
166 risk: PrRiskScore | null;
167 calculating: boolean;
168}) {
169 if (!risk) {
170 return (
171 <div
172 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted)`}
173 >
174 <strong style="font-size: 13px; color: var(--text)">
175 Risk score: calculating…
176 </strong>
177 <div style="font-size: 12px; margin-top: 4px">
178 Refresh in a moment to see the pre-merge risk score for this PR.
179 </div>
180 </div>
181 );
182 }
183
184 const palette = riskBandPalette(risk.band);
185 const label = riskBandLabel(risk.band);
186
187 return (
188 <div
189 style={`margin-top: 20px; padding: 14px 16px; background: var(--bg-secondary); border: 2px solid ${palette.border}; border-radius: var(--radius)`}
190 >
191 <div style="display:flex;align-items:center;gap:8px;font-size:14px">
192 <strong>Risk score:</strong>
193 <span style={`color:${palette.border};font-weight:600`}>
194 {palette.icon} {label} ({risk.score}/10)
195 </span>
196 <span style="margin-left:auto;font-size:11px;color:var(--text-muted)">
197 {risk.commitSha.slice(0, 7)}
198 </span>
199 </div>
200 {risk.aiSummary && (
201 <div style="font-size:13px;color:var(--text);margin-top:8px;line-height:1.5">
202 {risk.aiSummary}
203 </div>
204 )}
205 <details style="margin-top:10px">
206 <summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">
207 See full signal breakdown
208 </summary>
209 <ul style="font-size:12px;margin:8px 0 0 0;padding-left:18px;color:var(--text)">
210 <li>files changed: {risk.signals.filesChanged}</li>
211 <li>
212 lines added/removed: {risk.signals.linesAdded} /{" "}
213 {risk.signals.linesRemoved}
214 </li>
215 <li>distinct owners touched: {risk.signals.teamsAffected}</li>
216 <li>
217 schema migration touched:{" "}
218 {risk.signals.schemaMigrationTouched ? "yes" : "no"}
219 </li>
220 <li>
221 locked / sensitive path touched:{" "}
222 {risk.signals.lockedPathTouched ? "yes" : "no"}
223 </li>
224 <li>
225 adds new dependency:{" "}
226 {risk.signals.addsNewDependency ? "yes" : "no"}
227 </li>
228 <li>
229 bumps major dependency:{" "}
230 {risk.signals.bumpsMajorDependency ? "yes" : "no"}
231 </li>
232 <li>
233 tests added for new code:{" "}
234 {risk.signals.testsAddedForNewCode ? "yes" : "no"}
235 </li>
236 <li>
237 diff-minus-test ratio:{" "}
238 {risk.signals.diffMinusTestRatio.toFixed(2)}
239 </li>
240 </ul>
241 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
242 How is this calculated? The score is a transparent sum of
243 weighted signals — see <code>src/lib/pr-risk.ts</code>
244 {" "}<code>computePrRiskScore</code>.
245 </div>
246 </details>
247 {calculating && (
248 <div style="font-size:11px;color:var(--text-muted);margin-top:6px">
249 (recomputing for the latest commit — refresh to update)
250 </div>
251 )}
252 </div>
253 );
254}
255
256function riskBandPalette(band: PrRiskScore["band"]): {
257 border: string;
258 icon: string;
259} {
260 switch (band) {
261 case "low":
262 return { border: "var(--green)", icon: "" };
263 case "medium":
264 return { border: "var(--yellow, #d29922)", icon: "ℹ" };
265 case "high":
266 return { border: "var(--orange, #db6d28)", icon: "⚠" };
267 case "critical":
268 return { border: "var(--red)", icon: "\u{1F6D1}" };
269 }
270}
271
272function riskBandLabel(band: PrRiskScore["band"]): string {
273 switch (band) {
274 case "low":
275 return "LOW";
276 case "medium":
277 return "MEDIUM";
278 case "high":
279 return "HIGH";
280 case "critical":
281 return "CRITICAL";
282 }
283}
284
151285// List PRs
152286pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c) => {
153287 const { owner: ownerName, repo: repoName } = c.req.param();
@@ -538,6 +672,19 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
538672 }
539673 }
540674
675 // Block M3 — pre-merge risk score. Cache-only on the request path so
676 // the page never waits on Haiku. On a cache miss for an open PR we
677 // kick off the computation fire-and-forget; the next refresh shows it.
678 let prRisk: PrRiskScore | null = null;
679 let prRiskCalculating = false;
680 if (pr.state === "open") {
681 prRisk = await getCachedPrRisk(pr.id).catch(() => null);
682 if (!prRisk) {
683 prRiskCalculating = true;
684 void computePrRiskForPullRequest(pr.id).catch(() => {});
685 }
686 }
687
541688 // Get diff for "Files changed" tab
542689 let diffRaw = "";
543690 let diffFiles: GitDiffFile[] = [];
@@ -690,6 +837,10 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
690837 </div>
691838 )}
692839
840 {pr.state === "open" && (prRisk || prRiskCalculating) && (
841 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
842 )}
843
693844 {pr.state === "open" && gateChecks.length > 0 && (
694845 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
695846 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
Modifiedsrc/routes/pwa.ts+219−1View fileUnifiedSplit
@@ -15,8 +15,16 @@
1515 */
1616
1717import { Hono } from "hono";
18import type { AuthEnv } from "../middleware/auth";
19import { requireAuth } from "../middleware/auth";
20import {
21 getVapidPublicKey,
22 sendPushToUser,
23 subscribeUser,
24 unsubscribeUser,
25} from "../lib/push";
1826
19const pwa = new Hono();
27const pwa = new Hono<AuthEnv>();
2028
2129export const MANIFEST = {
2230 name: "Gluecron",
@@ -111,4 +119,214 @@ if ('serviceWorker' in navigator) {
111119}
112120`.trim();
113121
122// ---------------------------------------------------------------------------
123// Block M2 — additive routes + a SECOND service worker dedicated to push +
124// offline support. The original `/sw.js` keeps its v4 self-nuke behaviour
125// (locked) so the install/activate/unregister contract is preserved. The new
126// SW lives at `/sw-push.js` and is registered separately by the install
127// banner / settings page when the user opts into push.
128// ---------------------------------------------------------------------------
129
130/**
131 * Push + offline service worker. Strictly additive to the v4 self-nuke SW.
132 * Handles three things:
133 * 1. `push` event → display a notification (title/body/url/tag).
134 * 2. `notificationclick` → focus an existing tab on `url` or open a new one.
135 * 3. `fetch` event → serve `/offline.html` as the fallback when the
136 * network fails on an HTML navigation. Non-HTML fetches passthrough.
137 *
138 * The cache name is unique so we don't collide with anything `/sw.js`
139 * historically touched.
140 */
141export const PUSH_SERVICE_WORKER_SRC = `// gluecron push + offline service worker (Block M2)
142const CACHE = 'gluecron-offline-v1';
143const OFFLINE_URL = '/offline.html';
144
145self.addEventListener('install', (event) => {
146 event.waitUntil((async () => {
147 const cache = await caches.open(CACHE);
148 try { await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); } catch (_) {}
149 self.skipWaiting();
150 })());
151});
152
153self.addEventListener('activate', (event) => {
154 event.waitUntil((async () => {
155 // Drop any cache that isn't our current one.
156 const keys = await caches.keys();
157 await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
158 await self.clients.claim();
159 })());
160});
161
162self.addEventListener('push', (event) => {
163 let data = { title: 'Gluecron', body: '', url: '/', tag: 'gluecron', icon: '/icon.svg' };
164 if (event.data) {
165 try { data = Object.assign(data, event.data.json()); }
166 catch (_) { try { data.body = event.data.text(); } catch (_) {} }
167 }
168 event.waitUntil(self.registration.showNotification(data.title, {
169 body: data.body,
170 icon: data.icon,
171 tag: data.tag,
172 data: { url: data.url },
173 }));
174});
175
176self.addEventListener('notificationclick', (event) => {
177 event.notification.close();
178 const target = (event.notification.data && event.notification.data.url) || '/';
179 event.waitUntil((async () => {
180 const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
181 for (const c of all) {
182 try {
183 const u = new URL(c.url);
184 if (u.pathname === target || c.url === target) {
185 await c.focus();
186 return;
187 }
188 } catch (_) {}
189 }
190 await self.clients.openWindow(target);
191 })());
192});
193
194self.addEventListener('fetch', (event) => {
195 const req = event.request;
196 if (req.method !== 'GET') return;
197 const accept = req.headers.get('accept') || '';
198 // Only intervene on top-level HTML navigations. Everything else (CSS,
199 // images, API, /api/*, /.git/*, login/logout) passes straight through.
200 if (req.mode !== 'navigate' && !accept.includes('text/html')) return;
201 event.respondWith((async () => {
202 try {
203 return await fetch(req);
204 } catch (_) {
205 const cache = await caches.open(CACHE);
206 const cached = await cache.match(OFFLINE_URL);
207 if (cached) return cached;
208 return new Response('Offline', { status: 503, headers: { 'content-type': 'text/plain' } });
209 }
210 })());
211});
212`;
213
214pwa.get("/sw-push.js", (c) => {
215 c.header("content-type", "application/javascript");
216 // Same caching policy as /sw.js so updates propagate immediately.
217 c.header("cache-control", "no-cache, no-store, must-revalidate");
218 c.header("pragma", "no-cache");
219 c.header("service-worker-allowed", "/");
220 return c.body(PUSH_SERVICE_WORKER_SRC);
221});
222
223/** Offline fallback — minimal, theme-consistent. */
224export const OFFLINE_HTML = `<!doctype html>
225<html lang="en" data-theme="dark">
226<head>
227<meta charset="utf-8" />
228<meta name="viewport" content="width=device-width, initial-scale=1.0" />
229<title>Offline — gluecron</title>
230<style>
231 :root { --bg:#0d1117; --fg:#c9d1d9; --muted:#8b949e; --accent:#58a6ff; --border:#30363d; }
232 html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
233 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
234 main { max-width: 480px; margin: 12vh auto 0; padding: 24px; text-align:center; }
235 h1 { font-size: 22px; margin: 0 0 12px; }
236 p { color: var(--muted); line-height: 1.5; }
237 a.btn {
238 display:inline-block; margin-top:18px; padding:10px 18px;
239 background:transparent; border:1px solid var(--border); border-radius:6px;
240 color:var(--accent); text-decoration:none;
241 }
242 .pulse {
243 width:10px; height:10px; border-radius:50%;
244 background:#f85149; display:inline-block; margin-right:8px;
245 box-shadow:0 0 12px rgba(248,81,73,0.6);
246 }
247</style>
248</head>
249<body>
250<main>
251 <h1><span class="pulse"></span>You're offline</h1>
252 <p>We couldn't reach gluecron. Your last-known dashboard is still in cache — reconnect to refresh it.</p>
253 <a class="btn" href="/dashboard">Retry</a>
254</main>
255</body>
256</html>
257`;
258
259pwa.get("/offline.html", (c) => {
260 c.header("content-type", "text/html; charset=utf-8");
261 c.header("cache-control", "public, max-age=300");
262 return c.body(OFFLINE_HTML);
263});
264
265// --- API: VAPID public key --------------------------------------------------
266pwa.get("/pwa/vapid-public-key", async (c) => {
267 try {
268 const key = await getVapidPublicKey();
269 return c.json({ key });
270 } catch (err) {
271 console.error("[pwa] vapid public key failed:", err);
272 return c.json({ error: "vapid_unavailable" }, 500);
273 }
274});
275
276// --- API: subscribe ---------------------------------------------------------
277pwa.post("/pwa/subscribe", requireAuth, async (c) => {
278 const user = c.get("user")!;
279 let body: any;
280 try {
281 body = await c.req.json();
282 } catch {
283 return c.json({ error: "invalid_json" }, 400);
284 }
285 const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
286 const p256dh =
287 typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : "";
288 const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : "";
289 if (!endpoint || !p256dh || !auth) {
290 return c.json({ error: "invalid_subscription" }, 400);
291 }
292 const ua = c.req.header("user-agent") ?? null;
293 try {
294 await subscribeUser(
295 user.id,
296 { endpoint, keys: { p256dh, auth } },
297 ua ?? undefined
298 );
299 } catch (_) {
300 return c.json({ error: "subscribe_failed" }, 500);
301 }
302 return c.json({ ok: true }, 201);
303});
304
305// --- API: unsubscribe -------------------------------------------------------
306pwa.post("/pwa/unsubscribe", requireAuth, async (c) => {
307 const user = c.get("user")!;
308 let body: any;
309 try {
310 body = await c.req.json();
311 } catch {
312 return c.json({ error: "invalid_json" }, 400);
313 }
314 const endpoint = typeof body?.endpoint === "string" ? body.endpoint : "";
315 if (!endpoint) return c.json({ error: "missing_endpoint" }, 400);
316 await unsubscribeUser(user.id, endpoint);
317 return c.body(null, 204);
318});
319
320// --- API: send a test push to the calling user ------------------------------
321pwa.post("/pwa/test", requireAuth, async (c) => {
322 const user = c.get("user")!;
323 const result = await sendPushToUser(user.id, {
324 title: "Gluecron test notification",
325 body: "If you can read this, push delivery is working on this device.",
326 url: "/notifications",
327 tag: "gluecron-test",
328 });
329 return c.json(result);
330});
331
114332export default pwa;
Modifiedsrc/routes/repo-settings.tsx+117−0View fileUnifiedSplit
@@ -12,6 +12,7 @@ import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
1313import { requireRepoAccess } from "../middleware/repo-access";
1414import { listBranches } from "../git/repository";
15import { audit } from "../lib/notify";
1516import { rm } from "fs/promises";
1617import {
1718 Container,
@@ -234,6 +235,51 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
234235 </form>
235236 </div>
236237
238 <div
239 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
240 >
241 <h3 style="margin-bottom: 8px">Stale activity</h3>
242 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
243 Autopilot pokes PRs and issues that have gone quiet, then offers
244 a one-click close path. Each setting controls the final close
245 step — pokes always happen, but they're harmless reminders.
246 </p>
247 <form
248 method="post"
249 action={`/${ownerName}/${repoName}/settings/stale`}
250 >
251 <label
252 style="display:block;margin-bottom:8px;font-size:14px"
253 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
254 >
255 <input
256 type="checkbox"
257 name="auto_close_stale_prs"
258 value="1"
259 checked={repo.autoCloseStalePrs}
260 style="margin-right:8px"
261 />
262 Auto-close stale PRs after 14 days of no activity post-poke
263 </label>
264 <label
265 style="display:block;margin-bottom:12px;font-size:14px"
266 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
267 >
268 <input
269 type="checkbox"
270 name="auto_close_stale_issues"
271 value="1"
272 checked={repo.autoCloseStaleIssues}
273 style="margin-right:8px"
274 />
275 Auto-close stale issues after 60 days of no activity post-poke
276 </label>
277 <button type="submit" class="btn">
278 Save stale settings
279 </button>
280 </form>
281 </div>
282
237283 <div
238284 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
239285 >
@@ -440,6 +486,77 @@ repoSettings.post(
440486 }
441487);
442488
489// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
490repoSettings.post(
491 "/:owner/:repo/settings/stale",
492 requireAuth,
493 requireRepoAccess("admin"),
494 async (c) => {
495 const { owner: ownerName, repo: repoName } = c.req.param();
496 const user = c.get("user")!;
497 const body = await c.req.parseBody();
498 const [owner] = await db
499 .select()
500 .from(users)
501 .where(eq(users.username, ownerName))
502 .limit(1);
503 if (!owner || owner.id !== user.id) {
504 return c.redirect(`/${ownerName}/${repoName}`);
505 }
506 const [repo] = await db
507 .select()
508 .from(repositories)
509 .where(
510 and(
511 eq(repositories.ownerId, owner.id),
512 eq(repositories.name, repoName)
513 )
514 )
515 .limit(1);
516 if (!repo) return c.notFound();
517
518 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
519 const newPrs = body.auto_close_stale_prs === "1";
520 const newIssues = body.auto_close_stale_issues === "1";
521
522 await db
523 .update(repositories)
524 .set({
525 autoCloseStalePrs: newPrs,
526 autoCloseStaleIssues: newIssues,
527 updatedAt: new Date(),
528 })
529 .where(eq(repositories.id, repo.id));
530
531 // Audit toggle deltas so the repo's audit log shows the change. Two
532 // separate rows (one per flag) so the action names stay stable + grep-able.
533 if (newPrs !== repo.autoCloseStalePrs) {
534 await audit({
535 userId: user.id,
536 repositoryId: repo.id,
537 action: "repo.auto_close_stale_prs.toggled",
538 targetType: "repository",
539 targetId: repo.id,
540 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
541 });
542 }
543 if (newIssues !== repo.autoCloseStaleIssues) {
544 await audit({
545 userId: user.id,
546 repositoryId: repo.id,
547 action: "repo.auto_close_stale_issues.toggled",
548 targetType: "repository",
549 targetId: repo.id,
550 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
551 });
552 }
553
554 return c.redirect(
555 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
556 );
557 }
558);
559
443560// Delete repository
444561repoSettings.post(
445562 "/:owner/:repo/settings/delete",
Modifiedsrc/routes/settings.tsx+191−1View fileUnifiedSplit
@@ -184,10 +184,95 @@ settings.get("/settings", (c) => {
184184 />
185185 <a href="/settings/sleep-mode/preview">Preview digest now</a>
186186 </label>
187 <h3 style="margin-top: 32px; font-size: 16px">Mobile push notifications</h3>
188 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px">
189 Install Gluecron as a PWA (look for the install banner at the
190 bottom of the page after a few visits) to get push notifications
191 when something needs your attention. Per-event filters below
192 control which notification kinds trigger a push.
193 </p>
194 <label
195 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
196 >
197 <input
198 type="checkbox"
199 name="notify_push_on_mention"
200 value="1"
201 checked={user.notifyPushOnMention}
202 aria-label="Someone @mentions me"
203 />
204 <span>
205 Someone <code>@mentions</code> me
206 </span>
207 </label>
208 <label
209 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
210 >
211 <input
212 type="checkbox"
213 name="notify_push_on_assign"
214 value="1"
215 checked={user.notifyPushOnAssign}
216 aria-label="I am assigned to an issue or PR"
217 />
218 <span>I am assigned to an issue or PR</span>
219 </label>
220 <label
221 style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 14px"
222 >
223 <input
224 type="checkbox"
225 name="notify_push_on_review_request"
226 value="1"
227 checked={user.notifyPushOnReviewRequest}
228 aria-label="Someone requests a review from me"
229 />
230 <span>Someone requests a review from me</span>
231 </label>
232 <label
233 style="display: flex; gap: 8px; align-items: center; margin-bottom: 12px; font-size: 14px"
234 >
235 <input
236 type="checkbox"
237 name="notify_push_on_deploy_failed"
238 value="1"
239 checked={user.notifyPushOnDeployFailed}
240 aria-label="A deploy fails"
241 />
242 <span>A deploy fails on one of my repositories</span>
243 </label>
187244 <button type="submit" class="btn btn-primary">
188245 Save preferences
189246 </button>
190247 </form>
248 <div
249 id="gc-push-device"
250 style="margin-top:16px;padding:12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-elevated,transparent)"
251 >
252 <div
253 id="gc-push-status"
254 style="font-size:13px;color:var(--text-muted)"
255 >
256 Push status: checking…
257 </div>
258 <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
259 <button type="button" id="gc-push-subscribe" class="btn btn-sm btn-primary">
260 Subscribe on this device
261 </button>
262 <button type="button" id="gc-push-unsubscribe" class="btn btn-sm">
263 Unsubscribe
264 </button>
265 <button type="button" id="gc-push-test" class="btn btn-sm">
266 Send test notification
267 </button>
268 </div>
269 <div
270 id="gc-push-msg"
271 role="status"
272 style="margin-top:8px;font-size:12px;color:var(--text-muted);min-height:1em"
273 />
274 </div>
275 <script dangerouslySetInnerHTML={{ __html: pushDeviceScript }} />
191276 </div>
192277 </Layout>
193278 );
@@ -288,12 +373,117 @@ settings.post("/settings/notifications", async (c) => {
288373 String(body.notify_email_digest_weekly || "") === "1",
289374 sleepModeEnabled: String(body.sleep_mode_enabled || "") === "1",
290375 sleepModeDigestHourUtc: hour,
376 // Block M2 — per-event push preferences.
377 notifyPushOnMention:
378 String(body.notify_push_on_mention || "") === "1",
379 notifyPushOnAssign:
380 String(body.notify_push_on_assign || "") === "1",
381 notifyPushOnReviewRequest:
382 String(body.notify_push_on_review_request || "") === "1",
383 notifyPushOnDeployFailed:
384 String(body.notify_push_on_deploy_failed || "") === "1",
291385 updatedAt: new Date(),
292386 })
293387 .where(eq(users.id, user.id));
294 return c.redirect("/settings?success=Email+preferences+updated");
388 return c.redirect("/settings?success=Notification+preferences+updated");
295389});
296390
391// Block M2 — client-side device subscribe/unsubscribe/test helpers. Plain
392// JS, no framework; reads/writes via the /pwa/* endpoints.
393const pushDeviceScript = `
394(function(){
395 var statusEl = document.getElementById('gc-push-status');
396 var msgEl = document.getElementById('gc-push-msg');
397 var subBtn = document.getElementById('gc-push-subscribe');
398 var unsubBtn = document.getElementById('gc-push-unsubscribe');
399 var testBtn = document.getElementById('gc-push-test');
400 function setStatus(s){ if (statusEl) statusEl.textContent = 'Push status: ' + s; }
401 function setMsg(s){ if (msgEl) msgEl.textContent = s; }
402 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
403 setStatus("Browser doesn't support push");
404 if (subBtn) subBtn.disabled = true;
405 if (unsubBtn) unsubBtn.disabled = true;
406 if (testBtn) testBtn.disabled = true;
407 return;
408 }
409 function b64uToU8(s){
410 var pad = '='.repeat((4 - s.length % 4) % 4);
411 var b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
412 var bin = atob(b64);
413 var out = new Uint8Array(bin.length);
414 for (var i=0; i<bin.length; i++) out[i] = bin.charCodeAt(i);
415 return out;
416 }
417 function getReg(){
418 return navigator.serviceWorker.getRegistration('/').then(function(r){
419 return r || navigator.serviceWorker.register('/sw-push.js', { scope: '/' });
420 });
421 }
422 function refresh(){
423 getReg().then(function(reg){
424 return reg.pushManager.getSubscription();
425 }).then(function(sub){
426 if (sub) {
427 setStatus('Enabled on this device');
428 if (subBtn) subBtn.disabled = true;
429 if (unsubBtn) unsubBtn.disabled = false;
430 if (testBtn) testBtn.disabled = false;
431 } else {
432 setStatus('Not subscribed on this device');
433 if (subBtn) subBtn.disabled = false;
434 if (unsubBtn) unsubBtn.disabled = true;
435 if (testBtn) testBtn.disabled = true;
436 }
437 }).catch(function(){ setStatus('unavailable'); });
438 }
439 if (subBtn) subBtn.addEventListener('click', function(){
440 setMsg('Requesting permission…');
441 Notification.requestPermission().then(function(perm){
442 if (perm !== 'granted') { setMsg('Permission denied.'); return; }
443 return fetch('/pwa/vapid-public-key').then(function(r){ return r.json(); }).then(function(j){
444 if (!j || !j.key) throw new Error('no vapid key');
445 return getReg().then(function(reg){
446 return reg.pushManager.subscribe({
447 userVisibleOnly: true,
448 applicationServerKey: b64uToU8(j.key),
449 });
450 });
451 }).then(function(sub){
452 var json = sub.toJSON();
453 return fetch('/pwa/subscribe', {
454 method: 'POST',
455 headers: { 'content-type': 'application/json' },
456 body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
457 });
458 }).then(function(){ setMsg('Subscribed.'); refresh(); });
459 }).catch(function(err){ setMsg('Failed: ' + (err && err.message || err)); });
460 });
461 if (unsubBtn) unsubBtn.addEventListener('click', function(){
462 getReg().then(function(reg){
463 return reg.pushManager.getSubscription();
464 }).then(function(sub){
465 if (!sub) return;
466 var endpoint = sub.endpoint;
467 return sub.unsubscribe().then(function(){
468 return fetch('/pwa/unsubscribe', {
469 method: 'POST',
470 headers: { 'content-type': 'application/json' },
471 body: JSON.stringify({ endpoint: endpoint }),
472 });
473 });
474 }).then(function(){ setMsg('Unsubscribed.'); refresh(); })
475 .catch(function(err){ setMsg('Failed: ' + (err && err.message || err)); });
476 });
477 if (testBtn) testBtn.addEventListener('click', function(){
478 fetch('/pwa/test', { method: 'POST' }).then(function(r){ return r.json(); })
479 .then(function(j){
480 setMsg('Sent ' + (j.sent || 0) + ', failed ' + (j.failed || 0) + '.');
481 }).catch(function(err){ setMsg('Failed: ' + (err && err.message || err)); });
482 });
483 refresh();
484})();
485`;
486
297487settings.post("/settings/profile", async (c) => {
298488 const user = c.get("user")!;
299489 const body = await c.req.parseBody();
Modifiedsrc/routes/web.tsx+52−2View fileUnifiedSplit
@@ -48,8 +48,15 @@ import { highlightCode } from "../lib/highlight";
4848import { softAuth, requireAuth } from "../middleware/auth";
4949import type { AuthEnv } from "../middleware/auth";
5050import { trackByName } from "../lib/traffic";
51import { LandingPage } from "../views/landing";
51import { LandingPage, type LandingLiveFeed } from "../views/landing";
5252import { computePublicStats, type PublicStats } from "../lib/public-stats";
53import {
54 listQueuedAiBuildIssues,
55 listRecentAutoMerges,
56 listRecentAiReviews,
57 countAiReviewsSince,
58 listDemoActivityFeed,
59} from "../lib/demo-activity";
5360
5461const web = new Hono<AuthEnv>();
5562
@@ -89,6 +96,49 @@ web.get("/", async (c) => {
8996 publicStats = null;
9097 }
9198
99 // Block M1 — initial SSR snapshot for the live-now demo feed.
100 // The helpers in lib/demo-activity.ts never throw, but we still wrap
101 // in try/catch so a freak module-level explosion can't take down /.
102 let liveFeed: LandingLiveFeed | null = null;
103 try {
104 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
105 listQueuedAiBuildIssues(3),
106 listRecentAutoMerges(3, 24),
107 listRecentAiReviews(3, 24),
108 countAiReviewsSince(24),
109 listDemoActivityFeed(10),
110 ]);
111 liveFeed = {
112 queued: queued.map((i) => ({
113 repo: i.repo,
114 number: i.number,
115 title: i.title,
116 createdAt: i.createdAt,
117 })),
118 merges: merges.map((m) => ({
119 repo: m.repo,
120 number: m.number,
121 title: m.title,
122 mergedAt: m.mergedAt,
123 })),
124 reviews: reviewList.map((r) => ({
125 repo: r.repo,
126 prNumber: r.prNumber,
127 commentSnippet: r.commentSnippet,
128 createdAt: r.createdAt,
129 })),
130 reviewCount,
131 feed: feed.map((e) => ({
132 kind: e.kind,
133 repo: e.repo,
134 ref: e.ref,
135 at: e.at,
136 })),
137 };
138 } catch {
139 liveFeed = null;
140 }
141
92142 return c.html(
93143 <Layout
94144 user={null}
@@ -100,7 +150,7 @@ web.get("/", async (c) => {
100150 ogType="website"
101151 twitterCard="summary_large_image"
102152 >
103 <LandingPage stats={stats} publicStats={publicStats} />
153 <LandingPage stats={stats} publicStats={publicStats} liveFeed={liveFeed} />
104154 </Layout>
105155 );
106156});
Modifiedsrc/views/landing.tsx+785−1View fileUnifiedSplit
@@ -22,6 +22,50 @@
2222
2323import type { FC } from "hono/jsx";
2424import type { PublicStats } from "../lib/public-stats";
25import { DEMO_USERNAME } from "../lib/demo-seed";
26
27export interface LandingLiveFeedQueued {
28 repo: string;
29 number: number;
30 title: string;
31 createdAt: string | Date;
32}
33
34export interface LandingLiveFeedMerge {
35 repo: string;
36 number: number;
37 title: string;
38 mergedAt: string | Date;
39}
40
41export interface LandingLiveFeedReview {
42 repo: string;
43 prNumber: number;
44 commentSnippet: string;
45 createdAt: string | Date;
46}
47
48export interface LandingLiveFeedEntry {
49 kind: "auto_merge.merged" | "ai_build.dispatched" | "ai_review.posted";
50 repo: string;
51 ref: { type: "issue" | "pr"; number: number };
52 at: string | Date;
53}
54
55/**
56 * Block M1 — server-rendered snapshot of the live-now feed. The same
57 * fields are also fetched client-side every 30s from
58 * `/api/v2/demo/{queued,merges,reviews,activity}`. Optional so existing
59 * call-sites (and tests that don't care about the live block) keep
60 * compiling.
61 */
62export interface LandingLiveFeed {
63 queued: LandingLiveFeedQueued[];
64 merges: LandingLiveFeedMerge[];
65 reviews: LandingLiveFeedReview[];
66 reviewCount: number;
67 feed: LandingLiveFeedEntry[];
68}
2569
2670export interface LandingPageProps {
2771 stats?: {
@@ -34,9 +78,20 @@ export interface LandingPageProps {
3478 * six-tile social-proof row beneath the eyebrow.
3579 */
3680 publicStats?: PublicStats | null;
81 /**
82 * Block M1 — initial SSR snapshot for the live-now feed block.
83 * The same endpoints poll client-side every 30s. When undefined the
84 * section still renders, but with empty-state copy until the first
85 * client poll lands.
86 */
87 liveFeed?: LandingLiveFeed | null;
3788}
3889
39export const LandingHero: FC<LandingPageProps> = ({ stats, publicStats } = {}) => {
90export const LandingHero: FC<LandingPageProps> = ({
91 stats,
92 publicStats,
93 liveFeed,
94} = {}) => {
4095 const hasStats =
4196 stats &&
4297 ((stats.publicRepos !== undefined && stats.publicRepos > 0) ||
@@ -49,6 +104,15 @@ export const LandingHero: FC<LandingPageProps> = ({ stats, publicStats } = {}) =
49104 ? buildSocialProofTiles(publicStats)
50105 : null;
51106
107 // Block M1 — SSR-friendly fallbacks so the no-JS path still renders
108 // a populated block. The client-side poller will overwrite these
109 // every 30s anyway.
110 const liveQueued = liveFeed?.queued ?? [];
111 const liveMerges = liveFeed?.merges ?? [];
112 const liveReviews = liveFeed?.reviews ?? [];
113 const liveReviewCount = liveFeed?.reviewCount ?? 0;
114 const liveEntries = liveFeed?.feed ?? [];
115
52116 return (
53117 <>
54118 <style dangerouslySetInnerHTML={{ __html: landingCss }} />
@@ -182,6 +246,15 @@ export const LandingHero: FC<LandingPageProps> = ({ stats, publicStats } = {}) =
182246 </div>
183247 </section>
184248
249 {/* ---------- Block M1 — Live-now demo feed ---------- */}
250 <LiveNowSection
251 queued={liveQueued}
252 merges={liveMerges}
253 reviews={liveReviews}
254 reviewCount={liveReviewCount}
255 feed={liveEntries}
256 />
257
185258 {/* ---------- L4 social-proof counters (animated count-up) ---------- */}
186259 {tiles && (
187260 <section class="landing-counters" aria-label="Gluecron live counters">
@@ -682,6 +755,451 @@ export function buildSocialProofTiles(s: PublicStats): SocialProofTile[] {
682755 ];
683756}
684757
758// ─────────────────────────────────────────────────────────────────
759// Block M1 — Live-now demo feed.
760//
761// A 4-tile row inserted between the hero and the L4 counters tile
762// section, surfacing real autopilot activity from the seeded `demo`
763// owner's repos:
764// 1. Issues queued for AI build (ai:build label, open)
765// 2. PRs auto-merged in the last 24h
766// 3. AI reviews posted today (count + latest 3)
767// 4. Combined activity feed (last 10)
768//
769// SSR-renders an initial snapshot, then re-fetches every 30s via the
770// L3 JSON endpoints so the page feels alive without a websocket.
771// Pure presentational; the route layer owns the DB reads.
772// ─────────────────────────────────────────────────────────────────
773
774/**
775 * Render an ISO timestamp (or Date) as a coarse "about N units ago"
776 * string. Tolerates strings, Dates, NaN, and future timestamps.
777 *
778 * Exported for unit testing.
779 */
780export function relativeTimeFromNow(
781 value: string | Date | number | null | undefined,
782 now: number = Date.now()
783): string {
784 if (value === null || value === undefined) return "just now";
785 let t: number;
786 if (value instanceof Date) {
787 t = value.getTime();
788 } else if (typeof value === "number") {
789 t = value;
790 } else {
791 t = new Date(value).getTime();
792 }
793 if (!Number.isFinite(t)) return "just now";
794 const delta = now - t;
795 // Future timestamps (clock skew) — treat as "just now" rather than
796 // surfacing a confusing negative.
797 if (delta < 0) return "just now";
798 const s = Math.floor(delta / 1000);
799 if (s < 60) return "just now";
800 const m = Math.floor(s / 60);
801 if (m < 60) return `about ${m} minute${m === 1 ? "" : "s"} ago`;
802 const h = Math.floor(m / 60);
803 if (h < 24) return `about ${h} hour${h === 1 ? "" : "s"} ago`;
804 const d = Math.floor(h / 24);
805 return `about ${d} day${d === 1 ? "" : "s"} ago`;
806}
807
808function feedEntryId(e: LandingLiveFeedEntry): string {
809 const at = e.at instanceof Date ? e.at.toISOString() : String(e.at);
810 return `${e.kind}|${e.repo}|${e.ref.type}|${e.ref.number}|${at}`;
811}
812
813function feedEntryLabel(kind: LandingLiveFeedEntry["kind"]): string {
814 switch (kind) {
815 case "auto_merge.merged":
816 return "auto-merged";
817 case "ai_build.dispatched":
818 return "AI-build queued";
819 case "ai_review.posted":
820 return "AI review posted";
821 }
822}
823
824interface LiveNowSectionProps {
825 queued: LandingLiveFeedQueued[];
826 merges: LandingLiveFeedMerge[];
827 reviews: LandingLiveFeedReview[];
828 reviewCount: number;
829 feed: LandingLiveFeedEntry[];
830}
831
832const LiveNowSection: FC<LiveNowSectionProps> = ({
833 queued,
834 merges,
835 reviews,
836 reviewCount,
837 feed,
838}) => {
839 return (
840 <section class="landing-livenow" aria-labelledby="landing-livenow-h">
841 <div class="landing-livenow-head">
842 <div class="landing-livenow-eyebrow">
843 <span class="landing-livenow-pulse" aria-hidden="true" />
844 Live now
845 </div>
846 <h2 id="landing-livenow-h" class="landing-livenow-title">
847 Claude is working on demo repos as you read this.
848 </h2>
849 <p class="landing-livenow-sub">
850 Every card below is real data from the public{" "}
851 <code>{DEMO_USERNAME}/*</code> repos. Refreshes every 30 seconds.
852 </p>
853 </div>
854
855 <div class="landing-livenow-grid" data-livenow-grid>
856 {/* Card 1 — queued issues */}
857 <article class="landing-livecard" aria-labelledby="lc-queued-h">
858 <header class="landing-livecard-head">
859 <span class="landing-livecard-dot" aria-hidden="true" />
860 <h3 id="lc-queued-h" class="landing-livecard-title">
861 Issues queued for AI
862 </h3>
863 </header>
864 <ul class="landing-livecard-list" data-livecard="queued">
865 {queued.length === 0 ? (
866 <li class="landing-livecard-empty">
867 No queued AI builds — quiet right now.
868 </li>
869 ) : (
870 queued.slice(0, 3).map((i) => (
871 <li
872 class="landing-livecard-row"
873 data-row-id={`queued|${i.repo}|${i.number}`}
874 >
875 <a
876 class="landing-livecard-link"
877 href={`/${DEMO_USERNAME}/${i.repo}/issues/${i.number}`}
878 >
879 <span class="landing-livecard-num">#{i.number}</span>{" "}
880 <span class="landing-livecard-title-text">{i.title}</span>
881 </a>
882 <div class="landing-livecard-meta">
883 <span class="landing-livecard-repo">{i.repo}</span>
884 </div>
885 </li>
886 ))
887 )}
888 </ul>
889 </article>
890
891 {/* Card 2 — recently merged */}
892 <article class="landing-livecard" aria-labelledby="lc-merges-h">
893 <header class="landing-livecard-head">
894 <span class="landing-livecard-dot" aria-hidden="true" />
895 <h3 id="lc-merges-h" class="landing-livecard-title">
896 Recently merged by AI
897 </h3>
898 </header>
899 <ul class="landing-livecard-list" data-livecard="merges">
900 {merges.length === 0 ? (
901 <li class="landing-livecard-empty">
902 No auto-merges in the last 24h.
903 </li>
904 ) : (
905 merges.slice(0, 3).map((m) => (
906 <li
907 class="landing-livecard-row"
908 data-row-id={`merges|${m.repo}|${m.number}`}
909 >
910 <a
911 class="landing-livecard-link"
912 href={`/${DEMO_USERNAME}/${m.repo}/pulls/${m.number}`}
913 >
914 <span class="landing-livecard-num">#{m.number}</span>{" "}
915 <span class="landing-livecard-title-text">{m.title}</span>
916 </a>
917 <div class="landing-livecard-meta">
918 AI merged in{" "}
919 <span class="landing-livecard-repo">{m.repo}</span>{" "}
920 <span
921 class="landing-livecard-rel"
922 data-rel={
923 m.mergedAt instanceof Date
924 ? m.mergedAt.toISOString()
925 : String(m.mergedAt)
926 }
927 >
928 {relativeTimeFromNow(m.mergedAt)}
929 </span>
930 </div>
931 </li>
932 ))
933 )}
934 </ul>
935 </article>
936
937 {/* Card 3 — AI reviews */}
938 <article class="landing-livecard" aria-labelledby="lc-reviews-h">
939 <header class="landing-livecard-head">
940 <span class="landing-livecard-dot" aria-hidden="true" />
941 <h3 id="lc-reviews-h" class="landing-livecard-title">
942 AI reviews posted
943 </h3>
944 </header>
945 <div class="landing-livecard-bignum">
946 <span
947 class="landing-livecard-bignum-n"
948 data-livecard-count="reviews"
949 data-tick-target={String(reviewCount)}
950 >
951 {reviewCount.toLocaleString()}
952 </span>
953 <span class="landing-livecard-bignum-label">reviews today</span>
954 </div>
955 <ul class="landing-livecard-list" data-livecard="reviews">
956 {reviews.length === 0 ? (
957 <li class="landing-livecard-empty">
958 No AI reviews in the last 24h.
959 </li>
960 ) : (
961 reviews.slice(0, 3).map((r) => (
962 <li
963 class="landing-livecard-row"
964 data-row-id={`reviews|${r.repo}|${r.prNumber}`}
965 >
966 <a
967 class="landing-livecard-link"
968 href={`/${DEMO_USERNAME}/${r.repo}/pulls/${r.prNumber}`}
969 >
970 <span class="landing-livecard-num">#{r.prNumber}</span>{" "}
971 <span class="landing-livecard-snippet">
972 {r.commentSnippet}
973 </span>
974 </a>
975 <div class="landing-livecard-meta">
976 <span class="landing-livecard-repo">{r.repo}</span>
977 </div>
978 </li>
979 ))
980 )}
981 </ul>
982 </article>
983
984 {/* Card 4 — activity feed */}
985 <article class="landing-livecard" aria-labelledby="lc-feed-h">
986 <header class="landing-livecard-head">
987 <span class="landing-livecard-dot" aria-hidden="true" />
988 <h3 id="lc-feed-h" class="landing-livecard-title">
989 Activity feed
990 </h3>
991 </header>
992 <ul class="landing-livecard-list landing-livecard-feed" data-livecard="feed">
993 {feed.length === 0 ? (
994 <li class="landing-livecard-empty">
995 Quiet right now — check back in a minute.
996 </li>
997 ) : (
998 feed.slice(0, 10).map((e) => {
999 const path = e.ref.type === "pr" ? "pulls" : "issues";
1000 const id = feedEntryId(e);
1001 return (
1002 <li class="landing-livecard-feedrow" data-row-id={id}>
1003 <span
1004 class={`landing-livecard-kind landing-livecard-kind-${e.kind.replace(/\./g, "-")}`}
1005 >
1006 {feedEntryLabel(e.kind)}
1007 </span>{" "}
1008 <a
1009 class="landing-livecard-link"
1010 href={`/${DEMO_USERNAME}/${e.repo}/${path}/${e.ref.number}`}
1011 >
1012 {e.repo} #{e.ref.number}
1013 </a>{" "}
1014 <span
1015 class="landing-livecard-rel"
1016 data-rel={
1017 e.at instanceof Date ? e.at.toISOString() : String(e.at)
1018 }
1019 >
1020 {relativeTimeFromNow(e.at)}
1021 </span>
1022 </li>
1023 );
1024 })
1025 )}
1026 </ul>
1027 </article>
1028 </div>
1029
1030 <div class="landing-livenow-cta">
1031 <span class="landing-livenow-cta-text">
1032 Want this for your repos?
1033 </span>
1034 <a class="landing-livenow-cta-link" href="/register">
1035 Sign up free
1036 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
1037 </a>
1038 <span class="landing-livenow-cta-sep" aria-hidden="true">·</span>
1039 <a class="landing-livenow-cta-link" href="/demo">
1040 Try the live demo
1041 <span class="landing-cta-arrow" aria-hidden="true">{"→"}</span>
1042 </a>
1043 </div>
1044
1045 <script dangerouslySetInnerHTML={{ __html: liveNowJs }} />
1046 </section>
1047 );
1048};
1049
1050// Inline poller. Plain JS so we don't ship a separate bundle. Hits the
1051// four L3 JSON endpoints every 30s, re-renders the four cards, ticks
1052// the big number, refreshes relative timestamps, flashes new rows.
1053const liveNowJs = `
1054(function(){
1055try{
1056 var DEMO=${JSON.stringify(DEMO_USERNAME)};
1057 var INTERVAL=30000;
1058 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
1059 function rel(v){
1060 if(v==null) return 'just now';
1061 var t=(v instanceof Date)?v.getTime():(typeof v==='number'?v:new Date(v).getTime());
1062 if(!isFinite(t)) return 'just now';
1063 var d=Date.now()-t;
1064 if(d<0) return 'just now';
1065 var s=Math.floor(d/1000);
1066 if(s<60) return 'just now';
1067 var m=Math.floor(s/60);
1068 if(m<60) return 'about '+m+' minute'+(m===1?'':'s')+' ago';
1069 var h=Math.floor(m/60);
1070 if(h<24) return 'about '+h+' hour'+(h===1?'':'s')+' ago';
1071 var dd=Math.floor(h/24);
1072 return 'about '+dd+' day'+(dd===1?'':'s')+' ago';
1073 }
1074 function tickNumber(el,target){
1075 if(!el) return;
1076 var start=parseInt(el.getAttribute('data-tick-current')||'0',10)||0;
1077 if(start===target){el.textContent=target.toLocaleString();el.setAttribute('data-tick-current',String(target));return;}
1078 var dur=800,t0=performance.now();
1079 function step(now){
1080 var p=Math.min(1,(now-t0)/dur);
1081 var eased=1-Math.pow(1-p,3);
1082 var v=Math.round(start+(target-start)*eased);
1083 el.textContent=v.toLocaleString();
1084 if(p<1) requestAnimationFrame(step); else el.setAttribute('data-tick-current',String(target));
1085 }
1086 requestAnimationFrame(step);
1087 }
1088 function flashRow(li){
1089 if(!li) return;
1090 li.classList.add('landing-livecard-flash');
1091 setTimeout(function(){li.classList.remove('landing-livecard-flash');},1100);
1092 }
1093 function diffMount(ul,newHtml,newIds){
1094 if(!ul) return;
1095 var prev={};
1096 var nodes=ul.querySelectorAll('[data-row-id]');
1097 for(var i=0;i<nodes.length;i++){prev[nodes[i].getAttribute('data-row-id')]=true;}
1098 ul.innerHTML=newHtml;
1099 var fresh=ul.querySelectorAll('[data-row-id]');
1100 for(var j=0;j<fresh.length;j++){
1101 var id=fresh[j].getAttribute('data-row-id');
1102 if(id && !prev[id]) flashRow(fresh[j]);
1103 }
1104 }
1105 function pollQueued(){
1106 return fetch('/api/v2/demo/queued',{credentials:'omit'}).then(function(r){return r.json();}).then(function(d){
1107 var ul=document.querySelector('[data-livecard="queued"]');if(!ul) return;
1108 var items=(d&&d.items)||[];
1109 if(items.length===0){ul.innerHTML='<li class="landing-livecard-empty">No queued AI builds — quiet right now.</li>';return;}
1110 var ids=[];
1111 var html=items.slice(0,3).map(function(i){
1112 var id='queued|'+i.repo+'|'+i.number;ids.push(id);
1113 return '<li class="landing-livecard-row" data-row-id="'+esc(id)+'">'+
1114 '<a class="landing-livecard-link" href="/'+esc(DEMO)+'/'+esc(i.repo)+'/issues/'+i.number+'">'+
1115 '<span class="landing-livecard-num">#'+i.number+'</span> '+
1116 '<span class="landing-livecard-title-text">'+esc(i.title)+'</span></a>'+
1117 '<div class="landing-livecard-meta"><span class="landing-livecard-repo">'+esc(i.repo)+'</span></div></li>';
1118 }).join('');
1119 diffMount(ul,html,ids);
1120 }).catch(function(){});
1121 }
1122 function pollMerges(){
1123 return fetch('/api/v2/demo/merges',{credentials:'omit'}).then(function(r){return r.json();}).then(function(d){
1124 var ul=document.querySelector('[data-livecard="merges"]');if(!ul) return;
1125 var items=(d&&d.items)||[];
1126 if(items.length===0){ul.innerHTML='<li class="landing-livecard-empty">No auto-merges in the last 24h.</li>';return;}
1127 var ids=[];
1128 var html=items.slice(0,3).map(function(m){
1129 var id='merges|'+m.repo+'|'+m.number;ids.push(id);
1130 return '<li class="landing-livecard-row" data-row-id="'+esc(id)+'">'+
1131 '<a class="landing-livecard-link" href="/'+esc(DEMO)+'/'+esc(m.repo)+'/pulls/'+m.number+'">'+
1132 '<span class="landing-livecard-num">#'+m.number+'</span> '+
1133 '<span class="landing-livecard-title-text">'+esc(m.title)+'</span></a>'+
1134 '<div class="landing-livecard-meta">AI merged in <span class="landing-livecard-repo">'+esc(m.repo)+'</span> '+
1135 '<span class="landing-livecard-rel" data-rel="'+esc(m.mergedAt)+'">'+esc(rel(m.mergedAt))+'</span></div></li>';
1136 }).join('');
1137 diffMount(ul,html,ids);
1138 }).catch(function(){});
1139 }
1140 function pollReviews(){
1141 return fetch('/api/v2/demo/reviews',{credentials:'omit'}).then(function(r){return r.json();}).then(function(d){
1142 var ul=document.querySelector('[data-livecard="reviews"]');if(!ul) return;
1143 var bigEl=document.querySelector('[data-livecard-count="reviews"]');
1144 var n=(d&&typeof d.count==='number')?d.count:0;
1145 if(bigEl) tickNumber(bigEl,n);
1146 var items=(d&&d.items)||[];
1147 if(items.length===0){ul.innerHTML='<li class="landing-livecard-empty">No AI reviews in the last 24h.</li>';return;}
1148 var ids=[];
1149 var html=items.slice(0,3).map(function(r){
1150 var id='reviews|'+r.repo+'|'+r.prNumber;ids.push(id);
1151 return '<li class="landing-livecard-row" data-row-id="'+esc(id)+'">'+
1152 '<a class="landing-livecard-link" href="/'+esc(DEMO)+'/'+esc(r.repo)+'/pulls/'+r.prNumber+'">'+
1153 '<span class="landing-livecard-num">#'+r.prNumber+'</span> '+
1154 '<span class="landing-livecard-snippet">'+esc(r.commentSnippet)+'</span></a>'+
1155 '<div class="landing-livecard-meta"><span class="landing-livecard-repo">'+esc(r.repo)+'</span></div></li>';
1156 }).join('');
1157 diffMount(ul,html,ids);
1158 }).catch(function(){});
1159 }
1160 function pollFeed(){
1161 return fetch('/api/v2/demo/activity',{credentials:'omit'}).then(function(r){return r.json();}).then(function(d){
1162 var ul=document.querySelector('[data-livecard="feed"]');if(!ul) return;
1163 var entries=(d&&d.entries)||[];
1164 if(entries.length===0){ul.innerHTML='<li class="landing-livecard-empty">Quiet right now — check back in a minute.</li>';return;}
1165 var ids=[];
1166 var html=entries.slice(0,10).map(function(e){
1167 var path=(e.ref&&e.ref.type==='pr')?'pulls':'issues';
1168 var num=(e.ref&&e.ref.number)||0;
1169 var label=e.kind==='auto_merge.merged'?'auto-merged':(e.kind==='ai_build.dispatched'?'AI-build queued':'AI review posted');
1170 var kindCls=String(e.kind||'').replace(/\\./g,'-');
1171 var id=e.kind+'|'+e.repo+'|'+(e.ref&&e.ref.type)+'|'+num+'|'+e.at;ids.push(id);
1172 return '<li class="landing-livecard-feedrow" data-row-id="'+esc(id)+'">'+
1173 '<span class="landing-livecard-kind landing-livecard-kind-'+esc(kindCls)+'">'+esc(label)+'</span> '+
1174 '<a class="landing-livecard-link" href="/'+esc(DEMO)+'/'+esc(e.repo)+'/'+path+'/'+num+'">'+esc(e.repo)+' #'+num+'</a> '+
1175 '<span class="landing-livecard-rel" data-rel="'+esc(e.at)+'">'+esc(rel(e.at))+'</span></li>';
1176 }).join('');
1177 diffMount(ul,html,ids);
1178 }).catch(function(){});
1179 }
1180 function refreshRel(){
1181 var spans=document.querySelectorAll('.landing-livecard-rel[data-rel]');
1182 for(var i=0;i<spans.length;i++){
1183 spans[i].textContent=rel(spans[i].getAttribute('data-rel'));
1184 }
1185 }
1186 function tickAll(){pollQueued();pollMerges();pollReviews();pollFeed();}
1187 // Initial counter tick (count-up from 0) on first paint.
1188 var bigEl0=document.querySelector('[data-livecard-count="reviews"]');
1189 if(bigEl0){
1190 var target=parseInt(bigEl0.getAttribute('data-tick-target')||'0',10)||0;
1191 bigEl0.textContent='0';
1192 tickNumber(bigEl0,target);
1193 }
1194 refreshRel();
1195 setInterval(tickAll,INTERVAL);
1196 setInterval(refreshRel,INTERVAL);
1197 document.addEventListener('visibilitychange',function(){
1198 if(document.visibilityState==='visible'){tickAll();refreshRel();}
1199 });
1200}catch(_){}})();
1201`.trim();
1202
6851203// Backwards-compatible default — web.tsx imports `LandingPage`.
6861204export const LandingPage: FC<LandingPageProps> = (props) => (
6871205 <LandingHero {...props} />
@@ -1924,6 +2442,272 @@ const landingCss = `
19242442 .landing-hero-rail { flex-direction: column; align-items: flex-start; gap: 6px; padding: 0 var(--s-3); }
19252443 .landing-hero-rail li { width: 100%; }
19262444 }
2445
2446 /* ============================================================ */
2447 /* Block M1 — Live-now demo feed */
2448 /* ============================================================ */
2449 .landing-livenow {
2450 margin: var(--s-8) 0 var(--s-6);
2451 padding: var(--s-6) 0 var(--s-4);
2452 }
2453 .landing-livenow-head {
2454 text-align: center;
2455 margin-bottom: var(--s-6);
2456 }
2457 .landing-livenow-eyebrow {
2458 display: inline-flex;
2459 align-items: center;
2460 gap: 8px;
2461 padding: 4px 12px;
2462 border-radius: var(--r-full);
2463 background: rgba(52,211,153,0.08);
2464 border: 1px solid rgba(52,211,153,0.25);
2465 color: var(--green);
2466 font-family: var(--font-mono, ui-monospace, monospace);
2467 font-size: 11px;
2468 letter-spacing: 0.06em;
2469 text-transform: uppercase;
2470 margin-bottom: var(--s-3);
2471 }
2472 .landing-livenow-pulse {
2473 width: 8px; height: 8px;
2474 border-radius: 50%;
2475 background: var(--green);
2476 box-shadow: 0 0 0 0 rgba(52,211,153,0.6);
2477 animation: landing-livenow-pulse 1.6s ease-out infinite;
2478 }
2479 @keyframes landing-livenow-pulse {
2480 0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.55); transform: scale(1); }
2481 70% { box-shadow: 0 0 0 10px rgba(52,211,153,0); transform: scale(1.05); }
2482 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); transform: scale(1); }
2483 }
2484 .landing-livenow-title {
2485 font-size: 22px;
2486 line-height: 1.25;
2487 margin: 0 auto;
2488 max-width: 720px;
2489 color: var(--text-strong);
2490 font-weight: 600;
2491 letter-spacing: -0.01em;
2492 }
2493 .landing-livenow-sub {
2494 margin: var(--s-2) auto 0;
2495 color: var(--text-muted);
2496 font-size: 13px;
2497 max-width: 560px;
2498 }
2499 .landing-livenow-sub code {
2500 background: rgba(255,255,255,0.05);
2501 border: 1px solid var(--border);
2502 padding: 1px 6px;
2503 border-radius: 4px;
2504 font-size: 12px;
2505 color: var(--accent);
2506 }
2507
2508 .landing-livenow-grid {
2509 display: grid;
2510 grid-template-columns: repeat(2, minmax(0, 1fr));
2511 gap: var(--s-3);
2512 }
2513 @media (min-width: 980px) {
2514 .landing-livenow-grid {
2515 grid-template-columns: repeat(4, minmax(0, 1fr));
2516 }
2517 }
2518
2519 .landing-livecard {
2520 background: linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.005));
2521 border: 1px solid var(--border);
2522 border-radius: var(--r-md, 10px);
2523 padding: 14px 14px 12px;
2524 display: flex;
2525 flex-direction: column;
2526 min-height: 180px;
2527 position: relative;
2528 overflow: hidden;
2529 }
2530 .landing-livecard::before {
2531 content: '';
2532 position: absolute;
2533 inset: 0;
2534 background: var(--accent-gradient-faint);
2535 opacity: 0;
2536 transition: opacity 200ms var(--ease, ease);
2537 pointer-events: none;
2538 }
2539 .landing-livecard:hover::before { opacity: 1; }
2540
2541 .landing-livecard-head {
2542 display: flex;
2543 align-items: center;
2544 gap: 8px;
2545 margin-bottom: 10px;
2546 }
2547 .landing-livecard-dot {
2548 width: 7px; height: 7px;
2549 border-radius: 50%;
2550 background: var(--green);
2551 box-shadow: 0 0 8px rgba(52,211,153,0.55);
2552 animation: landing-livenow-pulse 1.8s ease-out infinite;
2553 flex-shrink: 0;
2554 }
2555 .landing-livecard-title {
2556 margin: 0;
2557 font-size: 12px;
2558 font-weight: 600;
2559 text-transform: uppercase;
2560 letter-spacing: 0.06em;
2561 color: var(--text-muted);
2562 }
2563
2564 .landing-livecard-bignum {
2565 display: flex;
2566 align-items: baseline;
2567 gap: 8px;
2568 margin: 4px 0 10px;
2569 }
2570 .landing-livecard-bignum-n {
2571 font-size: 30px;
2572 font-weight: 700;
2573 color: var(--text-strong);
2574 font-variant-numeric: tabular-nums;
2575 background: var(--accent-gradient);
2576 -webkit-background-clip: text;
2577 background-clip: text;
2578 -webkit-text-fill-color: transparent;
2579 color: transparent;
2580 }
2581 .landing-livecard-bignum-label {
2582 font-size: 12px;
2583 color: var(--text-muted);
2584 }
2585
2586 .landing-livecard-list {
2587 list-style: none;
2588 margin: 0;
2589 padding: 0;
2590 font-size: 13px;
2591 line-height: 1.45;
2592 flex: 1;
2593 }
2594 .landing-livecard-row, .landing-livecard-feedrow {
2595 padding: 6px 0;
2596 border-bottom: 1px dashed rgba(255,255,255,0.05);
2597 transition: background-color 1s var(--ease, ease);
2598 border-radius: 4px;
2599 margin: 0 -4px;
2600 padding-left: 4px;
2601 padding-right: 4px;
2602 }
2603 .landing-livecard-row:last-child,
2604 .landing-livecard-feedrow:last-child { border-bottom: 0; }
2605 .landing-livecard-feedrow { padding: 4px; font-size: 12.5px; }
2606
2607 .landing-livecard-flash {
2608 background-color: rgba(52,211,153,0.18) !important;
2609 animation: landing-livecard-flash-fade 1.1s ease-out forwards;
2610 }
2611 @keyframes landing-livecard-flash-fade {
2612 0% { background-color: rgba(52,211,153,0.22); }
2613 100% { background-color: rgba(52,211,153,0); }
2614 }
2615
2616 .landing-livecard-link {
2617 color: var(--text-strong);
2618 text-decoration: none;
2619 display: inline-block;
2620 max-width: 100%;
2621 overflow: hidden;
2622 text-overflow: ellipsis;
2623 white-space: nowrap;
2624 }
2625 .landing-livecard-link:hover { color: var(--accent); }
2626 .landing-livecard-num {
2627 font-family: var(--font-mono, ui-monospace, monospace);
2628 color: var(--text-faint);
2629 font-weight: 500;
2630 font-size: 12px;
2631 }
2632 .landing-livecard-title-text { color: var(--text-strong); }
2633 .landing-livecard-snippet {
2634 color: var(--text-muted);
2635 font-style: italic;
2636 font-size: 12px;
2637 }
2638 .landing-livecard-meta {
2639 font-size: 11px;
2640 color: var(--text-faint);
2641 margin-top: 2px;
2642 font-family: var(--font-mono, ui-monospace, monospace);
2643 }
2644 .landing-livecard-repo {
2645 color: var(--accent-2);
2646 }
2647 .landing-livecard-rel {
2648 color: var(--text-muted);
2649 }
2650 .landing-livecard-empty {
2651 color: var(--text-faint);
2652 font-size: 12px;
2653 font-style: italic;
2654 padding: 8px 0;
2655 }
2656 .landing-livecard-kind {
2657 display: inline-block;
2658 padding: 1px 7px;
2659 border-radius: var(--r-full);
2660 font-family: var(--font-mono, ui-monospace, monospace);
2661 font-size: 10px;
2662 letter-spacing: 0.04em;
2663 text-transform: uppercase;
2664 font-weight: 600;
2665 }
2666 .landing-livecard-kind-auto_merge-merged,
2667 .landing-livecard-kind-auto-merge-merged {
2668 background: rgba(52,211,153,0.12);
2669 color: var(--green);
2670 border: 1px solid rgba(52,211,153,0.25);
2671 }
2672 .landing-livecard-kind-ai_build-dispatched,
2673 .landing-livecard-kind-ai-build-dispatched {
2674 background: rgba(140,109,255,0.12);
2675 color: var(--accent);
2676 border: 1px solid rgba(140,109,255,0.30);
2677 }
2678 .landing-livecard-kind-ai_review-posted,
2679 .landing-livecard-kind-ai-review-posted {
2680 background: rgba(54,197,214,0.12);
2681 color: var(--accent-2);
2682 border: 1px solid rgba(54,197,214,0.30);
2683 }
2684
2685 .landing-livenow-cta {
2686 margin-top: var(--s-5);
2687 display: flex;
2688 flex-wrap: wrap;
2689 align-items: center;
2690 justify-content: center;
2691 gap: 10px;
2692 font-size: 13px;
2693 color: var(--text-muted);
2694 }
2695 .landing-livenow-cta-link {
2696 color: var(--accent);
2697 text-decoration: none;
2698 font-weight: 500;
2699 display: inline-flex;
2700 align-items: center;
2701 gap: 4px;
2702 }
2703 .landing-livenow-cta-link:hover { color: var(--accent-hover); }
2704 .landing-livenow-cta-sep { color: var(--text-faint); }
2705
2706 @media (prefers-reduced-motion: reduce) {
2707 .landing-livenow-pulse,
2708 .landing-livecard-dot { animation: none; }
2709 .landing-livecard-flash { animation: none; background-color: transparent !important; }
2710 }
19272711`;
19282712
19292713/**
Modifiedsrc/views/layout.tsx+79−0View fileUnifiedSplit
@@ -215,6 +215,15 @@ export const Layout: FC<
215215 </div>
216216 <script dangerouslySetInnerHTML={{ __html: clientJs }} />
217217
218 {/* Block M2 — smart install banner + push-SW bootstrap. Authed
219 users only; the banner respects a 3+ visits + 14-day cooldown
220 heuristic. The push SW is registered on the same gate so we
221 don't ask anonymous visitors to opt into anything. */}
222 {user && (
223
224 dangerouslySetInnerHTML={{ __html: pwaInstallBannerScript }}
225 />
226 )}
218227 <script dangerouslySetInnerHTML={{ __html: navScript }} />
219228 </body>
220229 </html>
@@ -278,6 +287,76 @@ const pwaRegisterScript = `
278287 }
279288`;
280289
290// Block M2 — smart install banner (authed users only). Three heuristics:
291// 1. user is authenticated (gated server-side via the `{user &&}` JSX)
292// 2. visit counter (localStorage) ≥ 3
293// 3. dismissed-cooldown < 14 days ago
294// Also bootstraps the push + offline SW at /sw-push.js. Idempotent — safe
295// to load on every page; only inserts DOM nodes if all conditions match.
296const pwaInstallBannerScript = `
297(function(){
298 try {
299 var DISMISS_KEY = 'gc:pwa-banner-dismissed-at';
300 var VISITS_KEY = 'gc:pwa-visit-count';
301 var DISMISS_MS = 14 * 24 * 60 * 60 * 1000;
302 var visits = parseInt(localStorage.getItem(VISITS_KEY) || '0', 10) || 0;
303 visits++;
304 try { localStorage.setItem(VISITS_KEY, String(visits)); } catch(_){}
305 // Bootstrap the push + offline SW (separate from /sw.js's locked behaviour).
306 if ('serviceWorker' in navigator) {
307 window.addEventListener('load', function(){
308 navigator.serviceWorker.register('/sw-push.js', { scope: '/' })
309 .catch(function(){});
310 });
311 }
312 var deferredPrompt = null;
313 window.addEventListener('beforeinstallprompt', function(e){
314 e.preventDefault();
315 deferredPrompt = e;
316 maybeShow();
317 });
318 function maybeShow(){
319 if (!deferredPrompt) return;
320 if (visits < 3) return;
321 var dismissedAt = parseInt(localStorage.getItem(DISMISS_KEY) || '0', 10) || 0;
322 if (dismissedAt && (Date.now() - dismissedAt) < DISMISS_MS) return;
323 if (document.getElementById('gc-install-banner')) return;
324 var bar = document.createElement('div');
325 bar.id = 'gc-install-banner';
326 bar.setAttribute('role', 'dialog');
327 bar.setAttribute('aria-label', 'Install Gluecron');
328 bar.style.cssText = 'position:fixed;left:12px;right:12px;bottom:12px;z-index:9997;'
329 + 'background:var(--bg-elevated,#161b22);color:var(--text,#c9d1d9);'
330 + 'border:1px solid var(--border,#30363d);border-radius:8px;padding:12px 14px;'
331 + 'display:flex;gap:10px;align-items:center;justify-content:space-between;'
332 + 'box-shadow:0 8px 24px rgba(0,0,0,0.45);font-size:13px;line-height:1.3;'
333 + 'max-width:560px;margin:0 auto';
334 bar.innerHTML = '<span style="flex:1">'
335 + '<strong>Install Gluecron</strong> to keep working when offline + get push notifications.'
336 + '</span>'
337 + '<button type="button" id="gc-install-go" style="background:#238636;color:#fff;'
338 + 'border:0;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;'
339 + 'font-family:inherit">Install</button>'
340 + '<button type="button" id="gc-install-no" style="background:transparent;color:inherit;'
341 + 'border:1px solid var(--border,#30363d);border-radius:6px;padding:6px 12px;'
342 + 'font-size:12px;cursor:pointer;font-family:inherit">Not now</button>';
343 document.body.appendChild(bar);
344 var go = bar.querySelector('#gc-install-go');
345 var no = bar.querySelector('#gc-install-no');
346 if (go) go.addEventListener('click', function(){
347 try { deferredPrompt.prompt(); } catch(_){}
348 bar.parentNode && bar.parentNode.removeChild(bar);
349 deferredPrompt = null;
350 });
351 if (no) no.addEventListener('click', function(){
352 try { localStorage.setItem(DISMISS_KEY, String(Date.now())); } catch(_){}
353 bar.parentNode && bar.parentNode.removeChild(bar);
354 });
355 }
356 } catch(_) {}
357})();
358`;
359
281360const navScript = `
282361 (function(){
283362 var chord = null;
284363