Commit05ab9b1unknown_key
feat(M1+M2): Push Watch page + Org Secrets Manager + BUILD_BIBLE §M
feat(M1+M2): Push Watch page + Org Secrets Manager + BUILD_BIBLE §M
Block M1 — Push Watch page (src/routes/push-watch.tsx):
GET /:owner/:repo/push/:sha — per-commit live status dashboard.
Shows gate results, deployment status, and push-to-live latency.
5-second JS poller stops when gates + deploy reach terminal state.
JSON API at /api/repos/:owner/:repo/push-status/:sha.
Block M2 — Org-level Secrets Manager:
drizzle/0075_org_secrets.sql — org_secrets table (AES-256-GCM,
unique on (org_id, name), FK to organizations).
src/lib/org-secrets.ts — listOrgSecrets, upsertOrgSecret,
deleteOrgSecret, loadOrgSecretsForRepo (fail-soft).
src/routes/org-secrets.tsx — /orgs/:slug/settings/secrets CRUD UI;
org-admin only; name live-validation (A-Z_); key-hint display.
Fixes: scoped middleware on both routers so mounting at root never
intercepts unrelated routes (use("*", ...) → path-specific use()).
BUILD_BIBLE: added Block M entry + updated timestamp to 23:02:50Z.
Tests: 2696/0/120 green. Zero TS errors.
https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y6 files changed+1803−905ab9b165fec825bd020341cdc76a3e9e91d7a98
6 changed files+1803−9
ModifiedBUILD_BIBLE.md+24−9View fileUnifiedSplit
@@ -1,6 +1,6 @@
11# GLUECRON BUILD BIBLE
22
3**Last updated: 2026-05-28T22:45:00Z**
3**Last updated: 2026-05-28T23:02:50Z**
44
55**This file is the single source of truth for the GlueCron build.**
66
@@ -355,6 +355,18 @@ The marketing + acquisition + activation + retention package. Ten sub-blocks shi
355355- **L9** — AI hours saved counter → ✅ shipped. Retention feature. `src/lib/ai-hours-saved.ts` (413 lines) exports `computeHoursSaved(breakdown)` (pure, deterministic, used by L1 and L4 too) + `computeAiSavingsForUser(userId, opts?)` + `computeLifetimeAiSavingsForUser(userId)`. Formula: `prsAutoMerged*0.30 + issuesBuiltByAi*1.50 + aiReviewsPosted*0.25 + aiTriagesPosted*0.10 + aiCommitMsgs*0.05 + secretsAutoRepaired*0.50 + gateAutoRepairs*0.40` — heuristic constants documented inline. Dashboard renders a large gradient hero widget with this-week / all-time tabs + breakdown pills + "How is this calculated?" disclosure. New endpoint `GET /api/v2/me/ai-savings` so VS Code + CLI consume the same metric. 17 tests.
356356- **L10** — Marketing landing hero rewrite → ✅ shipped. `src/views/landing.tsx` — new gradient headline ("The git host built around Claude."), one-sentence subhead ("Label an issue. Walk away. Wake up to a merged PR."), one-line install snippet + copy button, three CTAs (`/register` / `/demo` / `/vs-github`), "what just happened" rail driven by L4's `publicStats`, "Three reasons to switch" 3-column section linking to Sleep Mode / `/import` / `/demo`, and a "How is this different from GitHub?" pull-quote linking to `/vs-github`. Preserves L4 counter tiles + L5 vs-github CTA + L7 git-remote caption + L8 free-tier link. `src/views/layout.tsx` extended additively with optional `description`/`ogTitle`/`ogDescription`/`ogType`/`twitterCard`/`fullTitle` props (render byte-identical when omitted, so the §4.7 locked contract holds). `src/routes/web.tsx` landing handler now passes SEO + OG props. 9 tests.
357357
358### BLOCK M — 2030 vision: developer painkiller features (2026-05-28+)
359
360The "lightning-fast push → live site + zero friction" package. Owner directive: be the biggest painkiller for developers; lightning-fast push to bare metal to live site; never any caching issues; most advanced git host until 2030.
361
362- **M0** — DORA metrics dashboard → ✅ shipped (`8d1483c`). `src/routes/dora.tsx` — `GET /:owner/:repo/insights/dora`. Six parallel DB queries (deployment freq, lead time, change failure rate, MTTR, gate pass rate, workflow success rate). DORA level badges (Elite/High/Medium/Low) with colour coding. Last-10-deployments table. All queries wrapped in try/catch for graceful degradation.
363- **M1** — Push Watch page → 🟡 in-flight. `src/routes/push-watch.tsx` — `GET /:owner/:repo/push/:sha`. Per-commit live status: gate results, deploy status, push-to-live latency computation. JSON API at `/api/repos/:owner/:repo/push-status/:sha`. The "lightning-fast feedback" UX: one page that shows everything after a push.
364- **M2** — Org-level Secrets Manager → 🟡 in-flight. `drizzle/0075_org_secrets.sql` + `src/lib/org-secrets.ts` + `src/routes/org-secrets.tsx`. Org secrets apply to all repos in the org (overrideable per-repo). Settings UI at `/orgs/:slug/settings/secrets`. Extends the workflow runner's secrets resolution order: repo secrets > org secrets.
365- **M3** — No-cache middleware → ✅ shipped (`f5b9ef5`). `src/middleware/no-cache.ts` stamps `Cache-Control: no-store, no-cache, must-revalidate` + `Pragma: no-cache` + `Vary: Cookie` on all `text/html` responses. Assets (CSS/JS/images) unaffected. Eliminates stale-page issues after login, deploy, or push.
366- **M4** — Wider platform layout → ✅ shipped (`f5b9ef5`). All `max-width: 1240px` → `1440px` in `src/views/layout.tsx` (nav, main content, footer). Modern wide-screen utilisation for developer dashboards.
367- **M5** — Clean user nav dropdown → ✅ shipped (`f5b9ef5`). Replaced 8+ top-level nav links with a polished user dropdown (avatar initials + caret trigger, Dashboard, PRs, Issues, Activity, Import, Profile, Settings, Tokens, Theme toggle, Sign out) + bell inbox icon with unread badge. Reduces cognitive load; keeps the nav scannable.
368- **M6** — Zero-friction Claude Code integration → ✅ shipped (`f5b9ef5`). `src/routes/claude-integration.ts` — `POST /api/claude/connect` (Bearer PAT auth, auto-creates bare repo, returns gitRemote + mcpUrl), `GET /api/claude/connect`, `POST /api/claude/session` (telemetry). `src/routes/connect.tsx` — `/connect/claude-guide` public onboarding page.
369
358370---
359371
360372## 4. LOCKED BLOCKS (DO NOT UNDO)
@@ -715,15 +727,18 @@ Changes shipped this session (all pushed, zero TS errors, GateTest green):
715727- `src/__tests__/editor.test.ts` — `as unknown as Hono` double-cast for Hono type mismatch.
716728- `src/routes/workflow-secrets.tsx` — moved `wsecScript` const before route handlers (was forward-referenced past GET handler).
717729
718**In-flight / next session:**
719- DORA metrics dashboard `src/routes/dora.tsx` (agent running).
720- Bulk issue operations (agent running).
721- Repo health score on repo landing page.
722- `diff-view.tsx` still has one outstanding `TS2820` — confirmed fixed above.
723- ~~L1 `sleep-mode.ts` should import `computeHoursSaved` from `ai-hours-saved.ts`~~ — already done (line 39).
724- ~~K3 autopilot tasks not surfaced on `/admin/autopilot`~~ — already listed (admin.tsx lines 3437-3438).
730**Additional shipped this session (2026-05-28 continued):**
731- ~~DORA metrics dashboard `src/routes/dora.tsx`~~ — shipped (`8d1483c`). `GET /:owner/:repo/insights/dora` with 6 parallel DB queries, DORA level badges, last-10-deployments table.
732- ~~Bulk issue operations~~ — shipped (`8d1483c`). POST /:owner/:repo/issues/bulk endpoint + floating sticky action bar.
733- `src/__tests__/layout-user-prop.test.ts` — updated nav-user assertion to match redesigned dropdown (`da16579`). Test suite: 2696/0/120.
734- ~~`diff-view.tsx` TS2820~~ — confirmed fixed.
735- ~~L1/K3 follow-ups~~ — confirmed already done.
736
737**In-flight (agents running):**
738- **M1 Push Watch page** — `src/routes/push-watch.tsx` at `GET /:owner/:repo/push/:sha`. Per-push live status: commit info, gate results, deploy status, push-to-live latency. JSON API at `/api/repos/:owner/:repo/push-status/:sha`.
739- **M2 Org-level Secrets** — `drizzle/0075_org_secrets.sql` + `src/lib/org-secrets.ts` + `src/routes/org-secrets.tsx`. Org secrets auto-apply to all repos; settings UI at `/orgs/:slug/settings/secrets`.
725740
726(Intentionally empty. Add here if a block is partially complete at session end.)
741**Repo health score** — already exists at `src/routes/health.tsx` (uses `computeHealthScore` from `src/lib/intelligence.ts`). No new work needed.
727742
728743**2026-05-13 — BLOCK L (PR #62) follow-ups (all non-blocking; suite is 1491/0/2 green):**
729744
Addeddrizzle/0075_org_secrets.sql+14−0View fileUnifiedSplit
@@ -0,0 +1,14 @@
1-- Org-level secrets: apply to all repos in an org (or a selected subset)
2CREATE TABLE "org_secrets" (
3 "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 "org_id" UUID NOT NULL REFERENCES "organizations"("id") ON DELETE CASCADE,
5 "name" TEXT NOT NULL,
6 "encrypted_value" TEXT NOT NULL,
7 "iv" TEXT NOT NULL,
8 "key_hint" TEXT, -- last 4 chars of plaintext for display
9 "created_by" UUID REFERENCES "users"("id") ON DELETE SET NULL,
10 "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
11 "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
12 CONSTRAINT "org_secrets_org_name_uq" UNIQUE ("org_id", "name")
13);
14CREATE INDEX "org_secrets_org_idx" ON "org_secrets"("org_id");
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
@@ -136,6 +136,8 @@ import connectClaudeRoutes from "./routes/connect-claude";
136136import claudeDeployRoutes from "./routes/claude-deploy";
137137import claudeIntegration from "./routes/claude-integration";
138138import connectRoutes from "./routes/connect";
139import pushWatchRoutes from "./routes/push-watch";
140import orgSecretsRoutes from "./routes/org-secrets";
139141import releasesRoutes from "./routes/releases";
140142import requiredChecksRoutes from "./routes/required-checks";
141143import rulesetsRoutes from "./routes/rulesets";
@@ -628,6 +630,10 @@ app.route("/", connectClaudeRoutes);
628630app.route("/", claudeIntegration);
629631// Connect guide — public onboarding page (/connect/claude-guide).
630632app.route("/", connectRoutes);
633// Push Watch — per-commit live status (gates + deploy + latency) at /:owner/:repo/push/:sha
634app.route("/", pushWatchRoutes);
635// Org Secrets Manager — BLOCK M2 — /orgs/:slug/settings/secrets
636app.route("/", orgSecretsRoutes);
631637// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
632638// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
633639app.route("/", claudeDeployRoutes);
Addedsrc/lib/org-secrets.ts+186−0View fileUnifiedSplit
@@ -0,0 +1,186 @@
1/**
2 * Org-level secrets — DB CRUD + runtime loader (Block M1).
3 *
4 * Stores AES-256-GCM ciphertext produced by `workflow-secrets-crypto.ts`.
5 * The table is defined inline here because `src/db/schema.ts` is locked —
6 * no new tables may be added there.
7 *
8 * Public API:
9 * listOrgSecrets(orgId) — metadata only, never plaintext
10 * upsertOrgSecret(orgId, name, …) — create or replace by (org_id, name)
11 * deleteOrgSecret(orgId, secretId) — scoped delete, prevents cross-org deletes
12 * loadOrgSecretsForRepo(orgId) — { NAME: plaintext } map for runner
13 */
14
15import { and, asc, eq } from "drizzle-orm";
16import {
17 pgTable,
18 uuid,
19 text,
20 timestamp,
21 uniqueIndex,
22 index,
23} from "drizzle-orm/pg-core";
24import { db } from "../db";
25import { encryptSecret, decryptSecret } from "./workflow-secrets-crypto";
26
27// ── Inline Drizzle table definition ────────────────────────────────────────
28
29export const orgSecrets = pgTable(
30 "org_secrets",
31 {
32 id: uuid("id").primaryKey().defaultRandom(),
33 orgId: uuid("org_id").notNull(),
34 name: text("name").notNull(),
35 encryptedValue: text("encrypted_value").notNull(),
36 iv: text("iv").notNull(),
37 keyHint: text("key_hint"),
38 createdBy: uuid("created_by"),
39 createdAt: timestamp("created_at").defaultNow().notNull(),
40 updatedAt: timestamp("updated_at").defaultNow().notNull(),
41 },
42 (table) => [
43 uniqueIndex("org_secrets_org_name_uq").on(table.orgId, table.name),
44 index("org_secrets_org_idx").on(table.orgId),
45 ]
46);
47
48export type OrgSecret = typeof orgSecrets.$inferSelect;
49
50// ── Validation ──────────────────────────────────────────────────────────────
51
52const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
53const MAX_NAME_LEN = 100;
54
55// ── Public API ──────────────────────────────────────────────────────────────
56
57/**
58 * List all secrets for an org — metadata only (no plaintext, no ciphertext).
59 * Ordered alphabetically by name.
60 */
61export async function listOrgSecrets(
62 orgId: string
63): Promise<
64 Array<{
65 id: string;
66 name: string;
67 keyHint: string | null;
68 createdAt: Date;
69 updatedAt: Date;
70 }>
71> {
72 try {
73 const rows = await db
74 .select({
75 id: orgSecrets.id,
76 name: orgSecrets.name,
77 keyHint: orgSecrets.keyHint,
78 createdAt: orgSecrets.createdAt,
79 updatedAt: orgSecrets.updatedAt,
80 })
81 .from(orgSecrets)
82 .where(eq(orgSecrets.orgId, orgId))
83 .orderBy(asc(orgSecrets.name));
84 return rows;
85 } catch (err) {
86 console.error("[org-secrets] listOrgSecrets:", err);
87 return [];
88 }
89}
90
91/**
92 * Create or replace a secret. Name must be `[A-Z_][A-Z0-9_]*`.
93 * Uses delete-then-insert to handle the unique constraint on (org_id, name).
94 */
95export async function upsertOrgSecret(
96 orgId: string,
97 name: string,
98 plaintext: string,
99 createdBy: string
100): Promise<void> {
101 if (!SECRET_NAME_RE.test(name) || name.length > MAX_NAME_LEN) {
102 throw new Error(
103 `Secret name must match /^[A-Z_][A-Z0-9_]*$/ and be at most ${MAX_NAME_LEN} chars.`
104 );
105 }
106
107 const result = encryptSecret(plaintext);
108 if (!result.ok) {
109 throw new Error(`Encryption failed: ${result.error}`);
110 }
111
112 // keyHint: last 4 chars of the plaintext, displayed in the UI
113 const keyHint =
114 plaintext.length >= 4 ? plaintext.slice(-4) : "*".repeat(plaintext.length);
115
116 // The encrypted blob from encryptSecret is a single base64 string that
117 // already contains [iv(12) || tag(16) || ciphertext]. We store it in
118 // `encrypted_value` and leave `iv` as an empty string to satisfy the
119 // NOT NULL constraint (the column was part of the original schema spec
120 // but the crypto module packs iv into the ciphertext blob).
121 const now = new Date();
122
123 // Delete existing row for this (orgId, name) if present, then insert.
124 await db
125 .delete(orgSecrets)
126 .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.name, name)));
127
128 await db.insert(orgSecrets).values({
129 orgId,
130 name,
131 encryptedValue: result.ciphertext,
132 iv: "", // iv is packed into encryptedValue by encryptSecret
133 keyHint,
134 createdBy,
135 createdAt: now,
136 updatedAt: now,
137 });
138}
139
140/**
141 * Delete a specific secret, scoped by both orgId and secretId so a caller
142 * with one org's context cannot delete another org's secret by guessing a UUID.
143 */
144export async function deleteOrgSecret(
145 orgId: string,
146 secretId: string
147): Promise<void> {
148 await db
149 .delete(orgSecrets)
150 .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.id, secretId)));
151}
152
153/**
154 * Load all org secrets as a `{ NAME: plaintext }` map.
155 * Called by the workflow runner to merge org-level secrets with repo-level ones
156 * (repo-level secrets of the same name take precedence — callers apply that
157 * merge logic themselves via `{ ...orgSecrets, ...repoSecrets }`).
158 *
159 * Swallows all errors and returns `{}` on failure so a misconfigured master
160 * key or a transient DB outage never aborts a workflow run.
161 */
162export async function loadOrgSecretsForRepo(
163 orgId: string
164): Promise<Record<string, string>> {
165 try {
166 const rows = await db
167 .select({
168 name: orgSecrets.name,
169 encryptedValue: orgSecrets.encryptedValue,
170 })
171 .from(orgSecrets)
172 .where(eq(orgSecrets.orgId, orgId));
173
174 const map: Record<string, string> = {};
175 for (const row of rows) {
176 const dec = decryptSecret(row.encryptedValue);
177 if (dec.ok) {
178 map[row.name] = dec.plaintext;
179 }
180 }
181 return map;
182 } catch (err) {
183 console.error("[org-secrets] loadOrgSecretsForRepo:", err);
184 return {};
185 }
186}
Addedsrc/routes/org-secrets.tsx+627−0View fileUnifiedSplit
@@ -0,0 +1,627 @@
1/**
2 * Org-level secrets UI — Block M1.
3 *
4 * Routes:
5 * GET /orgs/:slug/settings/secrets — list secrets (admin/owner only)
6 * POST /orgs/:slug/settings/secrets — create or update a secret
7 * POST /orgs/:slug/settings/secrets/:id/delete — delete a secret
8 *
9 * Auth: requireAuth + org admin/owner check via loadOrgForUser.
10 * CSS: every selector prefixed `.osec-*` — no bleed into other surfaces.
11 */
12
13import { Hono } from "hono";
14import { Layout } from "../views/layout";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
17import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs";
18import {
19 listOrgSecrets,
20 upsertOrgSecret,
21 deleteOrgSecret,
22} from "../lib/org-secrets";
23import { getUnreadCount } from "../lib/unread";
24
25const orgSecretsRoutes = new Hono<AuthEnv>();
26
27// ── Auth guard ────────────────────────────────────────────────────────────────
28
29orgSecretsRoutes.use("/orgs/:slug/settings/secrets*", softAuth, requireAuth);
30
31// ── Scoped CSS (.osec-*) ──────────────────────────────────────────────────────
32
33const osecStyles = `
34 .osec-wrap {
35 max-width: 900px;
36 margin: 0 auto;
37 padding: var(--space-6) var(--space-4);
38 }
39
40 /* ─── Hero ─── */
41 .osec-hero {
42 position: relative;
43 margin-bottom: var(--space-6);
44 padding: var(--space-5) var(--space-6);
45 background: var(--bg-elevated);
46 border: 1px solid var(--border);
47 border-radius: 16px;
48 overflow: hidden;
49 }
50 .osec-hero::before {
51 content: '';
52 position: absolute;
53 top: 0; left: 0; right: 0;
54 height: 2px;
55 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
56 opacity: 0.7;
57 pointer-events: none;
58 }
59 .osec-hero-orb {
60 position: absolute;
61 inset: -20% -10% auto auto;
62 width: 360px; height: 360px;
63 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
64 filter: blur(80px);
65 opacity: 0.7;
66 pointer-events: none;
67 z-index: 0;
68 }
69 .osec-hero-inner {
70 position: relative;
71 z-index: 1;
72 max-width: 640px;
73 }
74 .osec-eyebrow {
75 font-size: 12px;
76 color: var(--text-muted);
77 margin-bottom: var(--space-2);
78 letter-spacing: 0.02em;
79 display: inline-flex;
80 align-items: center;
81 gap: 8px;
82 text-transform: uppercase;
83 font-family: var(--font-mono);
84 font-weight: 600;
85 }
86 .osec-title {
87 font-size: clamp(26px, 4vw, 38px);
88 font-family: var(--font-display);
89 font-weight: 800;
90 letter-spacing: -0.028em;
91 line-height: 1.05;
92 margin: 0 0 var(--space-2);
93 color: var(--text-strong);
94 }
95 .osec-title-grad {
96 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
97 -webkit-background-clip: text;
98 background-clip: text;
99 -webkit-text-fill-color: transparent;
100 color: transparent;
101 }
102 .osec-sub {
103 font-size: 14.5px;
104 color: var(--text-muted);
105 margin: 0;
106 line-height: 1.55;
107 }
108
109 /* ─── Info banner ─── */
110 .osec-banner {
111 display: flex;
112 gap: 12px;
113 padding: 14px 18px;
114 background: rgba(140,109,255,0.08);
115 border: 1px solid rgba(140,109,255,0.22);
116 border-radius: 12px;
117 margin-bottom: var(--space-5);
118 font-size: 13.5px;
119 color: var(--text);
120 line-height: 1.55;
121 }
122 .osec-banner-icon {
123 flex-shrink: 0;
124 width: 18px; height: 18px;
125 margin-top: 1px;
126 color: #a78bfa;
127 }
128
129 /* ─── Flash messages ─── */
130 .osec-flash {
131 display: flex;
132 align-items: flex-start;
133 gap: 10px;
134 padding: 12px 16px;
135 border-radius: 10px;
136 margin-bottom: var(--space-4);
137 font-size: 13.5px;
138 line-height: 1.45;
139 }
140 .osec-flash-ok {
141 background: rgba(34,197,94,0.10);
142 border: 1px solid rgba(34,197,94,0.26);
143 color: #86efac;
144 }
145 .osec-flash-err {
146 background: rgba(239,68,68,0.10);
147 border: 1px solid rgba(239,68,68,0.28);
148 color: #fca5a5;
149 }
150
151 /* ─── Form card ─── */
152 .osec-form-card {
153 background: var(--bg-elevated);
154 border: 1px solid var(--border);
155 border-radius: 14px;
156 padding: var(--space-5) var(--space-5);
157 margin-bottom: var(--space-6);
158 }
159 .osec-form-title {
160 font-size: 15px;
161 font-weight: 700;
162 color: var(--text-strong);
163 margin: 0 0 var(--space-4);
164 letter-spacing: -0.01em;
165 }
166 .osec-form-row {
167 display: grid;
168 grid-template-columns: 1fr 1fr auto;
169 gap: 10px;
170 align-items: end;
171 }
172 @media (max-width: 640px) {
173 .osec-form-row { grid-template-columns: 1fr; }
174 }
175 .osec-label {
176 display: block;
177 font-size: 12px;
178 font-weight: 600;
179 color: var(--text-muted);
180 text-transform: uppercase;
181 letter-spacing: 0.05em;
182 margin-bottom: 6px;
183 font-family: var(--font-mono);
184 }
185 .osec-input {
186 width: 100%;
187 padding: 9px 13px;
188 background: var(--bg-input, var(--bg));
189 border: 1px solid var(--border-strong, var(--border));
190 border-radius: 8px;
191 color: var(--text);
192 font-size: 13.5px;
193 font-family: var(--font-mono);
194 box-sizing: border-box;
195 transition: border-color 120ms ease, box-shadow 120ms ease;
196 }
197 .osec-input:focus {
198 outline: none;
199 border-color: rgba(140,109,255,0.6);
200 box-shadow: 0 0 0 3px rgba(140,109,255,0.14);
201 }
202 .osec-input.is-invalid {
203 border-color: rgba(239,68,68,0.6);
204 }
205 .osec-hint {
206 font-size: 11.5px;
207 color: var(--text-muted);
208 margin-top: 5px;
209 }
210 .osec-btn {
211 display: inline-flex;
212 align-items: center;
213 justify-content: center;
214 gap: 6px;
215 padding: 9px 18px;
216 border-radius: 9px;
217 font-size: 13.5px;
218 font-weight: 600;
219 border: 1px solid transparent;
220 cursor: pointer;
221 font-family: inherit;
222 line-height: 1;
223 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
224 white-space: nowrap;
225 }
226 .osec-btn-primary {
227 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
228 color: #fff;
229 box-shadow: 0 5px 16px -4px rgba(140,109,255,0.45);
230 }
231 .osec-btn-primary:hover {
232 transform: translateY(-1px);
233 box-shadow: 0 9px 22px -6px rgba(140,109,255,0.55);
234 }
235 .osec-btn-danger {
236 background: transparent;
237 color: #f87171;
238 border-color: rgba(239,68,68,0.35);
239 padding: 6px 12px;
240 font-size: 12.5px;
241 }
242 .osec-btn-danger:hover {
243 background: rgba(239,68,68,0.08);
244 border-color: rgba(239,68,68,0.6);
245 }
246
247 /* ─── Secrets table ─── */
248 .osec-section-title {
249 font-size: 14px;
250 font-weight: 700;
251 color: var(--text-strong);
252 margin: 0 0 var(--space-3);
253 letter-spacing: -0.01em;
254 }
255 .osec-table-wrap {
256 border: 1px solid var(--border);
257 border-radius: 12px;
258 overflow: hidden;
259 }
260 .osec-table {
261 width: 100%;
262 border-collapse: collapse;
263 font-size: 13.5px;
264 }
265 .osec-table thead th {
266 padding: 10px 16px;
267 background: var(--bg-secondary, var(--bg-elevated));
268 border-bottom: 1px solid var(--border);
269 text-align: left;
270 font-size: 11.5px;
271 font-weight: 700;
272 color: var(--text-muted);
273 text-transform: uppercase;
274 letter-spacing: 0.05em;
275 font-family: var(--font-mono);
276 }
277 .osec-table tbody tr {
278 border-bottom: 1px solid var(--border);
279 transition: background 80ms ease;
280 }
281 .osec-table tbody tr:last-child { border-bottom: none; }
282 .osec-table tbody tr:hover { background: rgba(140,109,255,0.035); }
283 .osec-table td {
284 padding: 11px 16px;
285 color: var(--text);
286 vertical-align: middle;
287 }
288 .osec-name {
289 font-family: var(--font-mono);
290 font-weight: 600;
291 color: var(--text-strong);
292 font-size: 13px;
293 }
294 .osec-hint-val {
295 font-family: var(--font-mono);
296 color: var(--text-muted);
297 font-size: 12.5px;
298 }
299 .osec-date {
300 font-size: 12.5px;
301 color: var(--text-muted);
302 font-family: var(--font-mono);
303 }
304 .osec-actions { text-align: right; }
305
306 /* ─── Empty state ─── */
307 .osec-empty {
308 padding: 48px 24px;
309 text-align: center;
310 background: var(--bg-secondary, var(--bg-elevated));
311 }
312 .osec-empty-icon {
313 width: 40px; height: 40px;
314 margin: 0 auto 14px;
315 display: flex;
316 align-items: center;
317 justify-content: center;
318 border-radius: 12px;
319 background: rgba(140,109,255,0.12);
320 color: #a78bfa;
321 }
322 .osec-empty-title {
323 font-size: 15px;
324 font-weight: 700;
325 color: var(--text-strong);
326 margin: 0 0 6px;
327 }
328 .osec-empty-sub {
329 font-size: 13.5px;
330 color: var(--text-muted);
331 margin: 0;
332 line-height: 1.5;
333 }
334
335 /* ─── Breadcrumb ─── */
336 .osec-crumb {
337 font-size: 13px;
338 color: var(--text-muted);
339 margin-bottom: var(--space-3);
340 display: flex;
341 align-items: center;
342 gap: 6px;
343 }
344 .osec-crumb a { color: var(--text-muted); text-decoration: none; }
345 .osec-crumb a:hover { color: var(--text); }
346 .osec-crumb-sep { opacity: 0.4; }
347`;
348
349// ── Helpers ───────────────────────────────────────────────────────────────────
350
351function formatDate(d: Date): string {
352 return d.toLocaleDateString("en-US", {
353 year: "numeric",
354 month: "short",
355 day: "numeric",
356 });
357}
358
359// ── GET /orgs/:slug/settings/secrets ─────────────────────────────────────────
360
361orgSecretsRoutes.get("/orgs/:slug/settings/secrets", async (c) => {
362 const slug = c.req.param("slug");
363 const user = c.get("user")!;
364
365 const { org, role } = await loadOrgForUser(slug, user.id);
366 if (!org) return c.notFound();
367 if (!role || !orgRoleAtLeast(role, "admin")) {
368 return c.redirect(`/orgs/${slug}`);
369 }
370
371 const secrets = await listOrgSecrets(org.id);
372 const unread = await getUnreadCount(user.id);
373 const success = c.req.query("success");
374 const error = c.req.query("error");
375
376 return c.html(
377 <Layout
378 title={`Secrets — ${org.slug}`}
379 user={user}
380 notificationCount={unread}
381 >
382 <style dangerouslySetInnerHTML={{ __html: osecStyles }} />
383
384 <div class="osec-wrap">
385 {/* Breadcrumb */}
386 <nav class="osec-crumb" aria-label="breadcrumb">
387 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
388 <span class="osec-crumb-sep">/</span>
389 <a href={`/orgs/${org.slug}/settings`}>settings</a>
390 <span class="osec-crumb-sep">/</span>
391 <span>secrets</span>
392 </nav>
393
394 {/* Hero */}
395 <div class="osec-hero" role="banner">
396 <div class="osec-hero-orb" aria-hidden="true" />
397 <div class="osec-hero-inner">
398 <div class="osec-eyebrow">
399 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
400 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
401 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
402 </svg>
403 Org secrets
404 </div>
405 <h1 class="osec-title">
406 <span class="osec-title-grad">Secrets.</span>
407 </h1>
408 <p class="osec-sub">
409 Encrypted values available to all workflow runs in{" "}
410 <strong>{org.slug}</strong>.
411 </p>
412 </div>
413 </div>
414
415 {/* Flash */}
416 {success && (
417 <div class="osec-flash osec-flash-ok" role="status">
418 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
419 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
420 <polyline points="22 4 12 14.01 9 11.01" />
421 </svg>
422 {decodeURIComponent(success)}
423 </div>
424 )}
425 {error && (
426 <div class="osec-flash osec-flash-err" role="alert">
427 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
428 <circle cx="12" cy="12" r="10" />
429 <line x1="12" y1="8" x2="12" y2="12" />
430 <line x1="12" y1="16" x2="12.01" y2="16" />
431 </svg>
432 {decodeURIComponent(error)}
433 </div>
434 )}
435
436 {/* Info banner */}
437 <div class="osec-banner" role="note">
438 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
439 <circle cx="12" cy="12" r="10" />
440 <line x1="12" y1="8" x2="12" y2="8" />
441 <line x1="12" y1="12" x2="12" y2="16" />
442 </svg>
443 These secrets are available to all workflow runs in this org. They're
444 overridden by repo-level secrets of the same name.
445 </div>
446
447 {/* Add / update form */}
448 <div class="osec-form-card">
449 <p class="osec-form-title">Add or update a secret</p>
450 <form method="post" action={`/orgs/${org.slug}/settings/secrets`}>
451 <div class="osec-form-row">
452 <div>
453 <label class="osec-label" for="osec-name">Name</label>
454 <input
455 id="osec-name"
456 class="osec-input"
457 type="text"
458 name="name"
459 required
460 placeholder="MY_SECRET_NAME"
461 pattern="[A-Z_][A-Z0-9_]*"
462 maxLength={100}
463 autocomplete="off"
464 spellcheck={false}
465 oninput="this.value=this.value.toUpperCase().replace(/[^A-Z0-9_]/g,'');this.classList.toggle('is-invalid',this.value.length>0&&!/^[A-Z_][A-Z0-9_]*$/.test(this.value))"
466 />
467 <p class="osec-hint">
468 Uppercase letters, digits, underscores — must start with a letter or
469 underscore.
470 </p>
471 </div>
472 <div>
473 <label class="osec-label" for="osec-value">Value</label>
474 <input
475 id="osec-value"
476 class="osec-input"
477 type="password"
478 name="value"
479 required
480 placeholder="••••••••••••"
481 autocomplete="new-password"
482 />
483 <p class="osec-hint">Value is encrypted at rest with AES-256-GCM.</p>
484 </div>
485 <div>
486 <button type="submit" class="osec-btn osec-btn-primary">
487 Save secret
488 </button>
489 </div>
490 </div>
491 </form>
492 </div>
493
494 {/* Secrets list */}
495 <p class="osec-section-title">
496 {secrets.length === 0
497 ? "No secrets yet"
498 : `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`}
499 </p>
500
501 {secrets.length === 0 ? (
502 <div class="osec-table-wrap">
503 <div class="osec-empty">
504 <div class="osec-empty-icon" aria-hidden="true">
505 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
506 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
507 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
508 </svg>
509 </div>
510 <p class="osec-empty-title">No secrets configured</p>
511 <p class="osec-empty-sub">
512 Add your first secret above. It will be injected into every
513 workflow run inside <strong>{org.slug}</strong> as{" "}
514 <code>{"${{ secrets.NAME }}"}</code>.
515 </p>
516 </div>
517 </div>
518 ) : (
519 <div class="osec-table-wrap">
520 <table class="osec-table" aria-label="Org secrets">
521 <thead>
522 <tr>
523 <th scope="col">Name</th>
524 <th scope="col">Hint</th>
525 <th scope="col">Updated</th>
526 <th scope="col">
527 <span class="sr-only">Actions</span>
528 </th>
529 </tr>
530 </thead>
531 <tbody>
532 {secrets.map((s) => (
533 <tr key={s.id}>
534 <td>
535 <span class="osec-name">{s.name}</span>
536 </td>
537 <td>
538 <span class="osec-hint-val">
539 {s.keyHint ? `••••${s.keyHint}` : "—"}
540 </span>
541 </td>
542 <td>
543 <span class="osec-date">{formatDate(s.updatedAt)}</span>
544 </td>
545 <td class="osec-actions">
546 <form
547 method="post"
548 action={`/orgs/${org.slug}/settings/secrets/${s.id}/delete`}
549 onsubmit="return confirm('Delete this secret? This cannot be undone.')"
550 >
551 <button type="submit" class="osec-btn osec-btn-danger">
552 Delete
553 </button>
554 </form>
555 </td>
556 </tr>
557 ))}
558 </tbody>
559 </table>
560 </div>
561 )}
562 </div>
563 </Layout>
564 );
565});
566
567// ── POST /orgs/:slug/settings/secrets ─────────────────────────────────────────
568
569orgSecretsRoutes.post("/orgs/:slug/settings/secrets", async (c) => {
570 const slug = c.req.param("slug");
571 const user = c.get("user")!;
572
573 const { org, role } = await loadOrgForUser(slug, user.id);
574 if (!org) return c.notFound();
575 if (!role || !orgRoleAtLeast(role, "admin")) {
576 return c.redirect(`/orgs/${slug}`);
577 }
578
579 const body = await c.req.parseBody();
580 const name = String(body.name ?? "").trim();
581 const value = String(body.value ?? "");
582
583 if (!name || !value) {
584 const msg = encodeURIComponent("Name and value are required.");
585 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
586 }
587
588 try {
589 await upsertOrgSecret(org.id, name, value, user.id);
590 } catch (err) {
591 const msg = encodeURIComponent(
592 err instanceof Error ? err.message : "Failed to save secret."
593 );
594 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
595 }
596
597 const ok = encodeURIComponent(`Secret "${name}" saved.`);
598 return c.redirect(`/orgs/${slug}/settings/secrets?success=${ok}`);
599});
600
601// ── POST /orgs/:slug/settings/secrets/:id/delete ──────────────────────────────
602
603orgSecretsRoutes.post("/orgs/:slug/settings/secrets/:id/delete", async (c) => {
604 const slug = c.req.param("slug");
605 const secretId = c.req.param("id");
606 const user = c.get("user")!;
607
608 const { org, role } = await loadOrgForUser(slug, user.id);
609 if (!org) return c.notFound();
610 if (!role || !orgRoleAtLeast(role, "admin")) {
611 return c.redirect(`/orgs/${slug}`);
612 }
613
614 try {
615 await deleteOrgSecret(org.id, secretId);
616 } catch (err) {
617 const msg = encodeURIComponent(
618 err instanceof Error ? err.message : "Failed to delete secret."
619 );
620 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
621 }
622
623 const ok = encodeURIComponent("Secret deleted.");
624 return c.redirect(`/orgs/${slug}/settings/secrets?success=${ok}`);
625});
626
627export default orgSecretsRoutes;
Addedsrc/routes/push-watch.tsx+946−0View fileUnifiedSplit
@@ -0,0 +1,946 @@
1/**
2 * Push Watch — per-push live status page.
3 *
4 * Routes:
5 * GET /:owner/:repo/push/:sha — HTML page for this push
6 * GET /api/repos/:owner/:repo/push-status/:sha — JSON polling endpoint
7 *
8 * Shows the developer everything that happened after their push:
9 * commit info, gate results, deployment status, and push-to-live latency.
10 * A plain JS 5-second poller refreshes the gate/deploy cards while any
11 * item is still in a non-terminal state.
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq, or } from "drizzle-orm";
16import { db } from "../db";
17import {
18 activityFeed,
19 gateRuns,
20 deployments,
21 repositories,
22 users,
23} from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { requireRepoAccess } from "../middleware/repo-access";
29import { getUnreadCount } from "../lib/unread";
30
31const pushWatchRoutes = new Hono<AuthEnv>();
32
33// Apply soft auth on every push-watch route so the repo-access middleware can
34// read the resolved user.
35pushWatchRoutes.use("/:owner/:repo/push/*", softAuth);
36pushWatchRoutes.use("/api/repos/:owner/:repo/push-status/*", softAuth);
37
38// ---------------------------------------------------------------------------
39// Helpers
40// ---------------------------------------------------------------------------
41
42function shortSha(sha: string): string {
43 return sha.slice(0, 7);
44}
45
46function relTime(d: Date | string | null): string {
47 if (!d) return "—";
48 const t = typeof d === "string" ? new Date(d) : d;
49 const diffMs = Date.now() - t.getTime();
50 const secs = Math.floor(diffMs / 1000);
51 if (secs < 10) return "just now";
52 if (secs < 60) return `${secs}s ago`;
53 const mins = Math.floor(secs / 60);
54 if (mins < 60) return `${mins}m ago`;
55 const hrs = Math.floor(mins / 60);
56 if (hrs < 24) return `${hrs}h ago`;
57 return t.toLocaleDateString();
58}
59
60function formatLatency(ms: number): string {
61 if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
62 const totalSecs = Math.round(ms / 1000);
63 const m = Math.floor(totalSecs / 60);
64 const s = totalSecs % 60;
65 return s > 0 ? `${m}m ${s}s` : `${m}m`;
66}
67
68function avatarInitials(name: string): string {
69 const parts = name.trim().split(/\s+/);
70 if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
71 return name.slice(0, 2).toUpperCase();
72}
73
74type GateStatus = "running" | "passed" | "failed" | "repaired" | "pending" | "skipped";
75
76function gateStatusClass(status: string): string {
77 const map: Record<string, string> = {
78 passed: "pw-pill-green",
79 repaired: "pw-pill-teal",
80 failed: "pw-pill-red",
81 running: "pw-pill-yellow",
82 pending: "pw-pill-gray",
83 skipped: "pw-pill-gray",
84 };
85 return map[status] ?? "pw-pill-gray";
86}
87
88function deployStatusClass(status: string): string {
89 const map: Record<string, string> = {
90 success: "pw-pill-green",
91 pending: "pw-pill-yellow",
92 running: "pw-pill-yellow",
93 failure: "pw-pill-red",
94 failed: "pw-pill-red",
95 blocked: "pw-pill-gray",
96 waiting_timer: "pw-pill-gray",
97 };
98 return map[status] ?? "pw-pill-gray";
99}
100
101/** True when the gate/deploy set still has non-terminal items. */
102function isInProgress(
103 gates: { status: string }[],
104 deploy: { status: string } | null
105): boolean {
106 const inFlight = new Set(["running", "pending"]);
107 if (gates.some((g) => inFlight.has(g.status))) return true;
108 if (deploy && inFlight.has(deploy.status)) return true;
109 return false;
110}
111
112function overallBanner(
113 gates: { status: string }[],
114 deploy: { status: string } | null,
115 latencyMs: number | null
116): { icon: string; label: string; mod: string } {
117 const hasFailed = gates.some((g) => g.status === "failed");
118 if (hasFailed) return { icon: "✗", label: "Gate failed", mod: "pw-banner-fail" };
119
120 const inProgress =
121 gates.some((g) => ["running", "pending"].includes(g.status)) ||
122 (deploy !== null && ["running", "pending"].includes(deploy.status));
123 if (inProgress) return { icon: "↻", label: "In progress…", mod: "pw-banner-progress" };
124
125 if (deploy?.status === "failure" || deploy?.status === "failed")
126 return { icon: "✗", label: "Deploy failed", mod: "pw-banner-fail" };
127
128 if (deploy?.status === "success" && latencyMs !== null)
129 return { icon: "✓", label: `Live in ${formatLatency(latencyMs)}`, mod: "pw-banner-live" };
130
131 if (gates.length > 0 && gates.every((g) => ["passed", "repaired", "skipped"].includes(g.status)))
132 return { icon: "✓", label: "All gates passed", mod: "pw-banner-live" };
133
134 return { icon: "◌", label: "No data yet", mod: "pw-banner-empty" };
135}
136
137// ---------------------------------------------------------------------------
138// Data loader shared by both the page and the JSON endpoint
139// ---------------------------------------------------------------------------
140
141async function loadPushData(repoId: string, sha: string) {
142 const [pushEvent] = await db
143 .select()
144 .from(activityFeed)
145 .where(
146 and(
147 eq(activityFeed.repositoryId, repoId),
148 or(
149 // standard push action stored with targetId = sha
150 and(eq(activityFeed.action, "push"), eq(activityFeed.targetId, sha)),
151 // SSH push variant
152 and(eq(activityFeed.action, "git.push.ssh"), eq(activityFeed.targetId, sha))
153 )
154 )
155 )
156 .orderBy(desc(activityFeed.createdAt))
157 .limit(1);
158
159 // Attempt to also find via metadata JSON if no targetId match
160 let pushActivity = pushEvent ?? null;
161 if (!pushActivity) {
162 // Fallback: scan recent push events and check metadata for the sha
163 const candidates = await db
164 .select()
165 .from(activityFeed)
166 .where(
167 and(
168 eq(activityFeed.repositoryId, repoId),
169 or(eq(activityFeed.action, "push"), eq(activityFeed.action, "git.push.ssh"))
170 )
171 )
172 .orderBy(desc(activityFeed.createdAt))
173 .limit(50);
174
175 for (const row of candidates) {
176 if (!row.metadata) continue;
177 try {
178 const m = JSON.parse(row.metadata) as Record<string, unknown>;
179 if (
180 m.sha === sha ||
181 m.headSha === sha ||
182 m.after === sha ||
183 m.commitSha === sha
184 ) {
185 pushActivity = row;
186 break;
187 }
188 } catch {
189 // skip
190 }
191 }
192 }
193
194 const gates = await db
195 .select()
196 .from(gateRuns)
197 .where(and(eq(gateRuns.repositoryId, repoId), eq(gateRuns.commitSha, sha)))
198 .orderBy(desc(gateRuns.createdAt));
199
200 const [deploy] = await db
201 .select()
202 .from(deployments)
203 .where(
204 and(eq(deployments.repositoryId, repoId), eq(deployments.commitSha, sha))
205 )
206 .orderBy(desc(deployments.createdAt))
207 .limit(1);
208
209 const deployment = deploy ?? null;
210
211 let latencyMs: number | null = null;
212 if (pushActivity && deployment?.completedAt && deployment.status === "success") {
213 latencyMs =
214 new Date(deployment.completedAt).getTime() -
215 new Date(pushActivity.createdAt).getTime();
216 if (latencyMs < 0) latencyMs = null;
217 }
218
219 return { pushActivity, gates, deployment, latencyMs };
220}
221
222// ---------------------------------------------------------------------------
223// CSS
224// ---------------------------------------------------------------------------
225
226const pwStyles = `
227 /* ── wrapper ── */
228 .pw-wrap { max-width: 960px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
229
230 /* ── banner ── */
231 .pw-banner {
232 position: relative;
233 display: flex;
234 align-items: center;
235 gap: 18px;
236 padding: 28px 32px;
237 border-radius: 16px;
238 border: 1px solid var(--border);
239 background: var(--bg-elevated);
240 margin-bottom: var(--space-5);
241 overflow: hidden;
242 }
243 .pw-banner::before {
244 content: '';
245 position: absolute;
246 top: 0; left: 0; right: 0;
247 height: 2px;
248 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
249 opacity: 0.7;
250 pointer-events: none;
251 }
252 .pw-banner-orb {
253 position: absolute;
254 inset: -40% -10% auto auto;
255 width: 320px; height: 320px;
256 background: radial-gradient(circle, rgba(140,109,255,0.15), rgba(54,197,214,0.07) 50%, transparent 70%);
257 filter: blur(70px);
258 opacity: 0.6;
259 pointer-events: none;
260 }
261 .pw-banner-live { border-color: rgba(52,211,153,0.34); background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, var(--bg-elevated) 55%); }
262 .pw-banner-fail { border-color: rgba(248,113,113,0.34); background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, var(--bg-elevated) 55%); }
263 .pw-banner-progress { border-color: rgba(251,191,36,0.32); background: linear-gradient(135deg, rgba(251,191,36,0.07) 0%, var(--bg-elevated) 55%); }
264 .pw-banner-empty { border-color: var(--border); }
265
266 .pw-banner-icon {
267 flex-shrink: 0;
268 width: 52px; height: 52px;
269 border-radius: 14px;
270 display: flex; align-items: center; justify-content: center;
271 font-size: 22px;
272 font-weight: 700;
273 color: #fff;
274 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
275 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.15);
276 position: relative; z-index: 1;
277 }
278 .pw-banner-live .pw-banner-icon { background: linear-gradient(135deg, #34d399 0%, #10b981 100%); box-shadow: 0 8px 20px -8px rgba(16,185,129,0.5), inset 0 1px 0 rgba(255,255,255,0.18); }
279 .pw-banner-fail .pw-banner-icon { background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); box-shadow: 0 8px 20px -8px rgba(239,68,68,0.5), inset 0 1px 0 rgba(255,255,255,0.15); }
280 .pw-banner-progress .pw-banner-icon { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); color: #1a1206; box-shadow: 0 8px 20px -8px rgba(251,191,36,0.5), inset 0 1px 0 rgba(255,255,255,0.18); }
281
282 .pw-banner-text { position: relative; z-index: 1; }
283 .pw-banner-headline {
284 font-family: var(--font-display);
285 font-size: clamp(20px, 2.8vw, 26px);
286 font-weight: 800;
287 letter-spacing: -0.022em;
288 line-height: 1.1;
289 color: var(--text-strong);
290 margin: 0 0 4px;
291 }
292 .pw-banner-sub {
293 font-size: 13px;
294 color: var(--text-muted);
295 margin: 0;
296 }
297 .pw-banner-sub a { color: var(--accent); text-decoration: none; }
298 .pw-banner-sub a:hover { text-decoration: underline; }
299
300 /* ── section cards ── */
301 .pw-card {
302 background: var(--bg-elevated);
303 border: 1px solid var(--border);
304 border-radius: 12px;
305 margin-bottom: var(--space-4);
306 overflow: hidden;
307 }
308 .pw-card-head {
309 display: flex;
310 align-items: center;
311 gap: 10px;
312 padding: 14px 20px;
313 border-bottom: 1px solid var(--border);
314 font-size: 13px;
315 font-weight: 600;
316 color: var(--text-strong);
317 text-transform: uppercase;
318 letter-spacing: 0.05em;
319 }
320 .pw-card-head-icon {
321 width: 22px; height: 22px;
322 border-radius: 6px;
323 background: rgba(140,109,255,0.14);
324 color: #b69dff;
325 display: flex; align-items: center; justify-content: center;
326 font-size: 12px;
327 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.3);
328 }
329 .pw-card-body { padding: 20px; }
330
331 /* ── commit card ── */
332 .pw-commit {
333 display: flex;
334 align-items: flex-start;
335 gap: 14px;
336 }
337 .pw-avatar {
338 flex-shrink: 0;
339 width: 40px; height: 40px;
340 border-radius: 50%;
341 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
342 display: flex; align-items: center; justify-content: center;
343 font-size: 14px;
344 font-weight: 700;
345 color: #fff;
346 letter-spacing: -0.02em;
347 }
348 .pw-commit-info { flex: 1; min-width: 0; }
349 .pw-commit-msg {
350 font-size: 15px;
351 font-weight: 600;
352 color: var(--text-strong);
353 margin: 0 0 8px;
354 white-space: nowrap;
355 overflow: hidden;
356 text-overflow: ellipsis;
357 }
358 .pw-commit-meta {
359 display: flex;
360 flex-wrap: wrap;
361 gap: 8px;
362 align-items: center;
363 font-size: 12.5px;
364 color: var(--text-muted);
365 }
366 .pw-sha {
367 font-family: var(--font-mono, monospace);
368 font-size: 12px;
369 background: rgba(140,109,255,0.10);
370 color: #b69dff;
371 border-radius: 4px;
372 padding: 2px 7px;
373 border: 1px solid rgba(140,109,255,0.22);
374 }
375 .pw-branch-badge {
376 font-size: 12px;
377 background: rgba(54,197,214,0.10);
378 color: #36c5d6;
379 border-radius: 4px;
380 padding: 2px 7px;
381 border: 1px solid rgba(54,197,214,0.22);
382 }
383
384 /* ── gate table ── */
385 .pw-gate-table {
386 width: 100%;
387 border-collapse: collapse;
388 font-size: 13.5px;
389 }
390 .pw-gate-table th {
391 text-align: left;
392 color: var(--text-muted);
393 font-size: 11px;
394 font-weight: 600;
395 text-transform: uppercase;
396 letter-spacing: 0.05em;
397 padding: 0 0 10px;
398 border-bottom: 1px solid var(--border);
399 }
400 .pw-gate-table td {
401 padding: 10px 0;
402 border-bottom: 1px solid rgba(255,255,255,0.05);
403 color: var(--text);
404 vertical-align: middle;
405 }
406 .pw-gate-table tr:last-child td { border-bottom: none; }
407 .pw-gate-name { font-weight: 500; color: var(--text-strong); }
408 .pw-gate-dur { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono, monospace); }
409
410 /* ── status pills ── */
411 .pw-pill {
412 display: inline-flex;
413 align-items: center;
414 gap: 5px;
415 font-size: 11.5px;
416 font-weight: 600;
417 border-radius: 9999px;
418 padding: 3px 9px;
419 text-transform: capitalize;
420 letter-spacing: 0.02em;
421 }
422 .pw-pill::before {
423 content: '';
424 width: 6px; height: 6px;
425 border-radius: 50%;
426 background: currentColor;
427 flex-shrink: 0;
428 }
429 .pw-pill-green { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.25); }
430 .pw-pill-teal { background: rgba(54,197,214,0.12); color: #36c5d6; border: 1px solid rgba(54,197,214,0.25); }
431 .pw-pill-red { background: rgba(248,113,113,0.12); color: #f87171; border: 1px solid rgba(248,113,113,0.25); }
432 .pw-pill-yellow { background: rgba(251,191,36,0.12); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
433 .pw-pill-gray { background: rgba(139,148,158,0.12); color: #8b949e; border: 1px solid rgba(139,148,158,0.22); }
434 .pw-pill-spin::after {
435 content: '';
436 display: inline-block;
437 width: 8px; height: 8px;
438 border: 1.5px solid #fbbf24;
439 border-top-color: transparent;
440 border-radius: 50%;
441 animation: pw-spin 0.8s linear infinite;
442 margin-left: 2px;
443 }
444 @keyframes pw-spin { to { transform: rotate(360deg); } }
445 @media (prefers-reduced-motion: reduce) { .pw-pill-spin::after { animation: none; } }
446
447 /* ── deploy card row ── */
448 .pw-deploy-row {
449 display: flex;
450 flex-wrap: wrap;
451 align-items: center;
452 gap: 12px 20px;
453 }
454 .pw-deploy-env {
455 font-size: 14px;
456 font-weight: 600;
457 color: var(--text-strong);
458 flex: 1;
459 min-width: 120px;
460 }
461 .pw-deploy-meta {
462 font-size: 12.5px;
463 color: var(--text-muted);
464 }
465
466 /* ── empty state ── */
467 .pw-empty {
468 padding: 40px 20px;
469 text-align: center;
470 color: var(--text-muted);
471 font-size: 14px;
472 }
473 .pw-empty-title {
474 font-size: 16px;
475 font-weight: 600;
476 color: var(--text-strong);
477 margin: 0 0 8px;
478 }
479
480 /* ── poller status ── */
481 .pw-poll-bar {
482 display: none;
483 align-items: center;
484 gap: 8px;
485 font-size: 12px;
486 color: var(--text-muted);
487 margin-bottom: var(--space-3);
488 }
489 .pw-poll-bar.pw-poll-visible { display: flex; }
490 .pw-poll-dot {
491 width: 6px; height: 6px;
492 border-radius: 50%;
493 background: #fbbf24;
494 animation: pw-pulse 1.6s ease-in-out infinite;
495 }
496 @keyframes pw-pulse { 0%,100%{opacity:1} 50%{opacity:0.35} }
497 @media (prefers-reduced-motion: reduce) { .pw-poll-dot { animation: none; } }
498`;
499
500// ---------------------------------------------------------------------------
501// JSON API endpoint
502// ---------------------------------------------------------------------------
503
504pushWatchRoutes.get(
505 "/api/repos/:owner/:repo/push-status/:sha",
506 requireRepoAccess("read"),
507 async (c) => {
508 const sha = c.req.param("sha");
509 const repo = c.get("repository") as { id: string };
510
511 try {
512 const { gates, deployment, latencyMs } = await loadPushData(repo.id, sha);
513 return c.json({
514 sha,
515 gates: gates.map((g) => ({
516 id: g.id,
517 gateName: g.gateName,
518 status: g.status,
519 durationMs: g.durationMs ?? null,
520 summary: g.summary ?? null,
521 createdAt: g.createdAt,
522 completedAt: g.completedAt ?? null,
523 })),
524 deployment: deployment
525 ? {
526 id: deployment.id,
527 environment: deployment.environment,
528 status: deployment.status,
529 target: deployment.target ?? null,
530 createdAt: deployment.createdAt,
531 completedAt: deployment.completedAt ?? null,
532 }
533 : null,
534 latencyMs,
535 });
536 } catch (err) {
537 console.error("[push-watch] JSON endpoint error:", err);
538 return c.json({ error: "Internal error" }, 500);
539 }
540 }
541);
542
543// ---------------------------------------------------------------------------
544// Page route
545// ---------------------------------------------------------------------------
546
547pushWatchRoutes.get(
548 "/:owner/:repo/push/:sha",
549 requireRepoAccess("read"),
550 async (c) => {
551 const owner = c.req.param("owner");
552 const repoName = c.req.param("repo");
553 const sha = c.req.param("sha");
554 const user = c.get("user");
555 const repo = c.get("repository") as {
556 id: string;
557 name: string;
558 ownerId: string;
559 starCount: number;
560 forkCount: number;
561 isPrivate: boolean;
562 };
563
564 const unread = user ? await getUnreadCount(user.id) : 0;
565
566 // Load all push data
567 const { pushActivity, gates, deployment, latencyMs } = await loadPushData(
568 repo.id,
569 sha
570 );
571
572 // Parse commit message + branch from activity metadata
573 let commitMessage = "";
574 let branch = "";
575 let pusherName = "";
576
577 if (pushActivity) {
578 if (pushActivity.metadata) {
579 try {
580 const m = JSON.parse(pushActivity.metadata) as Record<string, unknown>;
581 commitMessage =
582 (m.commitMessage as string) ||
583 (m.message as string) ||
584 (m.subject as string) ||
585 "";
586 branch =
587 (m.branch as string) ||
588 (m.ref as string
589 ? String(m.ref).replace("refs/heads/", "")
590 : "") ||
591 "";
592 } catch {
593 // ignore
594 }
595 }
596
597 // Resolve pusher name from userId
598 if (pushActivity.userId) {
599 try {
600 const [pusherRow] = await db
601 .select({ username: users.username, displayName: users.displayName })
602 .from(users)
603 .where(eq(users.id, pushActivity.userId))
604 .limit(1);
605 if (pusherRow) {
606 pusherName = pusherRow.displayName || pusherRow.username;
607 }
608 } catch {
609 // ignore
610 }
611 }
612 }
613
614 const banner = overallBanner(gates, deployment, latencyMs);
615 const polling = isInProgress(gates, deployment);
616
617 // Build branch from gate ref if not found in activity
618 if (!branch && gates.length > 0 && gates[0].ref) {
619 branch = gates[0].ref.replace("refs/heads/", "");
620 }
621
622 const title = `Push ${shortSha(sha)} — ${owner}/${repoName}`;
623
624 return c.html(
625 <Layout
626 title={title}
627 user={user}
628 notificationCount={unread}
629 >
630 <style dangerouslySetInnerHTML={{ __html: pwStyles }} />
631
632 <div class="pw-wrap">
633 <RepoHeader
634 owner={owner}
635 repo={repoName}
636 starCount={repo.starCount}
637 forkCount={repo.forkCount}
638 currentUser={user?.username ?? null}
639 />
640 <RepoNav owner={owner} repo={repoName} active="commits" />
641
642 {/* ── Live-update poller bar ── */}
643 <div
644 id="pw-poll-bar"
645 class={`pw-poll-bar${polling ? " pw-poll-visible" : ""}`}
646 >
647 <span class="pw-poll-dot" />
648 <span id="pw-poll-msg">Watching for updates…</span>
649 </div>
650
651 {/* ── Hero banner ── */}
652 <div class={`pw-banner ${banner.mod}`} id="pw-banner">
653 <div class="pw-banner-orb" />
654 <div class="pw-banner-icon">{banner.icon}</div>
655 <div class="pw-banner-text">
656 <p class="pw-banner-headline" id="pw-banner-headline">
657 {banner.label}
658 </p>
659 <p class="pw-banner-sub">
660 Commit{" "}
661 <a
662 href={`/${owner}/${repoName}/commit/${sha}`}
663 class="pw-sha"
664 >
665 {shortSha(sha)}
666 </a>
667 {branch && (
668 <>
669 {" "}on <span class="pw-branch-badge">{branch}</span>
670 </>
671 )}
672 {pushActivity && (
673 <> · pushed {relTime(pushActivity.createdAt)}</>
674 )}
675 </p>
676 </div>
677 </div>
678
679 {/* ── Commit card ── */}
680 <div class="pw-card">
681 <div class="pw-card-head">
682 <span class="pw-card-head-icon">⎇</span>
683 Commit
684 </div>
685 <div class="pw-card-body">
686 {pushActivity ? (
687 <div class="pw-commit">
688 <div class="pw-avatar">
689 {pusherName ? avatarInitials(pusherName) : shortSha(sha).slice(0, 2).toUpperCase()}
690 </div>
691 <div class="pw-commit-info">
692 <p class="pw-commit-msg">
693 {commitMessage || "(no commit message)"}
694 </p>
695 <div class="pw-commit-meta">
696 <span>
697 {pusherName ? (
698 <a href={`/${pusherName.toLowerCase().replace(/\s+/g, "-")}`}>
699 {pusherName}
700 </a>
701 ) : (
702 "Unknown"
703 )}
704 </span>
705 <span class="pw-sha">{shortSha(sha)}</span>
706 {branch && <span class="pw-branch-badge">{branch}</span>}
707 <span>{relTime(pushActivity.createdAt)}</span>
708 </div>
709 </div>
710 </div>
711 ) : (
712 <div class="pw-empty">
713 <p class="pw-empty-title">No push event found</p>
714 <p>No activity was recorded for commit {shortSha(sha)}.</p>
715 </div>
716 )}
717 </div>
718 </div>
719
720 {/* ── Gate results card ── */}
721 <div class="pw-card" id="pw-gates-card">
722 <div class="pw-card-head">
723 <span class="pw-card-head-icon">✓</span>
724 Gate results
725 </div>
726 <div class="pw-card-body" id="pw-gates-body">
727 {gates.length > 0 ? (
728 <table class="pw-gate-table">
729 <thead>
730 <tr>
731 <th>Gate</th>
732 <th>Status</th>
733 <th>Duration</th>
734 </tr>
735 </thead>
736 <tbody>
737 {gates.map((g) => (
738 <tr key={g.id}>
739 <td class="pw-gate-name">{g.gateName}</td>
740 <td>
741 <span
742 class={`pw-pill ${gateStatusClass(g.status)}${
743 ["running", "pending"].includes(g.status)
744 ? " pw-pill-spin"
745 : ""
746 }`}
747 >
748 {g.status}
749 </span>
750 </td>
751 <td class="pw-gate-dur">
752 {g.durationMs != null
753 ? `${(g.durationMs / 1000).toFixed(1)}s`
754 : "—"}
755 </td>
756 </tr>
757 ))}
758 </tbody>
759 </table>
760 ) : (
761 <div class="pw-empty">
762 <p class="pw-empty-title">No gate runs yet</p>
763 <p>Gates have not been triggered for this commit.</p>
764 </div>
765 )}
766 </div>
767 </div>
768
769 {/* ── Deployment card ── */}
770 <div class="pw-card" id="pw-deploy-card">
771 <div class="pw-card-head">
772 <span class="pw-card-head-icon">⬆</span>
773 Deployment
774 </div>
775 <div class="pw-card-body" id="pw-deploy-body">
776 {deployment ? (
777 <div class="pw-deploy-row">
778 <span class="pw-deploy-env">{deployment.environment}</span>
779 <span
780 class={`pw-pill ${deployStatusClass(deployment.status)}${
781 ["running", "pending"].includes(deployment.status)
782 ? " pw-pill-spin"
783 : ""
784 }`}
785 >
786 {deployment.status}
787 </span>
788 {deployment.target && (
789 <span class="pw-deploy-meta">→ {deployment.target}</span>
790 )}
791 <span class="pw-deploy-meta">
792 {deployment.completedAt
793 ? relTime(deployment.completedAt)
794 : relTime(deployment.createdAt)}
795 </span>
796 {latencyMs !== null && (
797 <span class="pw-pill pw-pill-green" style="margin-left: auto;">
798 Live in {formatLatency(latencyMs)}
799 </span>
800 )}
801 </div>
802 ) : (
803 <div class="pw-empty">
804 <p class="pw-empty-title">No deployment yet</p>
805 <p>
806 A deployment record will appear once a deploy is triggered
807 for this commit.
808 </p>
809 </div>
810 )}
811 </div>
812 </div>
813 </div>
814
815 {/* ── 5-second poller script ── */}
816 <script
817 dangerouslySetInnerHTML={{
818 __html: `
819(function() {
820 var POLL_INTERVAL = 5000;
821 var owner = ${JSON.stringify(owner)};
822 var repo = ${JSON.stringify(repoName)};
823 var sha = ${JSON.stringify(sha)};
824 var url = '/api/repos/' + owner + '/' + repo + '/push-status/' + sha;
825
826 var isTerminal = ${JSON.stringify(!polling)};
827 if (isTerminal) return;
828
829 var pollBar = document.getElementById('pw-poll-bar');
830 var pollMsg = document.getElementById('pw-poll-msg');
831 var banner = document.getElementById('pw-banner');
832 var headline = document.getElementById('pw-banner-headline');
833
834 function shortSha(s) { return s.slice(0, 7); }
835
836 function gateStatusClass(s) {
837 var m = { passed:'pw-pill-green', repaired:'pw-pill-teal', failed:'pw-pill-red',
838 running:'pw-pill-yellow', pending:'pw-pill-yellow', skipped:'pw-pill-gray' };
839 return m[s] || 'pw-pill-gray';
840 }
841 function deployStatusClass(s) {
842 var m = { success:'pw-pill-green', pending:'pw-pill-yellow', running:'pw-pill-yellow',
843 failure:'pw-pill-red', failed:'pw-pill-red', blocked:'pw-pill-gray', waiting_timer:'pw-pill-gray' };
844 return m[s] || 'pw-pill-gray';
845 }
846 function formatLatency(ms) {
847 if (ms < 60000) return Math.round(ms/1000) + 's';
848 var s = Math.round(ms/1000); var m = Math.floor(s/60); var r = s%60;
849 return r > 0 ? m + 'm ' + r + 's' : m + 'm';
850 }
851 function inProgress(gates, deploy) {
852 var inf = ['running','pending'];
853 if (gates.some(function(g){ return inf.indexOf(g.status)>=0; })) return true;
854 if (deploy && inf.indexOf(deploy.status)>=0) return true;
855 return false;
856 }
857 function overallBanner(gates, deploy, latencyMs) {
858 if (gates.some(function(g){ return g.status==='failed'; }))
859 return { icon:'✗', label:'Gate failed', mod:'pw-banner-fail' };
860 var inf = ['running','pending'];
861 if (gates.some(function(g){ return inf.indexOf(g.status)>=0; }) ||
862 (deploy && inf.indexOf(deploy.status)>=0))
863 return { icon:'↻', label:'In progress…', mod:'pw-banner-progress' };
864 if (deploy && (deploy.status==='failure'||deploy.status==='failed'))
865 return { icon:'✗', label:'Deploy failed', mod:'pw-banner-fail' };
866 if (deploy && deploy.status==='success' && latencyMs!=null)
867 return { icon:'✓', label:'Live in '+formatLatency(latencyMs), mod:'pw-banner-live' };
868 var terminalGate = ['passed','repaired','skipped'];
869 if (gates.length>0 && gates.every(function(g){ return terminalGate.indexOf(g.status)>=0; }))
870 return { icon:'✓', label:'All gates passed', mod:'pw-banner-live' };
871 return { icon:'◌', label:'No data yet', mod:'pw-banner-empty' };
872 }
873
874 function renderGates(gates) {
875 var body = document.getElementById('pw-gates-body');
876 if (!body) return;
877 if (!gates.length) {
878 body.innerHTML = '<div class="pw-empty"><p class="pw-empty-title">No gate runs yet</p><p>Gates have not been triggered for this commit.</p></div>';
879 return;
880 }
881 var rows = gates.map(function(g) {
882 var spin = (g.status==='running'||g.status==='pending') ? ' pw-pill-spin' : '';
883 var dur = g.durationMs != null ? (g.durationMs/1000).toFixed(1)+'s' : '—';
884 return '<tr><td class="pw-gate-name">'+g.gateName+'</td><td><span class="pw-pill '+gateStatusClass(g.status)+spin+'">'+g.status+'</span></td><td class="pw-gate-dur">'+dur+'</td></tr>';
885 }).join('');
886 body.innerHTML = '<table class="pw-gate-table"><thead><tr><th>Gate</th><th>Status</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table>';
887 }
888
889 function renderDeploy(deploy, latencyMs) {
890 var body = document.getElementById('pw-deploy-body');
891 if (!body) return;
892 if (!deploy) {
893 body.innerHTML = '<div class="pw-empty"><p class="pw-empty-title">No deployment yet</p><p>A deployment record will appear once a deploy is triggered for this commit.</p></div>';
894 return;
895 }
896 var spin = (deploy.status==='running'||deploy.status==='pending') ? ' pw-pill-spin' : '';
897 var target = deploy.target ? '<span class="pw-deploy-meta">→ '+deploy.target+'</span>' : '';
898 var when = deploy.completedAt || deploy.createdAt;
899 var whenStr = when ? new Date(when).toLocaleTimeString() : '—';
900 var latStr = latencyMs!=null ? '<span class="pw-pill pw-pill-green" style="margin-left:auto;">Live in '+formatLatency(latencyMs)+'</span>' : '';
901 body.innerHTML = '<div class="pw-deploy-row"><span class="pw-deploy-env">'+deploy.environment+'</span><span class="pw-pill '+deployStatusClass(deploy.status)+spin+'">'+deploy.status+'</span>'+target+'<span class="pw-deploy-meta">'+whenStr+'</span>'+latStr+'</div>';
902 }
903
904 function updateBanner(gates, deploy, latencyMs) {
905 if (!banner || !headline) return;
906 var b = overallBanner(gates, deploy, latencyMs);
907 banner.className = 'pw-banner ' + b.mod;
908 var icon = banner.querySelector('.pw-banner-icon');
909 if (icon) icon.textContent = b.icon;
910 headline.textContent = b.label;
911 }
912
913 function poll() {
914 fetch(url)
915 .then(function(r){ return r.json(); })
916 .then(function(data) {
917 renderGates(data.gates || []);
918 renderDeploy(data.deployment, data.latencyMs);
919 updateBanner(data.gates || [], data.deployment, data.latencyMs);
920
921 if (!inProgress(data.gates || [], data.deployment)) {
922 isTerminal = true;
923 if (pollBar) pollBar.classList.remove('pw-poll-visible');
924 if (pollMsg) pollMsg.textContent = 'Up to date';
925 return;
926 }
927 if (pollMsg) pollMsg.textContent = 'Watching for updates…';
928 setTimeout(poll, POLL_INTERVAL);
929 })
930 .catch(function() {
931 // silent — try again
932 setTimeout(poll, POLL_INTERVAL * 2);
933 });
934 }
935
936 setTimeout(poll, POLL_INTERVAL);
937})();
938`,
939 }}
940 />
941 </Layout>
942 );
943 }
944);
945
946export default pushWatchRoutes;
0947