Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

repo-settings.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.

repo-settings.tsxBlame2099 lines · 1 contributor
79136bbClaude1/**
2 * Repository settings — description, visibility, default branch, danger zone.
3 */
4
5import { Hono } from "hono";
6import { eq, and } from "drizzle-orm";
7import { db } from "../db";
ec9e3e3Claude8import { repositories, users, repoTransfers, branchProtection } from "../db/schema";
79136bbClaude9import { Layout } from "../views/layout";
10import { RepoHeader } from "../views/components";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
febd4f0Claude13import { requireRepoAccess } from "../middleware/repo-access";
79136bbClaude14import { listBranches } from "../git/repository";
534f04aClaude15import { audit } from "../lib/notify";
79136bbClaude16import { rm } from "fs/promises";
58307aeClaude17import { EmptyState } from "../views/ui";
79136bbClaude18
19const repoSettings = new Hono<AuthEnv>();
20
21repoSettings.use("*", softAuth);
22
58307aeClaude23// Inline, scoped CSS — every class prefixed `.repo-settings-` so styles cannot
24// bleed into other surfaces. Pattern mirrors the user-settings polish
25// (commit 98eb360) and the admin-panel polish (commit 07f4b70).
26const repoSettingsStyles = `
eed4684Claude27 .repo-settings-container { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-3); }
58307aeClaude28
29 /* ─── Hero ─── */
30 .repo-settings-hero {
31 position: relative;
32 margin: var(--space-5) 0 var(--space-5);
33 padding: var(--space-5) var(--space-6);
34 background: var(--bg-elevated);
35 border: 1px solid var(--border);
36 border-radius: 16px;
37 overflow: hidden;
38 }
39 .repo-settings-hero::before {
40 content: '';
41 position: absolute;
42 top: 0; left: 0; right: 0;
43 height: 2px;
44 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
45 opacity: 0.7;
46 pointer-events: none;
47 }
48 .repo-settings-hero-bg {
49 position: absolute;
50 inset: -20% -10% auto auto;
51 width: 360px; height: 360px;
52 pointer-events: none;
53 z-index: 0;
54 }
55 .repo-settings-hero-orb {
56 position: absolute;
57 inset: 0;
58 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
59 filter: blur(80px);
60 opacity: 0.65;
61 animation: repoSettingsHeroOrb 14s ease-in-out infinite;
62 }
63 @keyframes repoSettingsHeroOrb {
64 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
65 50% { transform: scale(1.08) translate(-8px, 6px); opacity: 0.78; }
66 }
67 @media (prefers-reduced-motion: reduce) {
68 .repo-settings-hero-orb { animation: none; }
69 }
70 .repo-settings-hero-inner {
71 position: relative;
72 z-index: 1;
73 max-width: 640px;
74 }
75 .repo-settings-hero-eyebrow {
76 font-size: 13px;
77 color: var(--text-muted);
78 margin-bottom: var(--space-2);
79 letter-spacing: -0.005em;
80 }
81 .repo-settings-hero-eyebrow .repo-settings-hero-repo {
82 color: var(--accent);
83 font-weight: 600;
84 }
85 .repo-settings-hero-title {
86 font-size: clamp(28px, 4vw, 40px);
87 font-family: var(--font-display);
88 font-weight: 800;
89 letter-spacing: -0.028em;
90 line-height: 1.05;
91 margin: 0 0 var(--space-2);
92 color: var(--text-strong);
93 }
94 .repo-settings-hero-title .gradient-text {
95 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
96 -webkit-background-clip: text;
97 background-clip: text;
98 -webkit-text-fill-color: transparent;
99 color: transparent;
100 }
101 .repo-settings-hero-sub {
102 font-size: 15px;
103 color: var(--text-muted);
104 margin: 0;
105 line-height: 1.5;
106 }
107 .repo-settings-hero-link {
108 display: inline-flex;
109 align-items: center;
110 gap: 6px;
111 margin-top: var(--space-3);
112 font-size: 13.5px;
113 color: var(--text-link, var(--accent));
114 text-decoration: none;
115 font-weight: 500;
116 }
117 .repo-settings-hero-link:hover { text-decoration: underline; }
118 .repo-settings-hero-link .arrow { transition: transform 120ms ease; }
119 .repo-settings-hero-link:hover .arrow { transform: translateX(2px); }
120
121 /* ─── Banners (success / error) ─── */
122 .repo-settings-banner {
123 display: flex;
124 align-items: center;
125 gap: 10px;
126 padding: 12px 16px;
127 border-radius: 12px;
128 font-size: 13.5px;
129 margin-bottom: var(--space-4);
130 line-height: 1.5;
131 }
132 .repo-settings-banner-success {
133 background: rgba(52,211,153,0.08);
134 color: #6ee7b7;
135 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
136 }
137 .repo-settings-banner-error {
138 background: rgba(248,113,113,0.08);
139 color: #fca5a5;
140 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
141 }
142 .repo-settings-banner-icon {
143 width: 18px; height: 18px;
144 border-radius: 9999px;
145 flex-shrink: 0;
146 display: inline-flex;
147 align-items: center;
148 justify-content: center;
149 font-size: 12px;
150 font-weight: 700;
151 }
152 .repo-settings-banner-success .repo-settings-banner-icon {
153 background: rgba(52,211,153,0.18);
154 color: #34d399;
155 }
156 .repo-settings-banner-error .repo-settings-banner-icon {
157 background: rgba(248,113,113,0.18);
158 color: #f87171;
159 }
160
161 /* ─── Section cards ─── */
162 .repo-settings-section {
163 background: var(--bg-elevated);
164 border: 1px solid var(--border);
165 border-radius: 14px;
166 margin-bottom: var(--space-5);
167 overflow: hidden;
168 }
169 .repo-settings-section-head {
170 padding: var(--space-4) var(--space-5) var(--space-3);
171 border-bottom: 1px solid var(--border);
172 }
173 .repo-settings-section-eyebrow {
174 font-size: 11px;
175 font-weight: 600;
176 letter-spacing: 0.08em;
177 text-transform: uppercase;
178 color: var(--accent);
179 margin-bottom: 6px;
180 }
181 .repo-settings-section-title {
182 font-family: var(--font-display);
183 font-size: 18px;
184 font-weight: 700;
185 letter-spacing: -0.018em;
186 margin: 0 0 4px;
187 color: var(--text-strong);
188 }
189 .repo-settings-section-desc {
190 font-size: 13.5px;
191 color: var(--text-muted);
192 margin: 0;
193 line-height: 1.5;
194 }
195 .repo-settings-section-body { padding: var(--space-4) var(--space-5); }
196 .repo-settings-section-foot {
197 padding: var(--space-3) var(--space-5);
198 border-top: 1px solid var(--border);
199 background: rgba(255,255,255,0.012);
200 display: flex;
201 justify-content: flex-end;
202 gap: var(--space-2);
203 align-items: center;
204 flex-wrap: wrap;
205 }
206 .repo-settings-section-foot .repo-settings-foot-hint {
207 margin-right: auto;
208 font-size: 12.5px;
209 color: var(--text-muted);
210 }
211
212 /* ─── Form rows ─── */
213 .repo-settings-field { margin-bottom: var(--space-4); }
214 .repo-settings-field:last-child { margin-bottom: 0; }
215 .repo-settings-field-label {
216 display: block;
217 font-size: 13px;
218 font-weight: 600;
219 color: var(--text-strong);
220 margin-bottom: 6px;
221 letter-spacing: -0.005em;
222 }
223 .repo-settings-field-hint {
224 font-size: 12.5px;
225 color: var(--text-muted);
226 margin-top: 6px;
227 line-height: 1.45;
228 }
229 .repo-settings-input,
230 .repo-settings-select {
231 width: 100%;
232 padding: 9px 12px;
233 font-size: 14px;
234 color: var(--text);
235 background: var(--bg);
236 border: 1px solid var(--border-strong, var(--border));
237 border-radius: 8px;
238 outline: none;
239 transition: border-color 120ms ease, box-shadow 120ms ease;
240 font-family: var(--font-sans);
241 }
242 .repo-settings-input:focus,
243 .repo-settings-select:focus {
244 border-color: var(--border-focus, var(--accent));
245 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
246 }
247
248 /* ─── Visibility radio cards ─── */
249 .repo-settings-visibility {
250 display: grid;
251 grid-template-columns: 1fr 1fr;
252 gap: var(--space-2);
253 }
254 @media (max-width: 600px) {
255 .repo-settings-visibility { grid-template-columns: 1fr; }
256 }
257 .repo-settings-vis-card {
258 display: flex;
259 gap: 10px;
260 padding: 14px;
261 background: var(--bg);
262 border: 1px solid var(--border);
263 border-radius: 12px;
264 cursor: pointer;
265 transition: border-color 120ms ease, background 120ms ease;
266 }
267 .repo-settings-vis-card:hover { border-color: var(--border-strong, var(--border)); }
268 .repo-settings-vis-card:has(input:checked) {
269 border-color: rgba(140,109,255,0.55);
270 background: rgba(140,109,255,0.06);
271 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
272 }
273 .repo-settings-vis-radio {
274 margin-top: 3px;
275 accent-color: var(--accent);
276 }
277 .repo-settings-vis-body { display: flex; flex-direction: column; gap: 4px; }
278 .repo-settings-vis-label {
279 font-size: 14px;
280 font-weight: 600;
281 color: var(--text-strong);
282 }
283 .repo-settings-vis-desc {
284 font-size: 12.5px;
285 color: var(--text-muted);
286 line-height: 1.4;
287 }
288
289 /* ─── Toggle rows (stale-sweep) ─── */
290 .repo-settings-toggle-row {
291 display: flex;
292 align-items: flex-start;
293 gap: var(--space-3);
294 padding: 12px 14px;
295 border-radius: 10px;
296 border: 1px solid var(--border-subtle, var(--border));
297 background: var(--bg-secondary, var(--bg));
298 margin-bottom: 8px;
299 transition: border-color 120ms ease, background 120ms ease;
300 cursor: pointer;
301 }
302 .repo-settings-toggle-row:last-of-type { margin-bottom: 0; }
303 .repo-settings-toggle-row:hover {
304 border-color: var(--border);
305 background: rgba(255,255,255,0.02);
306 }
307 .repo-settings-toggle-row:has(input:checked) {
308 border-color: rgba(140,109,255,0.45);
309 background: rgba(140,109,255,0.05);
310 }
311 .repo-settings-toggle-row input[type="checkbox"] {
312 margin-top: 2px;
313 flex-shrink: 0;
314 width: 16px; height: 16px;
315 accent-color: var(--accent);
316 cursor: pointer;
317 }
318 .repo-settings-toggle-text {
319 flex: 1;
320 font-size: 14px;
321 color: var(--text);
322 line-height: 1.45;
323 }
324 .repo-settings-toggle-text-title {
325 display: block;
326 font-weight: 600;
327 color: var(--text-strong);
328 margin-bottom: 2px;
329 }
330 .repo-settings-toggle-text-hint {
331 display: block;
332 margin-top: 3px;
333 font-size: 12.5px;
334 color: var(--text-muted);
335 }
336
337 /* ─── Primary CTA ─── */
338 .repo-settings-cta {
339 display: inline-flex;
340 align-items: center;
341 gap: 6px;
342 padding: 9px 16px;
343 font-size: 14px;
344 font-weight: 600;
345 color: white;
346 background: linear-gradient(135deg, #8c6dff 0%, #5b7bff 100%);
347 border: none;
348 border-radius: 8px;
349 cursor: pointer;
350 transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
351 text-decoration: none;
352 font-family: inherit;
353 }
354 .repo-settings-cta:hover {
355 filter: brightness(1.08);
356 box-shadow: 0 6px 18px rgba(140,109,255,0.25);
357 transform: translateY(-1px);
358 }
359 .repo-settings-cta .arrow { transition: transform 120ms ease; }
360 .repo-settings-cta:hover .arrow { transform: translateX(2px); }
361 .repo-settings-cta-secondary {
362 display: inline-flex;
363 align-items: center;
364 gap: 6px;
365 padding: 8px 14px;
366 font-size: 13.5px;
367 font-weight: 500;
368 color: var(--text);
369 background: var(--bg);
370 border: 1px solid var(--border-strong, var(--border));
371 border-radius: 8px;
372 cursor: pointer;
373 text-decoration: none;
374 transition: border-color 120ms ease, background 120ms ease;
375 font-family: inherit;
376 }
377 .repo-settings-cta-secondary:hover {
378 border-color: var(--accent);
379 background: rgba(140,109,255,0.06);
380 }
381
382 /* ─── Inline transfer row ─── */
383 .repo-settings-inline-row {
384 display: flex;
385 gap: var(--space-2);
386 align-items: stretch;
387 flex-wrap: wrap;
388 }
389 .repo-settings-inline-row .repo-settings-input { flex: 1; min-width: 220px; }
390
391 /* ─── Status pill (template / archived) ─── */
392 .repo-settings-pill {
393 display: inline-flex;
394 align-items: center;
395 gap: 6px;
396 padding: 4px 10px;
397 border-radius: 9999px;
398 font-size: 11.5px;
399 font-weight: 600;
400 letter-spacing: 0.01em;
401 margin-bottom: var(--space-2);
402 }
403 .repo-settings-pill.is-on {
404 background: rgba(140,109,255,0.14);
405 color: #b69dff;
406 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
407 }
408 .repo-settings-pill.is-off {
409 background: rgba(255,255,255,0.04);
410 color: var(--text-muted);
411 box-shadow: inset 0 0 0 1px var(--border);
412 }
413 .repo-settings-pill .dot {
414 width: 6px; height: 6px;
415 border-radius: 9999px;
416 background: currentColor;
417 box-shadow: 0 0 8px currentColor;
418 }
419 .repo-settings-pill.is-off .dot { box-shadow: none; opacity: 0.6; }
420
421 /* ─── Danger zone ─── */
422 .repo-settings-danger {
423 position: relative;
424 margin-top: var(--space-6);
425 padding: 0;
426 border: 1px solid rgba(248,113,113,0.30);
427 border-radius: 14px;
428 background:
429 linear-gradient(180deg, rgba(248,113,113,0.05) 0%, rgba(248,113,113,0.02) 100%),
430 var(--bg-elevated);
431 overflow: hidden;
432 }
433 .repo-settings-danger::before {
434 content: '';
435 position: absolute;
436 top: 0; left: 0; right: 0;
437 height: 2px;
438 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #ffb45e 70%, transparent 100%);
439 opacity: 0.7;
440 pointer-events: none;
441 }
442 .repo-settings-danger-head {
443 padding: var(--space-4) var(--space-5) var(--space-3);
444 border-bottom: 1px solid rgba(248,113,113,0.15);
445 }
446 .repo-settings-danger-eyebrow {
447 font-size: 11px;
448 font-weight: 600;
449 letter-spacing: 0.08em;
450 text-transform: uppercase;
451 color: #f87171;
452 margin-bottom: 6px;
453 }
454 .repo-settings-danger-title {
455 font-family: var(--font-display);
456 font-size: 18px;
457 font-weight: 700;
458 letter-spacing: -0.018em;
459 margin: 0 0 4px;
460 color: var(--text-strong);
461 }
462 .repo-settings-danger-desc {
463 font-size: 13.5px;
464 color: var(--text-muted);
465 margin: 0;
466 line-height: 1.5;
467 }
468 .repo-settings-danger-body { padding: var(--space-4) var(--space-5); }
469 .repo-settings-danger-body p { margin: 0 0 var(--space-3); font-size: 14px; line-height: 1.55; }
470 .repo-settings-danger-body p:last-child { margin-bottom: 0; }
471 .repo-settings-danger-body p.muted { color: var(--text-muted); font-size: 13px; }
472 .repo-settings-danger-btn {
473 display: inline-flex;
474 align-items: center;
475 gap: 6px;
476 padding: 9px 16px;
477 font-size: 14px;
478 font-weight: 600;
479 color: #fff;
480 background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
481 border: none;
482 border-radius: 8px;
483 cursor: pointer;
484 font-family: inherit;
485 transition: filter 120ms ease, box-shadow 120ms ease, transform 120ms ease;
486 }
487 .repo-settings-danger-btn:hover {
488 filter: brightness(1.08);
489 box-shadow: 0 6px 18px rgba(248,113,113,0.25);
490 transform: translateY(-1px);
491 }
492
493 /* ─── Responsive ─── */
494 @media (max-width: 720px) {
495 .repo-settings-hero { padding: var(--space-4) var(--space-4); }
496 .repo-settings-section-head,
497 .repo-settings-section-body,
498 .repo-settings-section-foot,
499 .repo-settings-danger-head,
500 .repo-settings-danger-body { padding-left: var(--space-4); padding-right: var(--space-4); }
501 }
2289dc6Claude502
503 /* ─── Settings sidebar layout ─── */
504 .rsettings-layout {
505 display: flex;
506 gap: var(--space-5);
507 align-items: flex-start;
508 }
509 .rsettings-sidebar {
510 width: 220px;
511 flex-shrink: 0;
512 position: sticky;
513 top: var(--space-4);
514 background: var(--bg-elevated);
515 border: 1px solid var(--border);
516 border-radius: 12px;
517 overflow: hidden;
518 }
519 .rsettings-sidebar-label {
520 padding: 10px 14px 8px;
521 font-size: 11px;
522 font-weight: 700;
523 letter-spacing: 0.07em;
524 text-transform: uppercase;
525 color: var(--text-subtle, var(--text-muted));
526 border-bottom: 1px solid var(--border);
527 }
528 .rsettings-nav {
529 display: flex;
530 flex-direction: column;
531 padding: 6px;
532 gap: 2px;
533 }
534 .rsettings-nav-item {
535 display: flex;
536 align-items: center;
537 gap: 8px;
538 padding: 7px 10px;
539 border-radius: 8px;
540 font-size: 13.5px;
541 font-weight: 500;
542 color: var(--text-muted);
543 text-decoration: none;
544 transition: background 100ms ease, color 100ms ease;
545 line-height: 1.3;
546 }
547 .rsettings-nav-item:hover {
548 background: var(--bg-surface, rgba(255,255,255,0.04));
549 color: var(--text);
550 }
551 .rsettings-nav-item.is-active {
552 background: var(--bg-surface, rgba(255,255,255,0.06));
553 color: var(--text);
554 font-weight: 600;
555 }
556 .rsettings-nav-divider {
557 height: 1px;
558 background: var(--border);
559 margin: 4px 6px;
560 }
561 .rsettings-content {
562 flex: 1;
563 min-width: 0;
564 }
565 @media (max-width: 860px) {
566 .rsettings-layout { flex-direction: column; }
567 .rsettings-sidebar {
568 width: 100%;
569 position: static;
570 }
571 .rsettings-nav { flex-direction: row; flex-wrap: wrap; }
572 }
58307aeClaude573`;
574
575/** Hero header for the repo settings page. */
576function RepoSettingsHero(props: { owner: string; repo: string }) {
577 const { owner, repo } = props;
578 return (
579 <div class="repo-settings-hero">
580 <div class="repo-settings-hero-bg" aria-hidden="true">
581 <div class="repo-settings-hero-orb" />
582 </div>
583 <div class="repo-settings-hero-inner">
584 <div class="repo-settings-hero-eyebrow">
585 Repository settings ·{" "}
586 <span class="repo-settings-hero-repo">
587 {owner}/{repo}
588 </span>
589 </div>
590 <h1 class="repo-settings-hero-title">
591 <span class="gradient-text">Configure</span>.
592 </h1>
593 <p class="repo-settings-hero-sub">
594 Description, visibility, branches, automation. Owners only.
595 </p>
596 <a
597 href={`/${owner}/${repo}/settings/collaborators`}
598 class="repo-settings-hero-link"
599 >
600 Manage collaborators <span class="arrow">→</span>
601 </a>
602 </div>
603 </div>
604 );
605}
606
607function Banner(props: { kind: "success" | "error"; text: string }) {
608 return (
609 <div
610 class={`repo-settings-banner repo-settings-banner-${props.kind}`}
611 role="status"
612 >
613 <span class="repo-settings-banner-icon" aria-hidden="true">
614 {props.kind === "success" ? "✓" : "!"}
615 </span>
616 <span>{props.text}</span>
617 </div>
618 );
619}
620
2289dc6Claude621/** Left-hand settings navigation sidebar. */
622function RepoSettingsSidebar(props: { owner: string; repo: string; currentPath: string }) {
623 const { owner, repo, currentPath } = props;
624 const base = `/${owner}/${repo}`;
625
626 type NavItem =
627 | { kind: "link"; label: string; href: string }
628 | { kind: "divider" };
629
630 const items: NavItem[] = [
631 { kind: "link", label: "General", href: `${base}/settings` },
632 { kind: "link", label: "Collaborators", href: `${base}/settings/collaborators` },
633 { kind: "link", label: "Automation", href: `${base}/settings/automation` },
634 { kind: "divider" },
635 { kind: "link", label: "Branch Protection", href: `${base}/gates` },
636 { kind: "link", label: "Protected Tags", href: `${base}/settings/protected-tags` },
637 { kind: "link", label: "Rulesets", href: `${base}/settings/rulesets` },
638 { kind: "divider" },
639 { kind: "link", label: "Webhooks", href: `${base}/settings/webhooks` },
640 { kind: "link", label: "Environments", href: `${base}/settings/environments` },
641 { kind: "link", label: "Pages", href: `${base}/settings/pages` },
642 { kind: "link", label: "Workflow Secrets", href: `${base}/settings/secrets` },
643 { kind: "divider" },
644 { kind: "link", label: "Mirrors", href: `${base}/settings/mirror` },
645 { kind: "link", label: "Dep Updater", href: `${base}/settings/dep-updater` },
646 { kind: "link", label: "Audit Log", href: `${base}/settings/audit` },
647 ];
648
649 return (
650 <nav class="rsettings-sidebar" aria-label="Settings navigation">
651 <div class="rsettings-sidebar-label">Settings</div>
652 <ul class="rsettings-nav" role="list" style="list-style:none;margin:0;padding:6px;">
653 {items.map((item) => {
654 if (item.kind === "divider") {
655 return <li class="rsettings-nav-divider" role="separator" />;
656 }
657 // Exact match for "General" (base settings URL), prefix match for sub-pages
658 const isActive =
659 item.href === `${base}/settings`
660 ? currentPath === `${base}/settings` || currentPath === `${base}/settings/`
661 : currentPath === item.href || currentPath.startsWith(item.href + "/");
662 return (
663 <li>
664 <a
665 href={item.href}
666 class={`rsettings-nav-item${isActive ? " is-active" : ""}`}
667 aria-current={isActive ? "page" : undefined}
668 >
669 {item.label}
670 </a>
671 </li>
672 );
673 })}
674 </ul>
675 </nav>
676 );
677}
678
79136bbClaude679// Settings page
febd4f0Claude680repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude681 const { owner: ownerName, repo: repoName } = c.req.param();
682 const user = c.get("user")!;
683 const success = c.req.query("success");
684 const error = c.req.query("error");
685
686 const [owner] = await db
687 .select()
688 .from(users)
689 .where(eq(users.username, ownerName))
690 .limit(1);
691
692 if (!owner || owner.id !== user.id) {
693 return c.html(
694 <Layout title="Unauthorized" user={user}>
bb0f894Claude695 <EmptyState title="Unauthorized">
79136bbClaude696 <p>Only the repository owner can access settings.</p>
bb0f894Claude697 </EmptyState>
79136bbClaude698 </Layout>,
699 403
700 );
701 }
702
703 const [repo] = await db
704 .select()
705 .from(repositories)
706 .where(
707 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
708 )
709 .limit(1);
710
711 if (!repo) return c.notFound();
712
713 const branches = await listBranches(ownerName, repoName);
714
ec9e3e3Claude715 // Branch protection rules for the "Branch protection" settings section.
716 const existingBranchRules = await db
717 .select()
718 .from(branchProtection)
719 .where(eq(branchProtection.repositoryId, repo.id))
720 .catch(() => [] as typeof branchProtection.$inferSelect[]);
721
79136bbClaude722 return c.html(
723 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
724 <RepoHeader owner={ownerName} repo={repoName} />
58307aeClaude725 <style dangerouslySetInnerHTML={{ __html: repoSettingsStyles }} />
726 <div class="repo-settings-container">
727 <RepoSettingsHero owner={ownerName} repo={repoName} />
2289dc6Claude728 <div class="rsettings-layout">
729 <RepoSettingsSidebar
730 owner={ownerName}
731 repo={repoName}
732 currentPath={`/${ownerName}/${repoName}/settings`}
733 />
734 <div class="rsettings-content">
58307aeClaude735
79136bbClaude736 {success && (
58307aeClaude737 <Banner kind="success" text={decodeURIComponent(success)} />
79136bbClaude738 )}
58307aeClaude739 {error && <Banner kind="error" text={decodeURIComponent(error)} />}
79136bbClaude740
58307aeClaude741 {/* ─── General: description + default branch + visibility ─── */}
742 <section class="repo-settings-section">
743 <div class="repo-settings-section-head">
744 <div class="repo-settings-section-eyebrow">General</div>
745 <h2 class="repo-settings-section-title">Repository basics</h2>
746 <p class="repo-settings-section-desc">
747 The headline details visitors see — description, the default
748 branch they land on, and who can browse the code.
749 </p>
750 </div>
751 <form
752 method="post"
753 action={`/${ownerName}/${repoName}/settings`}
754 >
755 <div class="repo-settings-section-body">
756 <div class="repo-settings-field">
757 <label class="repo-settings-field-label" for="description">
758 Description
759 </label>
79136bbClaude760 <input
58307aeClaude761 class="repo-settings-input"
762 name="description"
763 id="description"
764 value={repo.description || ""}
765 placeholder="A short description"
79136bbClaude766 />
58307aeClaude767 <div class="repo-settings-field-hint">
768 Shown on the repo home, search results, and explore pages.
769 </div>
770 </div>
771 <div class="repo-settings-field">
772 <label
773 class="repo-settings-field-label"
774 for="default_branch"
775 >
776 Default branch
777 </label>
778 <select
779 class="repo-settings-select"
780 name="default_branch"
781 id="default_branch"
782 >
783 {branches.length === 0 ? (
784 <option value={repo.defaultBranch}>
785 {repo.defaultBranch}
786 </option>
787 ) : (
788 branches.map((b) => (
789 <option value={b} selected={b === repo.defaultBranch}>
790 {b}
791 </option>
792 ))
793 )}
794 </select>
795 <div class="repo-settings-field-hint">
796 Where pulls land, where compare views start, and what
797 clones check out by default.
798 </div>
799 </div>
800 <div class="repo-settings-field">
801 <label class="repo-settings-field-label">Visibility</label>
802 <div class="repo-settings-visibility">
803 <label class="repo-settings-vis-card">
804 <input
805 type="radio"
806 name="visibility"
807 value="public"
808 checked={!repo.isPrivate}
809 class="repo-settings-vis-radio"
810 aria-label="Public"
811 />
812 <span class="repo-settings-vis-body">
813 <span class="repo-settings-vis-label">Public</span>
814 <span class="repo-settings-vis-desc">
815 Anyone can see this repository. You choose who can
816 commit.
817 </span>
818 </span>
819 </label>
820 <label class="repo-settings-vis-card">
821 <input
822 type="radio"
823 name="visibility"
824 value="private"
825 checked={repo.isPrivate}
826 class="repo-settings-vis-radio"
827 aria-label="Private"
828 />
829 <span class="repo-settings-vis-body">
830 <span class="repo-settings-vis-label">Private</span>
831 <span class="repo-settings-vis-desc">
832 Only you (and collaborators you invite) can see this
833 repository.
834 </span>
835 </span>
836 </label>
837 </div>
838 </div>
44f1a02Claude839 <div class="repo-settings-field">
840 <label class="repo-settings-field-label">Data region</label>
841 <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
842 <span
843 style={`display:inline-flex; align-items:center; gap:6px; padding:4px 12px; border-radius:9999px; font-size:12px; font-weight:600; font-family:var(--font-mono); ${
844 repo.dataRegion === "eu"
845 ? "background:rgba(54,197,214,0.10); color:#67e8f9; border:1px solid rgba(54,197,214,0.28);"
846 : "background:rgba(140,109,255,0.10); color:#c4b5fd; border:1px solid rgba(140,109,255,0.28);"
847 }`}
848 >
849 {repo.dataRegion === "eu" ? "EU · Frankfurt" : "US · Default"}
850 </span>
851 <span class="repo-settings-field-hint" style="margin:0;">
852 Data region is set at creation and cannot be changed. EU data
853 residency requires a{" "}
854 <a href="/pricing" style="color:var(--accent);text-decoration:none;">Pro plan or higher</a>.
855 </span>
856 </div>
857 </div>
79136bbClaude858 </div>
58307aeClaude859 <div class="repo-settings-section-foot">
860 <button type="submit" class="repo-settings-cta">
861 Save changes <span class="arrow">→</span>
862 </button>
863 </div>
864 </form>
865 </section>
14c3cc8Claude866
58307aeClaude867 {/* ─── Spec to PR ─── */}
868 <section class="repo-settings-section">
869 <div class="repo-settings-section-head">
870 <div class="repo-settings-section-eyebrow">
871 Automation · experimental
872 </div>
873 <h2 class="repo-settings-section-title">Spec to PR</h2>
874 <p class="repo-settings-section-desc">
875 Paste a plain-English feature spec and let Claude draft a pull
876 request for you. PRs open as drafts — review every line before
877 merging.
878 </p>
879 </div>
880 <div class="repo-settings-section-body">
881 <a
882 href={`/${ownerName}/${repoName}/spec`}
883 class="repo-settings-cta"
884 >
885 Open Spec to PR <span class="arrow">→</span>
886 </a>
887 </div>
888 </section>
889
890 {/* ─── Template repository ─── */}
891 <section class="repo-settings-section">
892 <div class="repo-settings-section-head">
893 <div class="repo-settings-section-eyebrow">Template</div>
894 <h2 class="repo-settings-section-title">Template repository</h2>
895 <p class="repo-settings-section-desc">
71cd5ecClaude896 {repo.isTemplate
58307aeClaude897 ? "This repository is a template. Users can click “Use this template” to create a new repository with the same files."
898 : "Mark this repository as a template so others can seed new repositories from its files."}
899 </p>
900 </div>
901 <div class="repo-settings-section-body">
902 <span
903 class={`repo-settings-pill ${repo.isTemplate ? "is-on" : "is-off"}`}
904 >
905 <span class="dot" aria-hidden="true" />
906 {repo.isTemplate ? "Template enabled" : "Not a template"}
907 </span>
908 <form
909 method="post"
910 action={`/${ownerName}/${repoName}/settings/template`}
911 style="margin-top: var(--space-3)"
912 >
913 <input
914 type="hidden"
915 name="template"
916 value={repo.isTemplate ? "0" : "1"}
917 />
918 <button type="submit" class="repo-settings-cta-secondary">
919 {repo.isTemplate ? "Unmark as template" : "Mark as template"}
920 </button>
921 </form>
922 </div>
923 </section>
71cd5ecClaude924
58307aeClaude925 {/* ─── Transfer ownership ─── */}
926 <section class="repo-settings-section">
927 <div class="repo-settings-section-head">
928 <div class="repo-settings-section-eyebrow">Ownership</div>
929 <h2 class="repo-settings-section-title">Transfer ownership</h2>
930 <p class="repo-settings-section-desc">
931 Hand this repository to another user. The new owner can accept
932 or decline the transfer by viewing it.
933 </p>
934 </div>
71cd5ecClaude935 <form
e7e240eClaude936 method="post"
71cd5ecClaude937 action={`/${ownerName}/${repoName}/settings/transfer`}
938 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
939 >
58307aeClaude940 <div class="repo-settings-section-body">
941 <div class="repo-settings-field">
942 <label class="repo-settings-field-label" for="new_owner">
943 New owner username
944 </label>
945 <div class="repo-settings-inline-row">
946 <input
947 type="text"
948 name="new_owner"
949 id="new_owner"
950 class="repo-settings-input"
951 placeholder="new-owner-username"
952 required
953 aria-label="New owner username"
954 />
955 <button type="submit" class="repo-settings-cta-secondary">
956 Transfer
957 </button>
958 </div>
959 <div class="repo-settings-field-hint">
960 Transfers are immediate. The new owner can rename or delete
961 the repository at any time.
962 </div>
963 </div>
964 </div>
71cd5ecClaude965 </form>
58307aeClaude966 </section>
71cd5ecClaude967
58307aeClaude968 {/* ─── Archive ─── */}
969 <section class="repo-settings-section">
970 <div class="repo-settings-section-head">
971 <div class="repo-settings-section-eyebrow">Lifecycle</div>
972 <h2 class="repo-settings-section-title">
973 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
974 </h2>
975 <p class="repo-settings-section-desc">
976 {repo.isArchived
977 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
978 : "Mark this repository as archived. It becomes read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
979 </p>
980 </div>
981 <div class="repo-settings-section-body">
982 <span
983 class={`repo-settings-pill ${repo.isArchived ? "is-on" : "is-off"}`}
534f04aClaude984 >
58307aeClaude985 <span class="dot" aria-hidden="true" />
986 {repo.isArchived ? "Archived" : "Active"}
987 </span>
988 <form
989 method="post"
990 action={`/${ownerName}/${repoName}/settings/archive`}
991 style="margin-top: var(--space-3)"
534f04aClaude992 >
993 <input
58307aeClaude994 type="hidden"
995 name="archive"
996 value={repo.isArchived ? "0" : "1"}
534f04aClaude997 />
58307aeClaude998 <button type="submit" class="repo-settings-cta-secondary">
999 {repo.isArchived ? "Unarchive" : "Archive"} this repository
1000 </button>
1001 </form>
1002 </div>
1003 </section>
534f04aClaude1004
1d4ff60Claude1005 {/* ─── AI test generator ─── */}
1006 <section class="repo-settings-section">
1007 <div class="repo-settings-section-head">
1008 <div class="repo-settings-section-eyebrow">AI tests</div>
1009 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
1010 <p class="repo-settings-section-desc">
1011 When a pull request opens, Gluecron AI reads the diff and writes
1012 tests for the new code, matching whatever framework your repo
1013 already uses. Tests land on the same branch. Default off —
1014 opt in here.
1015 </p>
1016 </div>
1017 <form
1018 method="post"
1019 action={`/${ownerName}/${repoName}/settings/ai-tests`}
1020 >
1021 <div class="repo-settings-section-body">
1022 <label
1023 class="repo-settings-toggle-row"
1024 aria-label="Auto-generate tests when a PR opens"
1025 >
1026 <input
1027 type="checkbox"
1028 name="auto_generate_tests"
1029 value="1"
1030 checked={repo.autoGenerateTests}
1031 />
1032 <span class="repo-settings-toggle-text">
1033 <span class="repo-settings-toggle-text-title">
1034 Auto-generate tests on PR open
1035 </span>
1036 <span class="repo-settings-toggle-text-hint">
1037 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
1038 source-file changes and PRs already opened by AI.
1039 </span>
1040 </span>
1041 </label>
1042 </div>
1043 <div class="repo-settings-section-foot">
1044 <button type="submit" class="repo-settings-cta">
1045 Save AI test settings <span class="arrow">→</span>
1046 </button>
1047 </div>
1048 </form>
1049 </section>
1050
f5ad215Claude1051 {/* ─── Dependency auto-updater ─── */}
1052 <section
1053 id="dep-updater"
1054 class="repo-settings-section"
1055 >
1056 <div class="repo-settings-section-head">
1057 <div class="repo-settings-section-eyebrow">AI dependency updates</div>
1058 <h2 class="repo-settings-section-title">Automatic dependency updates</h2>
1059 <p class="repo-settings-section-desc">
1060 Once per day, Gluecron reads your <code>package.json</code>,
1061 checks npm for patch and minor updates, and applies them
1062 automatically. If the gate check passes, the PR is auto-merged.
1063 If it fails, Gluecron opens a PR with an AI-written guide
1064 explaining what broke and how to fix it. Major updates always
1065 require human review and are handled separately by the migration
1066 watcher. Default off.
1067 </p>
1068 </div>
1069 <form
1070 method="post"
1071 action={`/${ownerName}/${repoName}/settings/dep-updater`}
1072 >
1073 <div class="repo-settings-section-body">
1074 <label
1075 class="repo-settings-toggle-row"
1076 aria-label="Enable automatic dependency updates for this repo"
1077 >
1078 <input
1079 type="checkbox"
1080 name="dep_updater_enabled"
1081 value="1"
1082 checked={
1083 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ??
1084 false
1085 }
1086 />
1087 <span class="repo-settings-toggle-text">
1088 <span class="repo-settings-toggle-text-title">
1089 Enable automatic dependency updates
1090 </span>
1091 <span class="repo-settings-toggle-text-hint">
1092 Patch and minor updates only. Runs once per day; max 2
1093 packages per sweep. Requires <code>DEP_UPDATER_ENABLED=1</code>{" "}
1094 on the server. Auto-merges when gate passes; opens a PR
1095 with an AI migration guide when it fails.
1096 </span>
1097 </span>
1098 </label>
1099 </div>
1100 <div class="repo-settings-section-foot">
1101 <button type="submit" class="repo-settings-cta">
1102 Save dependency update settings <span class="arrow">→</span>
1103 </button>
1104 </div>
1105 </form>
1106 </section>
1107
9b3a183Claude1108 {/* ─── Cloud dev environments ─── */}
1109 <section
1110 id="dev-envs"
1111 class="repo-settings-section"
1112 >
1113 <div class="repo-settings-section-head">
1114 <div class="repo-settings-section-eyebrow">Dev environments</div>
1115 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
1116 <p class="repo-settings-section-desc">
1117 When enabled, anyone with read access can hit{" "}
1118 <code>/{ownerName}/{repoName}/dev</code> to spin up a
1119 hosted VS Code IDE in the browser, backed by a cold-start
1120 container. We read <code>.gluecron/dev.yml</code> for the
1121 image + install commands; idle envs stop themselves after
1122 30 minutes. Default off — each env burns a container.
1123 </p>
1124 </div>
1125 <form
1126 method="post"
1127 action={`/${ownerName}/${repoName}/settings/dev-envs`}
1128 >
1129 <div class="repo-settings-section-body">
1130 <label
1131 class="repo-settings-toggle-row"
1132 aria-label="Enable cloud dev environments for this repo"
1133 >
1134 <input
1135 type="checkbox"
1136 name="dev_envs_enabled"
1137 value="1"
1138 checked={
1139 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
1140 false
1141 }
1142 />
1143 <span class="repo-settings-toggle-text">
1144 <span class="repo-settings-toggle-text-title">
1145 Enable dev environments
1146 </span>
1147 <span class="repo-settings-toggle-text-hint">
1148 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
1149 route. Commit <code>.gluecron/dev.yml</code> to your
1150 repo to customise the image, ports, and extensions.
1151 </span>
1152 </span>
1153 </label>
1154 </div>
1155 <div class="repo-settings-section-foot">
1156 <button type="submit" class="repo-settings-cta">
1157 Save dev env settings <span class="arrow">→</span>
1158 </button>
1159 </div>
1160 </form>
1161 </section>
1162
58307aeClaude1163 {/* ─── Stale activity ─── */}
1164 <section class="repo-settings-section">
1165 <div class="repo-settings-section-head">
1166 <div class="repo-settings-section-eyebrow">Stale sweep</div>
1167 <h2 class="repo-settings-section-title">Stale activity</h2>
1168 <p class="repo-settings-section-desc">
1169 Autopilot pokes PRs and issues that have gone quiet, then offers
1170 a one-click close path. Each toggle controls the final close
1171 step — pokes always happen, but they&apos;re harmless reminders.
1172 </p>
1173 </div>
79136bbClaude1174 <form
e7e240eClaude1175 method="post"
58307aeClaude1176 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude1177 >
58307aeClaude1178 <div class="repo-settings-section-body">
1179 <label
1180 class="repo-settings-toggle-row"
1181 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
1182 >
1183 <input
1184 type="checkbox"
1185 name="auto_close_stale_prs"
1186 value="1"
1187 checked={repo.autoCloseStalePrs}
1188 />
1189 <span class="repo-settings-toggle-text">
1190 <span class="repo-settings-toggle-text-title">
1191 Auto-close stale PRs
1192 </span>
1193 <span class="repo-settings-toggle-text-hint">
1194 Close PRs that go quiet for 14 days after autopilot pokes
1195 them.
1196 </span>
1197 </span>
1198 </label>
1199 <label
1200 class="repo-settings-toggle-row"
1201 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
1202 >
1203 <input
1204 type="checkbox"
1205 name="auto_close_stale_issues"
1206 value="1"
1207 checked={repo.autoCloseStaleIssues}
1208 />
1209 <span class="repo-settings-toggle-text">
1210 <span class="repo-settings-toggle-text-title">
1211 Auto-close stale issues
1212 </span>
1213 <span class="repo-settings-toggle-text-hint">
1214 Close issues that go quiet for 60 days after autopilot
1215 pokes them.
1216 </span>
1217 </span>
1218 </label>
1219 </div>
1220 <div class="repo-settings-section-foot">
1221 <button type="submit" class="repo-settings-cta">
1222 Save stale settings <span class="arrow">→</span>
1223 </button>
1224 </div>
79136bbClaude1225 </form>
58307aeClaude1226 </section>
1227
1df50d5Claude1228 {/* ─── PR preview builds (migration 0077) ─── */}
1229 <section class="repo-settings-section">
1230 <div class="repo-settings-section-head">
1231 <div class="repo-settings-section-eyebrow">Preview builds</div>
1232 <h2 class="repo-settings-section-title">PR preview environments</h2>
1233 <p class="repo-settings-section-desc">
1234 When a build command is set, every PR push automatically clones
1235 the branch, runs the command, and serves the built output from a
1236 unique preview URL — like Vercel, but native to Gluecron. Leave
1237 blank to use URL-only previews (no build runs).
1238 </p>
1239 </div>
1240 <form
1241 method="post"
1242 action={`/${ownerName}/${repoName}/settings/previews`}
1243 >
1244 <div class="repo-settings-section-body">
1245 <div class="repo-settings-field">
1246 <label class="repo-settings-field-label" for="preview_build_command">
1247 Build command
1248 </label>
1249 <input
1250 id="preview_build_command"
1251 class="repo-settings-input"
1252 type="text"
1253 name="preview_build_command"
1254 placeholder="npm run build"
1255 value={
1256 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1257 }
1258 />
1259 <p class="repo-settings-field-hint">
1260 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1261 <code>hugo</code>. Runs inside the cloned branch directory.
1262 </p>
1263 </div>
1264 <div class="repo-settings-field">
1265 <label class="repo-settings-field-label" for="preview_output_dir">
1266 Output directory
1267 </label>
1268 <input
1269 id="preview_output_dir"
1270 class="repo-settings-input"
1271 type="text"
1272 name="preview_output_dir"
1273 placeholder="dist"
1274 value={
1275 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1276 }
1277 />
1278 <p class="repo-settings-field-hint">
1279 The directory your build writes to. Common values:{" "}
1280 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1281 <code>build</code>. Served from{" "}
1282 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1283 </p>
1284 </div>
1285 </div>
1286 <div class="repo-settings-section-foot">
1287 <button type="submit" class="repo-settings-cta">
1288 Save preview settings <span class="arrow">→</span>
1289 </button>
1290 </div>
1291 </form>
1292 </section>
1293
ec9e3e3Claude1294 {/* ─── Branch protection ─── */}
1295 <section class="repo-settings-section" id="branch-protection">
1296 <div class="repo-settings-section-head">
1297 <div class="repo-settings-section-eyebrow">Branch protection</div>
1298 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1299 <p class="repo-settings-section-desc">
1300 Protect branches by requiring a minimum number of human approvals before
1301 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1302 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1303 </p>
1304 </div>
1305
1306 {/* Existing rules list */}
1307 {existingBranchRules.length > 0 && (
1308 <div style="padding: var(--space-3) var(--space-5) 0">
1309 {existingBranchRules.map((rule) => (
1310 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1311 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1312 {rule.pattern}
1313 </span>
1314 <span style="font-size:12.5px;color:var(--text-muted)">
1315 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1316 </span>
1317 {rule.requireHumanReview && (
1318 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1319 codeowner review required
1320 </span>
1321 )}
1322 {rule.dismissStaleReviews && (
1323 <span style="font-size:11px;color:var(--text-muted)">
1324 dismiss stale
1325 </span>
1326 )}
1327 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1328 <button type="submit"
1329 class="repo-settings-cta-secondary"
1330 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1331 onclick="return confirm('Delete this branch protection rule?')"
1332 >
1333 Delete
1334 </button>
1335 </form>
1336 </div>
1337 ))}
1338 </div>
1339 )}
1340
1341 {/* Add new rule form */}
1342 <form
1343 method="post"
1344 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1345 >
1346 <div class="repo-settings-section-body">
1347 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1348 <div class="repo-settings-field" style="margin-bottom:0">
1349 <label class="repo-settings-field-label" for="bp_pattern">
1350 Branch name pattern
1351 </label>
1352 <input
1353 class="repo-settings-input"
1354 name="pattern"
1355 id="bp_pattern"
1356 placeholder="main"
1357 required
1358 aria-label="Branch name pattern"
1359 />
1360 <div class="repo-settings-field-hint">
1361 Exact name or glob like <code>release/*</code>
1362 </div>
1363 </div>
1364 <div class="repo-settings-field" style="margin-bottom:0">
1365 <label class="repo-settings-field-label" for="bp_required">
1366 Required approvals
1367 </label>
1368 <input
1369 class="repo-settings-input"
1370 name="required_approvals"
1371 id="bp_required"
1372 type="number"
1373 min="0"
1374 max="10"
1375 value="1"
1376 aria-label="Number of required approvals"
1377 />
1378 </div>
1379 </div>
1380 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1381 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1382 <input
1383 type="checkbox"
1384 name="require_codeowner_review"
1385 value="1"
1386 />
1387 <span class="repo-settings-toggle-text">
1388 <span class="repo-settings-toggle-text-title">
1389 Require codeowner review
1390 </span>
1391 <span class="repo-settings-toggle-text-hint">
1392 At least one CODEOWNERS-matched reviewer must approve before merging.
1393 </span>
1394 </span>
1395 </label>
1396 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1397 <input
1398 type="checkbox"
1399 name="dismiss_stale_reviews"
1400 value="1"
1401 />
1402 <span class="repo-settings-toggle-text">
1403 <span class="repo-settings-toggle-text-title">
1404 Dismiss stale reviews on new push
1405 </span>
1406 <span class="repo-settings-toggle-text-hint">
1407 Prior approvals are dismissed when the head branch receives a new commit.
1408 </span>
1409 </span>
1410 </label>
1411 </div>
1412 </div>
1413 <div class="repo-settings-section-foot">
1414 <button type="submit" class="repo-settings-cta">
1415 Add rule <span class="arrow">→</span>
1416 </button>
1417 </div>
1418 </form>
1419 </section>
1420
58307aeClaude1421 {/* ─── Danger zone ─── */}
1422 <section class="repo-settings-danger">
1423 <div class="repo-settings-danger-head">
1424 <div class="repo-settings-danger-eyebrow">Danger zone</div>
1425 <h2 class="repo-settings-danger-title">Delete this repository</h2>
1426 <p class="repo-settings-danger-desc">
1427 Permanently remove this repository and every byte of its history,
1428 issues, PRs, stars, and webhooks. There is no undo.
1429 </p>
1430 </div>
1431 <div class="repo-settings-danger-body">
1432 <p class="muted">
1433 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
1434 up immediately. Open clones will fail to fetch or push.
1435 </p>
1436 <form
1437 method="post"
1438 action={`/${ownerName}/${repoName}/settings/delete`}
1439 onsubmit="return confirm('Are you sure? This cannot be undone.')"
1440 >
1441 <button type="submit" class="repo-settings-danger-btn">
1442 Delete this repository
1443 </button>
1444 </form>
1445 </div>
1446 </section>
2289dc6Claude1447 </div>{/* .rsettings-content */}
1448 </div>{/* .rsettings-layout */}
58307aeClaude1449 </div>
79136bbClaude1450 </Layout>
1451 );
1452});
1453
1454// Save settings
febd4f0Claude1455repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude1456 const { owner: ownerName, repo: repoName } = c.req.param();
1457 const user = c.get("user")!;
1458 const body = await c.req.parseBody();
1459
1460 const [owner] = await db
1461 .select()
1462 .from(users)
1463 .where(eq(users.username, ownerName))
1464 .limit(1);
1465
1466 if (!owner || owner.id !== user.id) {
1467 return c.redirect(`/${ownerName}/${repoName}`);
1468 }
1469
1470 await db
1471 .update(repositories)
1472 .set({
1473 description: String(body.description || "").trim() || null,
1474 defaultBranch: String(body.default_branch || "main"),
1475 isPrivate: body.visibility === "private",
1476 updatedAt: new Date(),
1477 })
1478 .where(
1479 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1480 );
1481
1482 return c.redirect(
1483 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1484 );
1485});
1486
71cd5ecClaude1487// Toggle template flag
1488repoSettings.post(
1489 "/:owner/:repo/settings/template",
1490 requireAuth,
febd4f0Claude1491 requireRepoAccess("admin"),
71cd5ecClaude1492 async (c) => {
1493 const { owner: ownerName, repo: repoName } = c.req.param();
1494 const user = c.get("user")!;
1495 const body = await c.req.parseBody();
1496 const [owner] = await db
1497 .select()
1498 .from(users)
1499 .where(eq(users.username, ownerName))
1500 .limit(1);
1501 if (!owner || owner.id !== user.id) {
1502 return c.redirect(`/${ownerName}/${repoName}`);
1503 }
1504 const target = String(body.template || "1") === "1";
1505 await db
1506 .update(repositories)
1507 .set({ isTemplate: target, updatedAt: new Date() })
1508 .where(
1509 and(
1510 eq(repositories.ownerId, owner.id),
1511 eq(repositories.name, repoName)
1512 )
1513 );
1514 return c.redirect(
1515 `/${ownerName}/${repoName}/settings?success=${
1516 target ? "Marked+as+template" : "Unmarked+as+template"
1517 }`
1518 );
1519 }
1520);
1521
1522// Transfer repository to a new owner (by username)
1523repoSettings.post(
1524 "/:owner/:repo/settings/transfer",
1525 requireAuth,
febd4f0Claude1526 requireRepoAccess("admin"),
71cd5ecClaude1527 async (c) => {
1528 const { owner: ownerName, repo: repoName } = c.req.param();
1529 const user = c.get("user")!;
1530 const body = await c.req.parseBody();
1531 const newOwnerName = String(body.new_owner || "").trim();
1532 if (!newOwnerName) {
1533 return c.redirect(
1534 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1535 );
1536 }
1537 const [owner] = await db
1538 .select()
1539 .from(users)
1540 .where(eq(users.username, ownerName))
1541 .limit(1);
1542 if (!owner || owner.id !== user.id) {
1543 return c.redirect(`/${ownerName}/${repoName}`);
1544 }
1545 const [newOwner] = await db
1546 .select()
1547 .from(users)
1548 .where(eq(users.username, newOwnerName))
1549 .limit(1);
1550 if (!newOwner) {
1551 return c.redirect(
1552 `/${ownerName}/${repoName}/settings?error=User+not+found`
1553 );
1554 }
1555 if (newOwner.id === owner.id) {
1556 return c.redirect(
1557 `/${ownerName}/${repoName}/settings?error=Same+owner`
1558 );
1559 }
1560 // Reject if new owner already has a repo by this name
1561 const [conflict] = await db
1562 .select()
1563 .from(repositories)
1564 .where(
1565 and(
1566 eq(repositories.ownerId, newOwner.id),
1567 eq(repositories.name, repoName)
1568 )
1569 )
1570 .limit(1);
1571 if (conflict) {
1572 return c.redirect(
1573 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1574 );
1575 }
1576 const [repo] = await db
1577 .select()
1578 .from(repositories)
1579 .where(
1580 and(
1581 eq(repositories.ownerId, owner.id),
1582 eq(repositories.name, repoName)
1583 )
1584 )
1585 .limit(1);
1586 if (!repo) return c.notFound();
1587 await db
1588 .update(repositories)
1589 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1590 .where(eq(repositories.id, repo.id));
1591 await db.insert(repoTransfers).values({
1592 repositoryId: repo.id,
1593 fromOwnerId: owner.id,
1594 fromOrgId: repo.orgId,
1595 toOwnerId: newOwner.id,
1596 toOrgId: null,
1597 initiatedBy: user.id,
1598 });
1599 return c.redirect(`/${newOwnerName}/${repoName}`);
1600 }
1601);
1602
1603// Archive / unarchive repository
1604repoSettings.post(
1605 "/:owner/:repo/settings/archive",
1606 requireAuth,
febd4f0Claude1607 requireRepoAccess("admin"),
71cd5ecClaude1608 async (c) => {
1609 const { owner: ownerName, repo: repoName } = c.req.param();
1610 const user = c.get("user")!;
1611 const body = await c.req.parseBody();
1612 const [owner] = await db
1613 .select()
1614 .from(users)
1615 .where(eq(users.username, ownerName))
1616 .limit(1);
1617 if (!owner || owner.id !== user.id) {
1618 return c.redirect(`/${ownerName}/${repoName}`);
1619 }
1620 const target = String(body.archive || "1") === "1";
1621 await db
1622 .update(repositories)
1623 .set({ isArchived: target, updatedAt: new Date() })
1624 .where(
1625 and(
1626 eq(repositories.ownerId, owner.id),
1627 eq(repositories.name, repoName)
1628 )
1629 );
1630 return c.redirect(
1631 `/${ownerName}/${repoName}/settings?success=${
1632 target ? "Repository+archived" : "Repository+unarchived"
1633 }`
1634 );
1635 }
1636);
1637
534f04aClaude1638// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1639repoSettings.post(
1640 "/:owner/:repo/settings/stale",
1641 requireAuth,
1642 requireRepoAccess("admin"),
1643 async (c) => {
1644 const { owner: ownerName, repo: repoName } = c.req.param();
1645 const user = c.get("user")!;
1646 const body = await c.req.parseBody();
1647 const [owner] = await db
1648 .select()
1649 .from(users)
1650 .where(eq(users.username, ownerName))
1651 .limit(1);
1652 if (!owner || owner.id !== user.id) {
1653 return c.redirect(`/${ownerName}/${repoName}`);
1654 }
1655 const [repo] = await db
1656 .select()
1657 .from(repositories)
1658 .where(
1659 and(
1660 eq(repositories.ownerId, owner.id),
1661 eq(repositories.name, repoName)
1662 )
1663 )
1664 .limit(1);
1665 if (!repo) return c.notFound();
1666
1667 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1668 const newPrs = body.auto_close_stale_prs === "1";
1669 const newIssues = body.auto_close_stale_issues === "1";
1670
1671 await db
1672 .update(repositories)
1673 .set({
1674 autoCloseStalePrs: newPrs,
1675 autoCloseStaleIssues: newIssues,
1676 updatedAt: new Date(),
1677 })
1678 .where(eq(repositories.id, repo.id));
1679
1680 // Audit toggle deltas so the repo's audit log shows the change. Two
1681 // separate rows (one per flag) so the action names stay stable + grep-able.
1682 if (newPrs !== repo.autoCloseStalePrs) {
1683 await audit({
1684 userId: user.id,
1685 repositoryId: repo.id,
1686 action: "repo.auto_close_stale_prs.toggled",
1687 targetType: "repository",
1688 targetId: repo.id,
1689 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1690 });
1691 }
1692 if (newIssues !== repo.autoCloseStaleIssues) {
1693 await audit({
1694 userId: user.id,
1695 repositoryId: repo.id,
1696 action: "repo.auto_close_stale_issues.toggled",
1697 targetType: "repository",
1698 targetId: repo.id,
1699 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1700 });
1701 }
1702
1703 return c.redirect(
1704 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1705 );
1706 }
1707);
1708
1d4ff60Claude1709// AI test generator opt-in. Owner-only; audits the toggle delta so the
1710// repo's audit log shows the change.
1711repoSettings.post(
1712 "/:owner/:repo/settings/ai-tests",
1713 requireAuth,
1714 requireRepoAccess("admin"),
1715 async (c) => {
1716 const { owner: ownerName, repo: repoName } = c.req.param();
1717 const user = c.get("user")!;
1718 const body = await c.req.parseBody();
1719 const [owner] = await db
1720 .select()
1721 .from(users)
1722 .where(eq(users.username, ownerName))
1723 .limit(1);
1724 if (!owner || owner.id !== user.id) {
1725 return c.redirect(`/${ownerName}/${repoName}`);
1726 }
1727 const [repo] = await db
1728 .select()
1729 .from(repositories)
1730 .where(
1731 and(
1732 eq(repositories.ownerId, owner.id),
1733 eq(repositories.name, repoName)
1734 )
1735 )
1736 .limit(1);
1737 if (!repo) return c.notFound();
1738
1739 // Unchecked checkboxes are absent from the form payload, so coerce.
1740 const next = body.auto_generate_tests === "1";
1741
1742 await db
1743 .update(repositories)
1744 .set({
1745 autoGenerateTests: next,
1746 updatedAt: new Date(),
1747 })
1748 .where(eq(repositories.id, repo.id));
1749
1750 if (next !== repo.autoGenerateTests) {
1751 await audit({
1752 userId: user.id,
1753 repositoryId: repo.id,
1754 action: "repo.auto_generate_tests.toggled",
1755 targetType: "repository",
1756 targetId: repo.id,
1757 metadata: { from: repo.autoGenerateTests, to: next },
1758 });
1759 }
1760
1761 return c.redirect(
1762 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1763 );
1764 }
1765);
1766
9b3a183Claude1767// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1768// the toggle delta so the repo's audit log shows the change.
1769repoSettings.post(
1770 "/:owner/:repo/settings/dev-envs",
1771 requireAuth,
1772 requireRepoAccess("admin"),
1773 async (c) => {
1774 const { owner: ownerName, repo: repoName } = c.req.param();
1775 const user = c.get("user")!;
1776 const body = await c.req.parseBody();
1777 const [owner] = await db
1778 .select()
1779 .from(users)
1780 .where(eq(users.username, ownerName))
1781 .limit(1);
1782 if (!owner || owner.id !== user.id) {
1783 return c.redirect(`/${ownerName}/${repoName}`);
1784 }
1785 const [repo] = await db
1786 .select()
1787 .from(repositories)
1788 .where(
1789 and(
1790 eq(repositories.ownerId, owner.id),
1791 eq(repositories.name, repoName)
1792 )
1793 )
1794 .limit(1);
1795 if (!repo) return c.notFound();
1796
1797 const next = body.dev_envs_enabled === "1";
1798 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1799
1800 await db
1801 .update(repositories)
1802 .set({
1803 devEnvsEnabled: next,
1804 updatedAt: new Date(),
1805 })
1806 .where(eq(repositories.id, repo.id));
1807
1808 if (next !== prev) {
1809 await audit({
1810 userId: user.id,
1811 repositoryId: repo.id,
1812 action: "repo.dev_envs_enabled.toggled",
1813 targetType: "repository",
1814 targetId: repo.id,
1815 metadata: { from: prev, to: next },
1816 });
1817 }
1818
1819 return c.redirect(
1820 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1821 );
1822 }
1823);
1824
f5ad215Claude1825// Migration 0077 — toggle AI dependency auto-updater. Owner-only; audits
1826// the toggle delta so the repo's audit log shows the change.
1827repoSettings.post(
1828 "/:owner/:repo/settings/dep-updater",
1829 requireAuth,
1830 requireRepoAccess("admin"),
1831 async (c) => {
1832 const { owner: ownerName, repo: repoName } = c.req.param();
1833 const user = c.get("user")!;
1834 const body = await c.req.parseBody();
1835 const [owner] = await db
1836 .select()
1837 .from(users)
1838 .where(eq(users.username, ownerName))
1839 .limit(1);
1840 if (!owner || owner.id !== user.id) {
1841 return c.redirect(`/${ownerName}/${repoName}`);
1842 }
1843 const [repo] = await db
1844 .select()
1845 .from(repositories)
1846 .where(
1847 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1848 )
1849 .limit(1);
1850 if (!repo) return c.notFound();
1851
1852 const next = body.dep_updater_enabled === "1";
1853 const prev =
1854 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ?? false;
1855
1856 await db
1857 .update(repositories)
1858 .set({
1859 depUpdaterEnabled: next,
1860 updatedAt: new Date(),
1861 })
1862 .where(eq(repositories.id, repo.id));
1863
1864 if (next !== prev) {
1865 await audit({
1866 userId: user.id,
1867 repositoryId: repo.id,
1868 action: "repo.dep_updater_enabled.toggled",
1869 targetType: "repository",
1870 targetId: repo.id,
1871 metadata: { from: prev, to: next },
1872 });
1873 }
1874
1875 return c.redirect(
1876 `/${ownerName}/${repoName}/settings?success=Dependency+update+settings+saved#dep-updater`
1877 );
1878 }
1879);
1880
79136bbClaude1881// Delete repository
1882repoSettings.post(
1883 "/:owner/:repo/settings/delete",
1884 requireAuth,
febd4f0Claude1885 requireRepoAccess("admin"),
79136bbClaude1886 async (c) => {
1887 const { owner: ownerName, repo: repoName } = c.req.param();
1888 const user = c.get("user")!;
1889
1890 const [owner] = await db
1891 .select()
1892 .from(users)
1893 .where(eq(users.username, ownerName))
1894 .limit(1);
1895
1896 if (!owner || owner.id !== user.id) {
1897 return c.redirect(`/${ownerName}/${repoName}`);
1898 }
1899
1900 const [repo] = await db
1901 .select()
1902 .from(repositories)
1903 .where(
1904 and(
1905 eq(repositories.ownerId, owner.id),
1906 eq(repositories.name, repoName)
1907 )
1908 )
1909 .limit(1);
1910
1911 if (!repo) return c.redirect(`/${ownerName}`);
1912
1913 // Delete from disk
1914 try {
1915 await rm(repo.diskPath, { recursive: true, force: true });
1916 } catch {
1917 // Disk cleanup best-effort
1918 }
1919
1920 // Delete from DB (cascades to stars, issues, etc.)
1921 await db.delete(repositories).where(eq(repositories.id, repo.id));
1922
1923 return c.redirect(`/${ownerName}`);
1924 }
1925);
1926
1df50d5Claude1927// Migration 0077 — PR preview build configuration. Owner-only.
1928repoSettings.post(
1929 "/:owner/:repo/settings/previews",
1930 requireAuth,
1931 requireRepoAccess("admin"),
1932 async (c) => {
1933 const { owner: ownerName, repo: repoName } = c.req.param();
1934 const user = c.get("user")!;
1935 const body = await c.req.parseBody();
1936
1937 const [owner] = await db
1938 .select()
1939 .from(users)
1940 .where(eq(users.username, ownerName))
1941 .limit(1);
1942 if (!owner || owner.id !== user.id) {
1943 return c.redirect(`/${ownerName}/${repoName}`);
1944 }
1945
1946 const [repo] = await db
1947 .select()
1948 .from(repositories)
1949 .where(
1950 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1951 )
1952 .limit(1);
1953 if (!repo) return c.notFound();
1954
1955 const buildCommand = String(body.preview_build_command || "").trim() || null;
1956 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1957
1958 await db
1959 .update(repositories)
1960 .set({
1961 previewBuildCommand: buildCommand,
1962 previewOutputDir: outputDir,
1963 updatedAt: new Date(),
1964 })
1965 .where(eq(repositories.id, repo.id));
1966
1967 return c.redirect(
1968 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1969 );
1970 }
1971);
1972
ec9e3e3Claude1973// ─── Branch protection: add rule ───────────────────────────────────────────
1974repoSettings.post(
1975 "/:owner/:repo/settings/branch-protection",
1976 requireAuth,
1977 requireRepoAccess("admin"),
1978 async (c) => {
1979 const { owner: ownerName, repo: repoName } = c.req.param();
1980 const user = c.get("user")!;
1981 const body = await c.req.parseBody();
1982
1983 const [owner] = await db
1984 .select()
1985 .from(users)
1986 .where(eq(users.username, ownerName))
1987 .limit(1);
1988 if (!owner || owner.id !== user.id) {
1989 return c.redirect(`/${ownerName}/${repoName}`);
1990 }
1991
1992 const [repo] = await db
1993 .select()
1994 .from(repositories)
1995 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1996 .limit(1);
1997 if (!repo) return c.notFound();
1998
1999 const pattern = String(body.pattern || "").trim();
2000 if (!pattern) {
2001 return c.redirect(
2002 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
2003 );
2004 }
2005
2006 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
2007 const requireHumanReview = body.require_codeowner_review === "1";
2008 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
2009
2010 try {
2011 await db
2012 .insert(branchProtection)
2013 .values({
2014 repositoryId: repo.id,
2015 pattern,
2016 requiredApprovals,
2017 requireHumanReview,
2018 dismissStaleReviews,
2019 })
2020 .onConflictDoUpdate({
2021 target: [branchProtection.repositoryId, branchProtection.pattern],
2022 set: {
2023 requiredApprovals,
2024 requireHumanReview,
2025 dismissStaleReviews,
2026 updatedAt: new Date(),
2027 },
2028 });
2029
2030 await audit({
2031 userId: user.id,
2032 repositoryId: repo.id,
2033 action: "branch_protection.updated",
2034 targetType: "repository",
2035 targetId: repo.id,
2036 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
2037 });
2038 } catch (err) {
2039 return c.redirect(
2040 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
2041 );
2042 }
2043
2044 return c.redirect(
2045 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
2046 );
2047 }
2048);
2049
2050// ─── Branch protection: delete rule ────────────────────────────────────────
2051repoSettings.post(
2052 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
2053 requireAuth,
2054 requireRepoAccess("admin"),
2055 async (c) => {
2056 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
2057 const user = c.get("user")!;
2058
2059 const [owner] = await db
2060 .select()
2061 .from(users)
2062 .where(eq(users.username, ownerName))
2063 .limit(1);
2064 if (!owner || owner.id !== user.id) {
2065 return c.redirect(`/${ownerName}/${repoName}`);
2066 }
2067
2068 const [repo] = await db
2069 .select()
2070 .from(repositories)
2071 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
2072 .limit(1);
2073 if (!repo) return c.notFound();
2074
2075 await db
2076 .delete(branchProtection)
2077 .where(
2078 and(
2079 eq(branchProtection.id, ruleId),
2080 eq(branchProtection.repositoryId, repo.id)
2081 )
2082 );
2083
2084 await audit({
2085 userId: user.id,
2086 repositoryId: repo.id,
2087 action: "branch_protection.deleted",
2088 targetType: "repository",
2089 targetId: repo.id,
2090 metadata: { ruleId },
2091 });
2092
2093 return c.redirect(
2094 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
2095 );
2096 }
2097);
2098
79136bbClaude2099export default repoSettings;