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.tsxBlame517 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">
4127ecfClaude117 <div style="font-size: 22px; font-weight: 700; color: var(--accent)">{repaired}</div>
3ef4c9dClaude118 <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 && (
4127ecfClaude157 <div style="font-size: 12px; color: var(--accent); margin-top: 2px">
3ef4c9dClaude158 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 : ""}
4626e61Claude300 {p.enableAutoMerge ? "AI auto-merge · " : ""}
3ef4c9dClaude301 {!p.allowForcePush ? "No force push · " : ""}
302 {!p.allowDeletion ? "No deletion" : ""}
303 </div>
304 </div>
a79a9edClaude305 <div style="display:flex;gap:6px">
306 <a
307 href={`/${owner}/${repo}/gates/protection/${p.id}/checks`}
308 class="btn btn-sm"
309 title="Manage required status checks for this rule"
310 >
311 Required checks
312 </a>
313 <form
cce7944Claude314 method="post"
a79a9edClaude315 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
316 onsubmit="return confirm('Remove this rule?')"
317 >
318 <button type="submit" class="btn btn-sm btn-danger">
319 Remove
320 </button>
321 </form>
322 </div>
3ef4c9dClaude323 </div>
324 ))
325 )}
326 </div>
327
328 <form
cce7944Claude329 method="post"
3ef4c9dClaude330 action={`/${owner}/${repo}/gates/protection`}
331 class="panel"
332 style="padding: 16px"
333 >
334 <div class="form-group">
335 <label>Pattern</label>
336 <input
337 type="text"
338 name="pattern"
339 required
340 placeholder="release/* or main"
2c3ba6ecopilot-swe-agent[bot]341 aria-label="Branch protection pattern"
3ef4c9dClaude342 />
343 </div>
344 <div style="display: flex; flex-wrap: wrap; gap: 16px">
345 <label style="display: flex; align-items: center; gap: 6px">
346 <input type="checkbox" name="requirePullRequest" value="1" checked />
347 Require PR
348 </label>
349 <label style="display: flex; align-items: center; gap: 6px">
350 <input type="checkbox" name="requireGreenGates" value="1" checked />
351 Require green gates
352 </label>
353 <label style="display: flex; align-items: center; gap: 6px">
354 <input type="checkbox" name="requireAiApproval" value="1" checked />
355 Require AI approval
356 </label>
357 <label style="display: flex; align-items: center; gap: 6px">
358 <input type="checkbox" name="requireHumanReview" value="1" />
359 Require human review
360 </label>
361 <label style="display: flex; align-items: center; gap: 6px">
362 Approvals{" "}
363 <input
364 type="number"
365 name="requiredApprovals"
366 min="0"
367 max="10"
368 value="1"
369 style="width: 60px"
370 />
371 </label>
372 <label style="display: flex; align-items: center; gap: 6px">
373 <input type="checkbox" name="allowForcePush" value="1" />
374 Allow force push
375 </label>
376 <label style="display: flex; align-items: center; gap: 6px">
377 <input type="checkbox" name="allowDeletion" value="1" />
378 Allow deletion
379 </label>
4626e61Claude380 <label
381 style="display: flex; align-items: center; gap: 6px"
382 title="K2 — Let the autopilot ticker auto-merge PRs that pass every gate this rule enforces."
383 >
384 <input type="checkbox" name="enableAutoMerge" value="1" />
385 Enable AI auto-merge
386 </label>
3ef4c9dClaude387 </div>
388 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
389 Add rule
390 </button>
391 </form>
392 </Layout>
393 );
394});
395
396gates.post("/:owner/:repo/gates/settings", requireAuth, async (c) => {
397 const user = c.get("user")!;
398 const { owner, repo } = c.req.param();
399 const repoRow = await loadRepo(owner, repo);
400 if (!repoRow) return c.notFound();
401 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
402
403 const body = await c.req.parseBody();
404 const b = (k: string) => body[k] === "1" || body[k] === "on";
405
406 try {
407 await db
408 .update(repoSettings)
409 .set({
410 gateTestEnabled: b("gateTestEnabled"),
411 aiReviewEnabled: b("aiReviewEnabled"),
412 secretScanEnabled: b("secretScanEnabled"),
413 securityScanEnabled: b("securityScanEnabled"),
414 dependencyScanEnabled: b("dependencyScanEnabled"),
415 lintEnabled: b("lintEnabled"),
416 typeCheckEnabled: b("typeCheckEnabled"),
417 testEnabled: b("testEnabled"),
418 autoFixEnabled: b("autoFixEnabled"),
419 autoMergeResolveEnabled: b("autoMergeResolveEnabled"),
420 autoFormatEnabled: b("autoFormatEnabled"),
421 aiCommitMessagesEnabled: b("aiCommitMessagesEnabled"),
422 aiPrSummaryEnabled: b("aiPrSummaryEnabled"),
423 aiChangelogEnabled: b("aiChangelogEnabled"),
424 autoDeployEnabled: b("autoDeployEnabled"),
425 deployRequireAllGreen: b("deployRequireAllGreen"),
426 updatedAt: new Date(),
427 })
428 .where(eq(repoSettings.repositoryId, repoRow.id));
429 } catch (err) {
430 console.error("[gates] settings save:", err);
431 }
432
433 await audit({
434 userId: user.id,
435 repositoryId: repoRow.id,
436 action: "gates.settings.update",
437 });
438
439 return c.redirect(
440 `/${owner}/${repo}/gates/settings?success=Settings+saved`
441 );
442});
443
444gates.post("/:owner/:repo/gates/protection", requireAuth, async (c) => {
445 const user = c.get("user")!;
446 const { owner, repo } = c.req.param();
447 const repoRow = await loadRepo(owner, repo);
448 if (!repoRow) return c.notFound();
449 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
450
451 const body = await c.req.parseBody();
452 const pattern = String(body.pattern || "").trim();
453 if (!pattern) return c.redirect(`/${owner}/${repo}/gates/settings`);
454 const b = (k: string) => body[k] === "1" || body[k] === "on";
455 const requiredApprovals = Math.max(
456 0,
457 Math.min(10, parseInt(String(body.requiredApprovals || "0"), 10) || 0)
458 );
459
460 try {
461 await db.insert(branchProtection).values({
462 repositoryId: repoRow.id,
463 pattern,
464 requirePullRequest: b("requirePullRequest"),
465 requireGreenGates: b("requireGreenGates"),
466 requireAiApproval: b("requireAiApproval"),
467 requireHumanReview: b("requireHumanReview"),
468 requiredApprovals,
469 allowForcePush: b("allowForcePush"),
470 allowDeletion: b("allowDeletion"),
4626e61Claude471 // K2 — opt-in flag for the autopilot auto-merger.
472 enableAutoMerge: b("enableAutoMerge"),
3ef4c9dClaude473 });
474 } catch (err) {
475 console.error("[gates] protection save:", err);
476 }
477
478 await audit({
479 userId: user.id,
480 repositoryId: repoRow.id,
481 action: "branch_protection.create",
482 metadata: { pattern },
483 });
484
485 return c.redirect(
486 `/${owner}/${repo}/gates/settings?success=Rule+added`
487 );
488});
489
490gates.post(
491 "/:owner/:repo/gates/protection/:id/delete",
492 requireAuth,
493 async (c) => {
494 const user = c.get("user")!;
495 const { owner, repo, id } = c.req.param();
496 const repoRow = await loadRepo(owner, repo);
497 if (!repoRow) return c.notFound();
498 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
499 await db
500 .delete(branchProtection)
501 .where(
502 and(
503 eq(branchProtection.id, id),
504 eq(branchProtection.repositoryId, repoRow.id)
505 )
506 );
507 await audit({
508 userId: user.id,
509 repositoryId: repoRow.id,
510 action: "branch_protection.delete",
511 targetId: id,
512 });
513 return c.redirect(`/${owner}/${repo}/gates/settings?success=Rule+removed`);
514 }
515);
516
517export default gates;