Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

gates.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

gates.tsxBlame507 lines · 2 contributors
3ef4c9dClaude1/**
2 * Gates UI — gate run history + branch protection settings + repo settings toggles.
3 *
4 * GET /:owner/:repo/gates — per-repo gate run history
5 * GET /:owner/:repo/gates/settings — settings toggles + branch protection (owner-only)
6 * POST /:owner/:repo/gates/settings — save toggles
7 * POST /:owner/:repo/gates/protection — save/update branch protection rule
8 * POST /:owner/:repo/gates/protection/:id/delete — remove a protection rule
9 * POST /:owner/:repo/gates/run — manually trigger a gate run on the default branch
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq } from "drizzle-orm";
14import { db } from "../db";
15import {
16 branchProtection,
17 gateRuns,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getUnreadCount } from "../lib/unread";
28import { audit } from "../lib/notify";
29
30const gates = new Hono<AuthEnv>();
31gates.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50function relTime(d: Date | string): string {
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diffMs = Date.now() - t.getTime();
53 const mins = Math.floor(diffMs / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63// ---------- Gate run history ----------
64
65gates.get("/:owner/:repo/gates", async (c) => {
66 const user = c.get("user");
67 const { owner, repo } = c.req.param();
68 const repoRow = await loadRepo(owner, repo);
69 if (!repoRow) return c.notFound();
70
71 const runs = await db
72 .select()
73 .from(gateRuns)
74 .where(eq(gateRuns.repositoryId, repoRow.id))
75 .orderBy(desc(gateRuns.createdAt))
76 .limit(100);
77
78 const unread = user ? await getUnreadCount(user.id) : 0;
79 const total = runs.length;
80 const passed = runs.filter((r) => r.status === "passed").length;
81 const failed = runs.filter((r) => r.status === "failed").length;
82 const repaired = runs.filter((r) => r.status === "repaired").length;
83 const skipped = runs.filter((r) => r.status === "skipped").length;
84
85 return c.html(
86 <Layout
87 title={`Gates — ${owner}/${repo}`}
88 user={user}
89 notificationCount={unread}
90 >
91 <RepoHeader
92 owner={owner}
93 repo={repo}
94 starCount={repoRow.starCount}
95 forkCount={repoRow.forkCount}
96 currentUser={user?.username || null}
97 />
98 <RepoNav owner={owner} repo={repo} active="gates" />
99 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
100 <h3>Gate runs</h3>
101 {user && user.id === repoRow.ownerId && (
102 <a
103 href={`/${owner}/${repo}/gates/settings`}
104 class="btn btn-sm"
105 >
106 {"\u2699"} Settings
107 </a>
108 )}
109 </div>
110
111 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px">
112 <div class="panel" style="padding: 12px; text-align: center">
113 <div style="font-size: 22px; font-weight: 700; color: var(--green)">{passed}</div>
114 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Passed</div>
115 </div>
116 <div class="panel" style="padding: 12px; text-align: center">
117 <div style="font-size: 22px; font-weight: 700; color: #bc8cff">{repaired}</div>
118 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Repaired</div>
119 </div>
120 <div class="panel" style="padding: 12px; text-align: center">
121 <div style="font-size: 22px; font-weight: 700; color: var(--red)">{failed}</div>
122 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Failed</div>
123 </div>
124 <div class="panel" style="padding: 12px; text-align: center">
125 <div style="font-size: 22px; font-weight: 700; color: var(--text-muted)">{skipped}</div>
126 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Skipped</div>
127 </div>
128 </div>
129
130 {total === 0 ? (
131 <div class="empty-state">
132 <p>No gate runs yet. Push a commit to trigger the full green ecosystem.</p>
133 </div>
134 ) : (
135 <div class="gate-list">
136 {runs.map((r) => (
137 <div class="gate-run-row">
138 <span class={`gate-status ${r.status}`}>{r.status}</span>
139 <div style="flex: 1">
140 <div style="font-weight: 500">{r.gateName}</div>
141 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
142 <a href={`/${owner}/${repo}/commit/${r.commitSha}`}>
143 {r.commitSha.slice(0, 7)}
144 </a>
145 {" · "}
146 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
147 {" · "}
148 <span>{relTime(r.createdAt)}</span>
149 {r.durationMs ? ` · ${(r.durationMs / 1000).toFixed(1)}s` : ""}
150 </div>
151 {r.summary && (
152 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
153 {r.summary}
154 </div>
155 )}
156 {r.repairCommitSha && (
157 <div style="font-size: 12px; color: #bc8cff; margin-top: 2px">
158 Auto-repaired in{" "}
159 <a href={`/${owner}/${repo}/commit/${r.repairCommitSha}`}>
160 {r.repairCommitSha.slice(0, 7)}
161 </a>
162 </div>
163 )}
164 </div>
165 </div>
166 ))}
167 </div>
168 )}
169 </Layout>
170 );
171});
172
173// ---------- Settings UI ----------
174
175gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
176 const user = c.get("user")!;
177 const { owner, repo } = c.req.param();
178 const repoRow = await loadRepo(owner, repo);
179 if (!repoRow) return c.notFound();
180 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
181
182 const settings = await getOrCreateSettings(repoRow.id);
183 const protections = await db
184 .select()
185 .from(branchProtection)
186 .where(eq(branchProtection.repositoryId, repoRow.id));
187
188 const unread = await getUnreadCount(user.id);
189 const success = c.req.query("success");
190
191 const toggle = (name: string, label: string, checked: boolean, desc?: string) => (
192 <label
193 style="display: flex; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer"
194 >
2c3ba6ecopilot-swe-agent[bot]195 <input type="checkbox" name={name} value="1" checked={checked} aria-label={label} />
3ef4c9dClaude196 <div>
197 <div style="font-weight: 500">{label}</div>
198 {desc && (
199 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
200 {desc}
201 </div>
202 )}
203 </div>
204 </label>
205 );
206
207 return c.html(
208 <Layout
209 title={`Gate settings — ${owner}/${repo}`}
210 user={user}
211 notificationCount={unread}
212 >
213 <RepoHeader
214 owner={owner}
215 repo={repo}
216 starCount={repoRow.starCount}
217 forkCount={repoRow.forkCount}
218 currentUser={user.username}
219 />
220 <RepoNav owner={owner} repo={repo} active="gates" />
221 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
222 <h3>Gate & auto-repair settings</h3>
223 <a href={`/${owner}/${repo}/gates`} class="btn btn-sm">
224 Back to runs
225 </a>
226 </div>
227 {success && (
228 <div class="auth-success">{decodeURIComponent(success)}</div>
229 )}
230
cce7944Claude231 <form method="post" action={`/${owner}/${repo}/gates/settings`}>
3ef4c9dClaude232 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
233 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
234 Gates
235 </div>
236 {toggle("gateTestEnabled", "GateTest scan", settings!.gateTestEnabled, "External test/lint runner")}
237 {toggle("aiReviewEnabled", "AI code review", settings!.aiReviewEnabled, "Claude reviews every PR")}
238 {toggle("secretScanEnabled", "Secret scan", settings!.secretScanEnabled, "Regex + AI secret detection on every push")}
239 {toggle("securityScanEnabled", "Security scan", settings!.securityScanEnabled, "Claude-powered semantic security review")}
240 {toggle("dependencyScanEnabled", "Dependency scan", settings!.dependencyScanEnabled, "Vulnerability scanning on lockfiles")}
241 {toggle("lintEnabled", "Lint", settings!.lintEnabled, "Auto-lint every push")}
242 {toggle("typeCheckEnabled", "Type check", settings!.typeCheckEnabled)}
243 {toggle("testEnabled", "Tests", settings!.testEnabled, "Run your test suite on every push")}
244 </div>
245
246 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
247 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
248 Auto-repair
249 </div>
250 {toggle("autoFixEnabled", "Auto-fix failing gates", settings!.autoFixEnabled, "Claude attempts a fix before a human is pinged")}
251 {toggle("autoMergeResolveEnabled", "Auto-resolve merge conflicts", settings!.autoMergeResolveEnabled)}
252 {toggle("autoFormatEnabled", "Auto-format on commit", settings!.autoFormatEnabled)}
253 </div>
254
255 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
256 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
257 AI features
258 </div>
259 {toggle("aiCommitMessagesEnabled", "AI commit messages", settings!.aiCommitMessagesEnabled)}
260 {toggle("aiPrSummaryEnabled", "AI PR summaries", settings!.aiPrSummaryEnabled)}
261 {toggle("aiChangelogEnabled", "AI release changelogs", settings!.aiChangelogEnabled)}
262 </div>
263
264 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
265 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
266 Deploy
267 </div>
268 {toggle("autoDeployEnabled", "Auto-deploy on green pushes to default branch", settings!.autoDeployEnabled)}
269 {toggle("deployRequireAllGreen", "Block deploys unless all gates are green", settings!.deployRequireAllGreen)}
270 </div>
271
272 <button type="submit" class="btn btn-primary">
273 Save settings
274 </button>
275 </form>
276
277 <h3 style="margin-top: 32px; margin-bottom: 12px">Branch protection</h3>
278 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
279 The default branch is protected on every new repo. Add extra rules for release branches.
280 </p>
281 <div class="panel" style="margin-bottom: 16px">
282 {protections.length === 0 ? (
283 <div class="panel-empty">No protection rules yet.</div>
284 ) : (
285 protections.map((p) => (
286 <div class="panel-item" style="justify-content: space-between">
287 <div style="flex: 1">
288 <code
289 style="background: var(--bg-tertiary); padding: 2px 8px; border-radius: 3px"
290 >
291 {p.pattern}
292 </code>
293 <div class="meta" style="margin-top: 4px">
294 {p.requirePullRequest ? "PR required · " : ""}
295 {p.requireGreenGates ? "Green gates · " : ""}
296 {p.requireAiApproval ? "AI approval · " : ""}
297 {p.requireHumanReview
298 ? `${p.requiredApprovals} human approval(s) · `
299 : ""}
300 {!p.allowForcePush ? "No force push · " : ""}
301 {!p.allowDeletion ? "No deletion" : ""}
302 </div>
303 </div>
a79a9edClaude304 <div style="display:flex;gap:6px">
305 <a
306 href={`/${owner}/${repo}/gates/protection/${p.id}/checks`}
307 class="btn btn-sm"
308 title="Manage required status checks for this rule"
309 >
310 Required checks
311 </a>
312 <form
cce7944Claude313 method="post"
a79a9edClaude314 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
315 onsubmit="return confirm('Remove this rule?')"
316 >
317 <button type="submit" class="btn btn-sm btn-danger">
318 Remove
319 </button>
320 </form>
321 </div>
3ef4c9dClaude322 </div>
323 ))
324 )}
325 </div>
326
327 <form
cce7944Claude328 method="post"
3ef4c9dClaude329 action={`/${owner}/${repo}/gates/protection`}
330 class="panel"
331 style="padding: 16px"
332 >
333 <div class="form-group">
334 <label>Pattern</label>
335 <input
336 type="text"
337 name="pattern"
338 required
339 placeholder="release/* or main"
2c3ba6ecopilot-swe-agent[bot]340 aria-label="Branch protection pattern"
3ef4c9dClaude341 />
342 </div>
343 <div style="display: flex; flex-wrap: wrap; gap: 16px">
344 <label style="display: flex; align-items: center; gap: 6px">
345 <input type="checkbox" name="requirePullRequest" value="1" checked />
346 Require PR
347 </label>
348 <label style="display: flex; align-items: center; gap: 6px">
349 <input type="checkbox" name="requireGreenGates" value="1" checked />
350 Require green gates
351 </label>
352 <label style="display: flex; align-items: center; gap: 6px">
353 <input type="checkbox" name="requireAiApproval" value="1" checked />
354 Require AI approval
355 </label>
356 <label style="display: flex; align-items: center; gap: 6px">
357 <input type="checkbox" name="requireHumanReview" value="1" />
358 Require human review
359 </label>
360 <label style="display: flex; align-items: center; gap: 6px">
361 Approvals{" "}
362 <input
363 type="number"
364 name="requiredApprovals"
365 min="0"
366 max="10"
367 value="1"
368 style="width: 60px"
369 />
370 </label>
371 <label style="display: flex; align-items: center; gap: 6px">
372 <input type="checkbox" name="allowForcePush" value="1" />
373 Allow force push
374 </label>
375 <label style="display: flex; align-items: center; gap: 6px">
376 <input type="checkbox" name="allowDeletion" value="1" />
377 Allow deletion
378 </label>
379 </div>
380 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
381 Add rule
382 </button>
383 </form>
384 </Layout>
385 );
386});
387
388gates.post("/:owner/:repo/gates/settings", requireAuth, async (c) => {
389 const user = c.get("user")!;
390 const { owner, repo } = c.req.param();
391 const repoRow = await loadRepo(owner, repo);
392 if (!repoRow) return c.notFound();
393 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
394
395 const body = await c.req.parseBody();
396 const b = (k: string) => body[k] === "1" || body[k] === "on";
397
398 try {
399 await db
400 .update(repoSettings)
401 .set({
402 gateTestEnabled: b("gateTestEnabled"),
403 aiReviewEnabled: b("aiReviewEnabled"),
404 secretScanEnabled: b("secretScanEnabled"),
405 securityScanEnabled: b("securityScanEnabled"),
406 dependencyScanEnabled: b("dependencyScanEnabled"),
407 lintEnabled: b("lintEnabled"),
408 typeCheckEnabled: b("typeCheckEnabled"),
409 testEnabled: b("testEnabled"),
410 autoFixEnabled: b("autoFixEnabled"),
411 autoMergeResolveEnabled: b("autoMergeResolveEnabled"),
412 autoFormatEnabled: b("autoFormatEnabled"),
413 aiCommitMessagesEnabled: b("aiCommitMessagesEnabled"),
414 aiPrSummaryEnabled: b("aiPrSummaryEnabled"),
415 aiChangelogEnabled: b("aiChangelogEnabled"),
416 autoDeployEnabled: b("autoDeployEnabled"),
417 deployRequireAllGreen: b("deployRequireAllGreen"),
418 updatedAt: new Date(),
419 })
420 .where(eq(repoSettings.repositoryId, repoRow.id));
421 } catch (err) {
422 console.error("[gates] settings save:", err);
423 }
424
425 await audit({
426 userId: user.id,
427 repositoryId: repoRow.id,
428 action: "gates.settings.update",
429 });
430
431 return c.redirect(
432 `/${owner}/${repo}/gates/settings?success=Settings+saved`
433 );
434});
435
436gates.post("/:owner/:repo/gates/protection", requireAuth, async (c) => {
437 const user = c.get("user")!;
438 const { owner, repo } = c.req.param();
439 const repoRow = await loadRepo(owner, repo);
440 if (!repoRow) return c.notFound();
441 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
442
443 const body = await c.req.parseBody();
444 const pattern = String(body.pattern || "").trim();
445 if (!pattern) return c.redirect(`/${owner}/${repo}/gates/settings`);
446 const b = (k: string) => body[k] === "1" || body[k] === "on";
447 const requiredApprovals = Math.max(
448 0,
449 Math.min(10, parseInt(String(body.requiredApprovals || "0"), 10) || 0)
450 );
451
452 try {
453 await db.insert(branchProtection).values({
454 repositoryId: repoRow.id,
455 pattern,
456 requirePullRequest: b("requirePullRequest"),
457 requireGreenGates: b("requireGreenGates"),
458 requireAiApproval: b("requireAiApproval"),
459 requireHumanReview: b("requireHumanReview"),
460 requiredApprovals,
461 allowForcePush: b("allowForcePush"),
462 allowDeletion: b("allowDeletion"),
463 });
464 } catch (err) {
465 console.error("[gates] protection save:", err);
466 }
467
468 await audit({
469 userId: user.id,
470 repositoryId: repoRow.id,
471 action: "branch_protection.create",
472 metadata: { pattern },
473 });
474
475 return c.redirect(
476 `/${owner}/${repo}/gates/settings?success=Rule+added`
477 );
478});
479
480gates.post(
481 "/:owner/:repo/gates/protection/:id/delete",
482 requireAuth,
483 async (c) => {
484 const user = c.get("user")!;
485 const { owner, repo, id } = c.req.param();
486 const repoRow = await loadRepo(owner, repo);
487 if (!repoRow) return c.notFound();
488 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
489 await db
490 .delete(branchProtection)
491 .where(
492 and(
493 eq(branchProtection.id, id),
494 eq(branchProtection.repositoryId, repoRow.id)
495 )
496 );
497 await audit({
498 userId: user.id,
499 repositoryId: repoRow.id,
500 action: "branch_protection.delete",
501 targetId: id,
502 });
503 return c.redirect(`/${owner}/${repo}/gates/settings?success=Rule+removed`);
504 }
505);
506
507export default gates;