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.tsxBlame1983 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 }
502`;
503
504/** Hero header for the repo settings page. */
505function RepoSettingsHero(props: { owner: string; repo: string }) {
506 const { owner, repo } = props;
507 return (
508 <div class="repo-settings-hero">
509 <div class="repo-settings-hero-bg" aria-hidden="true">
510 <div class="repo-settings-hero-orb" />
511 </div>
512 <div class="repo-settings-hero-inner">
513 <div class="repo-settings-hero-eyebrow">
514 Repository settings ·{" "}
515 <span class="repo-settings-hero-repo">
516 {owner}/{repo}
517 </span>
518 </div>
519 <h1 class="repo-settings-hero-title">
520 <span class="gradient-text">Configure</span>.
521 </h1>
522 <p class="repo-settings-hero-sub">
523 Description, visibility, branches, automation. Owners only.
524 </p>
525 <a
526 href={`/${owner}/${repo}/settings/collaborators`}
527 class="repo-settings-hero-link"
528 >
529 Manage collaborators <span class="arrow">→</span>
530 </a>
531 </div>
532 </div>
533 );
534}
535
536function Banner(props: { kind: "success" | "error"; text: string }) {
537 return (
538 <div
539 class={`repo-settings-banner repo-settings-banner-${props.kind}`}
540 role="status"
541 >
542 <span class="repo-settings-banner-icon" aria-hidden="true">
543 {props.kind === "success" ? "✓" : "!"}
544 </span>
545 <span>{props.text}</span>
546 </div>
547 );
548}
549
79136bbClaude550// Settings page
febd4f0Claude551repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude552 const { owner: ownerName, repo: repoName } = c.req.param();
553 const user = c.get("user")!;
554 const success = c.req.query("success");
555 const error = c.req.query("error");
556
557 const [owner] = await db
558 .select()
559 .from(users)
560 .where(eq(users.username, ownerName))
561 .limit(1);
562
563 if (!owner || owner.id !== user.id) {
564 return c.html(
565 <Layout title="Unauthorized" user={user}>
bb0f894Claude566 <EmptyState title="Unauthorized">
79136bbClaude567 <p>Only the repository owner can access settings.</p>
bb0f894Claude568 </EmptyState>
79136bbClaude569 </Layout>,
570 403
571 );
572 }
573
574 const [repo] = await db
575 .select()
576 .from(repositories)
577 .where(
578 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
579 )
580 .limit(1);
581
582 if (!repo) return c.notFound();
583
584 const branches = await listBranches(ownerName, repoName);
585
ec9e3e3Claude586 // Branch protection rules for the "Branch protection" settings section.
587 const existingBranchRules = await db
588 .select()
589 .from(branchProtection)
590 .where(eq(branchProtection.repositoryId, repo.id))
591 .catch(() => [] as typeof branchProtection.$inferSelect[]);
592
79136bbClaude593 return c.html(
594 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
595 <RepoHeader owner={ownerName} repo={repoName} />
58307aeClaude596 <style dangerouslySetInnerHTML={{ __html: repoSettingsStyles }} />
597 <div class="repo-settings-container">
598 <RepoSettingsHero owner={ownerName} repo={repoName} />
599
79136bbClaude600 {success && (
58307aeClaude601 <Banner kind="success" text={decodeURIComponent(success)} />
79136bbClaude602 )}
58307aeClaude603 {error && <Banner kind="error" text={decodeURIComponent(error)} />}
79136bbClaude604
58307aeClaude605 {/* ─── General: description + default branch + visibility ─── */}
606 <section class="repo-settings-section">
607 <div class="repo-settings-section-head">
608 <div class="repo-settings-section-eyebrow">General</div>
609 <h2 class="repo-settings-section-title">Repository basics</h2>
610 <p class="repo-settings-section-desc">
611 The headline details visitors see — description, the default
612 branch they land on, and who can browse the code.
613 </p>
614 </div>
615 <form
616 method="post"
617 action={`/${ownerName}/${repoName}/settings`}
618 >
619 <div class="repo-settings-section-body">
620 <div class="repo-settings-field">
621 <label class="repo-settings-field-label" for="description">
622 Description
623 </label>
79136bbClaude624 <input
58307aeClaude625 class="repo-settings-input"
626 name="description"
627 id="description"
628 value={repo.description || ""}
629 placeholder="A short description"
79136bbClaude630 />
58307aeClaude631 <div class="repo-settings-field-hint">
632 Shown on the repo home, search results, and explore pages.
633 </div>
634 </div>
635 <div class="repo-settings-field">
636 <label
637 class="repo-settings-field-label"
638 for="default_branch"
639 >
640 Default branch
641 </label>
642 <select
643 class="repo-settings-select"
644 name="default_branch"
645 id="default_branch"
646 >
647 {branches.length === 0 ? (
648 <option value={repo.defaultBranch}>
649 {repo.defaultBranch}
650 </option>
651 ) : (
652 branches.map((b) => (
653 <option value={b} selected={b === repo.defaultBranch}>
654 {b}
655 </option>
656 ))
657 )}
658 </select>
659 <div class="repo-settings-field-hint">
660 Where pulls land, where compare views start, and what
661 clones check out by default.
662 </div>
663 </div>
664 <div class="repo-settings-field">
665 <label class="repo-settings-field-label">Visibility</label>
666 <div class="repo-settings-visibility">
667 <label class="repo-settings-vis-card">
668 <input
669 type="radio"
670 name="visibility"
671 value="public"
672 checked={!repo.isPrivate}
673 class="repo-settings-vis-radio"
674 aria-label="Public"
675 />
676 <span class="repo-settings-vis-body">
677 <span class="repo-settings-vis-label">Public</span>
678 <span class="repo-settings-vis-desc">
679 Anyone can see this repository. You choose who can
680 commit.
681 </span>
682 </span>
683 </label>
684 <label class="repo-settings-vis-card">
685 <input
686 type="radio"
687 name="visibility"
688 value="private"
689 checked={repo.isPrivate}
690 class="repo-settings-vis-radio"
691 aria-label="Private"
692 />
693 <span class="repo-settings-vis-body">
694 <span class="repo-settings-vis-label">Private</span>
695 <span class="repo-settings-vis-desc">
696 Only you (and collaborators you invite) can see this
697 repository.
698 </span>
699 </span>
700 </label>
701 </div>
702 </div>
44f1a02Claude703 <div class="repo-settings-field">
704 <label class="repo-settings-field-label">Data region</label>
705 <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
706 <span
707 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); ${
708 repo.dataRegion === "eu"
709 ? "background:rgba(54,197,214,0.10); color:#67e8f9; border:1px solid rgba(54,197,214,0.28);"
710 : "background:rgba(140,109,255,0.10); color:#c4b5fd; border:1px solid rgba(140,109,255,0.28);"
711 }`}
712 >
713 {repo.dataRegion === "eu" ? "EU · Frankfurt" : "US · Default"}
714 </span>
715 <span class="repo-settings-field-hint" style="margin:0;">
716 Data region is set at creation and cannot be changed. EU data
717 residency requires a{" "}
718 <a href="/pricing" style="color:var(--accent);text-decoration:none;">Pro plan or higher</a>.
719 </span>
720 </div>
721 </div>
79136bbClaude722 </div>
58307aeClaude723 <div class="repo-settings-section-foot">
724 <button type="submit" class="repo-settings-cta">
725 Save changes <span class="arrow">→</span>
726 </button>
727 </div>
728 </form>
729 </section>
14c3cc8Claude730
58307aeClaude731 {/* ─── Spec to PR ─── */}
732 <section class="repo-settings-section">
733 <div class="repo-settings-section-head">
734 <div class="repo-settings-section-eyebrow">
735 Automation · experimental
736 </div>
737 <h2 class="repo-settings-section-title">Spec to PR</h2>
738 <p class="repo-settings-section-desc">
739 Paste a plain-English feature spec and let Claude draft a pull
740 request for you. PRs open as drafts — review every line before
741 merging.
742 </p>
743 </div>
744 <div class="repo-settings-section-body">
745 <a
746 href={`/${ownerName}/${repoName}/spec`}
747 class="repo-settings-cta"
748 >
749 Open Spec to PR <span class="arrow">→</span>
750 </a>
751 </div>
752 </section>
753
479dcd9Claude754 {/* ─── Automation hub ─── */}
755 <section class="repo-settings-section">
756 <div class="repo-settings-section-head">
757 <div class="repo-settings-section-eyebrow">Automation</div>
758 <h2 class="repo-settings-section-title">Automation settings</h2>
759 <p class="repo-settings-section-desc">
760 Every automation on this repository in one place — AI code
761 review, PR/issue triage, auto-merge, CI auto-fix, test
762 generation, and dependency updates. Flip each between off,
763 suggest (manual), and automatic.
764 </p>
765 </div>
766 <div class="repo-settings-section-body">
767 <a
768 href={`/${ownerName}/${repoName}/settings/automation`}
769 class="repo-settings-cta"
770 >
771 Open Automation settings <span class="arrow">→</span>
772 </a>
773 </div>
774 </section>
775
58307aeClaude776 {/* ─── Template repository ─── */}
777 <section class="repo-settings-section">
778 <div class="repo-settings-section-head">
779 <div class="repo-settings-section-eyebrow">Template</div>
780 <h2 class="repo-settings-section-title">Template repository</h2>
781 <p class="repo-settings-section-desc">
71cd5ecClaude782 {repo.isTemplate
58307aeClaude783 ? "This repository is a template. Users can click “Use this template” to create a new repository with the same files."
784 : "Mark this repository as a template so others can seed new repositories from its files."}
785 </p>
786 </div>
787 <div class="repo-settings-section-body">
788 <span
789 class={`repo-settings-pill ${repo.isTemplate ? "is-on" : "is-off"}`}
790 >
791 <span class="dot" aria-hidden="true" />
792 {repo.isTemplate ? "Template enabled" : "Not a template"}
793 </span>
794 <form
795 method="post"
796 action={`/${ownerName}/${repoName}/settings/template`}
797 style="margin-top: var(--space-3)"
798 >
799 <input
800 type="hidden"
801 name="template"
802 value={repo.isTemplate ? "0" : "1"}
803 />
804 <button type="submit" class="repo-settings-cta-secondary">
805 {repo.isTemplate ? "Unmark as template" : "Mark as template"}
806 </button>
807 </form>
808 </div>
809 </section>
71cd5ecClaude810
58307aeClaude811 {/* ─── Transfer ownership ─── */}
812 <section class="repo-settings-section">
813 <div class="repo-settings-section-head">
814 <div class="repo-settings-section-eyebrow">Ownership</div>
815 <h2 class="repo-settings-section-title">Transfer ownership</h2>
816 <p class="repo-settings-section-desc">
817 Hand this repository to another user. The new owner can accept
818 or decline the transfer by viewing it.
819 </p>
820 </div>
71cd5ecClaude821 <form
e7e240eClaude822 method="post"
71cd5ecClaude823 action={`/${ownerName}/${repoName}/settings/transfer`}
824 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
825 >
58307aeClaude826 <div class="repo-settings-section-body">
827 <div class="repo-settings-field">
828 <label class="repo-settings-field-label" for="new_owner">
829 New owner username
830 </label>
831 <div class="repo-settings-inline-row">
832 <input
833 type="text"
834 name="new_owner"
835 id="new_owner"
836 class="repo-settings-input"
837 placeholder="new-owner-username"
838 required
839 aria-label="New owner username"
840 />
841 <button type="submit" class="repo-settings-cta-secondary">
842 Transfer
843 </button>
844 </div>
845 <div class="repo-settings-field-hint">
846 Transfers are immediate. The new owner can rename or delete
847 the repository at any time.
848 </div>
849 </div>
850 </div>
71cd5ecClaude851 </form>
58307aeClaude852 </section>
71cd5ecClaude853
58307aeClaude854 {/* ─── Archive ─── */}
855 <section class="repo-settings-section">
856 <div class="repo-settings-section-head">
857 <div class="repo-settings-section-eyebrow">Lifecycle</div>
858 <h2 class="repo-settings-section-title">
859 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
860 </h2>
861 <p class="repo-settings-section-desc">
862 {repo.isArchived
863 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
864 : "Mark this repository as archived. It becomes read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
865 </p>
866 </div>
867 <div class="repo-settings-section-body">
868 <span
869 class={`repo-settings-pill ${repo.isArchived ? "is-on" : "is-off"}`}
534f04aClaude870 >
58307aeClaude871 <span class="dot" aria-hidden="true" />
872 {repo.isArchived ? "Archived" : "Active"}
873 </span>
874 <form
875 method="post"
876 action={`/${ownerName}/${repoName}/settings/archive`}
877 style="margin-top: var(--space-3)"
534f04aClaude878 >
879 <input
58307aeClaude880 type="hidden"
881 name="archive"
882 value={repo.isArchived ? "0" : "1"}
534f04aClaude883 />
58307aeClaude884 <button type="submit" class="repo-settings-cta-secondary">
885 {repo.isArchived ? "Unarchive" : "Archive"} this repository
886 </button>
887 </form>
888 </div>
889 </section>
534f04aClaude890
1d4ff60Claude891 {/* ─── AI test generator ─── */}
892 <section class="repo-settings-section">
893 <div class="repo-settings-section-head">
894 <div class="repo-settings-section-eyebrow">AI tests</div>
895 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
896 <p class="repo-settings-section-desc">
897 When a pull request opens, Gluecron AI reads the diff and writes
898 tests for the new code, matching whatever framework your repo
899 already uses. Tests land on the same branch. Default off —
900 opt in here.
901 </p>
902 </div>
903 <form
904 method="post"
905 action={`/${ownerName}/${repoName}/settings/ai-tests`}
906 >
907 <div class="repo-settings-section-body">
908 <label
909 class="repo-settings-toggle-row"
910 aria-label="Auto-generate tests when a PR opens"
911 >
912 <input
913 type="checkbox"
914 name="auto_generate_tests"
915 value="1"
916 checked={repo.autoGenerateTests}
917 />
918 <span class="repo-settings-toggle-text">
919 <span class="repo-settings-toggle-text-title">
920 Auto-generate tests on PR open
921 </span>
922 <span class="repo-settings-toggle-text-hint">
923 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
924 source-file changes and PRs already opened by AI.
925 </span>
926 </span>
927 </label>
928 </div>
929 <div class="repo-settings-section-foot">
930 <button type="submit" class="repo-settings-cta">
931 Save AI test settings <span class="arrow">→</span>
932 </button>
933 </div>
934 </form>
935 </section>
936
f5ad215Claude937 {/* ─── Dependency auto-updater ─── */}
938 <section
939 id="dep-updater"
940 class="repo-settings-section"
941 >
942 <div class="repo-settings-section-head">
943 <div class="repo-settings-section-eyebrow">AI dependency updates</div>
944 <h2 class="repo-settings-section-title">Automatic dependency updates</h2>
945 <p class="repo-settings-section-desc">
946 Once per day, Gluecron reads your <code>package.json</code>,
947 checks npm for patch and minor updates, and applies them
948 automatically. If the gate check passes, the PR is auto-merged.
949 If it fails, Gluecron opens a PR with an AI-written guide
950 explaining what broke and how to fix it. Major updates always
951 require human review and are handled separately by the migration
952 watcher. Default off.
953 </p>
954 </div>
955 <form
956 method="post"
957 action={`/${ownerName}/${repoName}/settings/dep-updater`}
958 >
959 <div class="repo-settings-section-body">
960 <label
961 class="repo-settings-toggle-row"
962 aria-label="Enable automatic dependency updates for this repo"
963 >
964 <input
965 type="checkbox"
966 name="dep_updater_enabled"
967 value="1"
968 checked={
969 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ??
970 false
971 }
972 />
973 <span class="repo-settings-toggle-text">
974 <span class="repo-settings-toggle-text-title">
975 Enable automatic dependency updates
976 </span>
977 <span class="repo-settings-toggle-text-hint">
978 Patch and minor updates only. Runs once per day; max 2
979 packages per sweep. Requires <code>DEP_UPDATER_ENABLED=1</code>{" "}
980 on the server. Auto-merges when gate passes; opens a PR
981 with an AI migration guide when it fails.
982 </span>
983 </span>
984 </label>
985 </div>
986 <div class="repo-settings-section-foot">
987 <button type="submit" class="repo-settings-cta">
988 Save dependency update settings <span class="arrow">→</span>
989 </button>
990 </div>
991 </form>
992 </section>
993
9b3a183Claude994 {/* ─── Cloud dev environments ─── */}
995 <section
996 id="dev-envs"
997 class="repo-settings-section"
998 >
999 <div class="repo-settings-section-head">
1000 <div class="repo-settings-section-eyebrow">Dev environments</div>
1001 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
1002 <p class="repo-settings-section-desc">
1003 When enabled, anyone with read access can hit{" "}
1004 <code>/{ownerName}/{repoName}/dev</code> to spin up a
1005 hosted VS Code IDE in the browser, backed by a cold-start
1006 container. We read <code>.gluecron/dev.yml</code> for the
1007 image + install commands; idle envs stop themselves after
1008 30 minutes. Default off — each env burns a container.
1009 </p>
1010 </div>
1011 <form
1012 method="post"
1013 action={`/${ownerName}/${repoName}/settings/dev-envs`}
1014 >
1015 <div class="repo-settings-section-body">
1016 <label
1017 class="repo-settings-toggle-row"
1018 aria-label="Enable cloud dev environments for this repo"
1019 >
1020 <input
1021 type="checkbox"
1022 name="dev_envs_enabled"
1023 value="1"
1024 checked={
1025 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
1026 false
1027 }
1028 />
1029 <span class="repo-settings-toggle-text">
1030 <span class="repo-settings-toggle-text-title">
1031 Enable dev environments
1032 </span>
1033 <span class="repo-settings-toggle-text-hint">
1034 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
1035 route. Commit <code>.gluecron/dev.yml</code> to your
1036 repo to customise the image, ports, and extensions.
1037 </span>
1038 </span>
1039 </label>
1040 </div>
1041 <div class="repo-settings-section-foot">
1042 <button type="submit" class="repo-settings-cta">
1043 Save dev env settings <span class="arrow">→</span>
1044 </button>
1045 </div>
1046 </form>
1047 </section>
1048
58307aeClaude1049 {/* ─── Stale activity ─── */}
1050 <section class="repo-settings-section">
1051 <div class="repo-settings-section-head">
1052 <div class="repo-settings-section-eyebrow">Stale sweep</div>
1053 <h2 class="repo-settings-section-title">Stale activity</h2>
1054 <p class="repo-settings-section-desc">
1055 Autopilot pokes PRs and issues that have gone quiet, then offers
1056 a one-click close path. Each toggle controls the final close
1057 step — pokes always happen, but they&apos;re harmless reminders.
1058 </p>
1059 </div>
79136bbClaude1060 <form
e7e240eClaude1061 method="post"
58307aeClaude1062 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude1063 >
58307aeClaude1064 <div class="repo-settings-section-body">
1065 <label
1066 class="repo-settings-toggle-row"
1067 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
1068 >
1069 <input
1070 type="checkbox"
1071 name="auto_close_stale_prs"
1072 value="1"
1073 checked={repo.autoCloseStalePrs}
1074 />
1075 <span class="repo-settings-toggle-text">
1076 <span class="repo-settings-toggle-text-title">
1077 Auto-close stale PRs
1078 </span>
1079 <span class="repo-settings-toggle-text-hint">
1080 Close PRs that go quiet for 14 days after autopilot pokes
1081 them.
1082 </span>
1083 </span>
1084 </label>
1085 <label
1086 class="repo-settings-toggle-row"
1087 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
1088 >
1089 <input
1090 type="checkbox"
1091 name="auto_close_stale_issues"
1092 value="1"
1093 checked={repo.autoCloseStaleIssues}
1094 />
1095 <span class="repo-settings-toggle-text">
1096 <span class="repo-settings-toggle-text-title">
1097 Auto-close stale issues
1098 </span>
1099 <span class="repo-settings-toggle-text-hint">
1100 Close issues that go quiet for 60 days after autopilot
1101 pokes them.
1102 </span>
1103 </span>
1104 </label>
1105 </div>
1106 <div class="repo-settings-section-foot">
1107 <button type="submit" class="repo-settings-cta">
1108 Save stale settings <span class="arrow">→</span>
1109 </button>
1110 </div>
79136bbClaude1111 </form>
58307aeClaude1112 </section>
1113
1df50d5Claude1114 {/* ─── PR preview builds (migration 0077) ─── */}
1115 <section class="repo-settings-section">
1116 <div class="repo-settings-section-head">
1117 <div class="repo-settings-section-eyebrow">Preview builds</div>
1118 <h2 class="repo-settings-section-title">PR preview environments</h2>
1119 <p class="repo-settings-section-desc">
1120 When a build command is set, every PR push automatically clones
1121 the branch, runs the command, and serves the built output from a
1122 unique preview URL — like Vercel, but native to Gluecron. Leave
1123 blank to use URL-only previews (no build runs).
1124 </p>
1125 </div>
1126 <form
1127 method="post"
1128 action={`/${ownerName}/${repoName}/settings/previews`}
1129 >
1130 <div class="repo-settings-section-body">
1131 <div class="repo-settings-field">
1132 <label class="repo-settings-field-label" for="preview_build_command">
1133 Build command
1134 </label>
1135 <input
1136 id="preview_build_command"
1137 class="repo-settings-input"
1138 type="text"
1139 name="preview_build_command"
1140 placeholder="npm run build"
1141 value={
1142 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1143 }
1144 />
1145 <p class="repo-settings-field-hint">
1146 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1147 <code>hugo</code>. Runs inside the cloned branch directory.
1148 </p>
1149 </div>
1150 <div class="repo-settings-field">
1151 <label class="repo-settings-field-label" for="preview_output_dir">
1152 Output directory
1153 </label>
1154 <input
1155 id="preview_output_dir"
1156 class="repo-settings-input"
1157 type="text"
1158 name="preview_output_dir"
1159 placeholder="dist"
1160 value={
1161 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1162 }
1163 />
1164 <p class="repo-settings-field-hint">
1165 The directory your build writes to. Common values:{" "}
1166 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1167 <code>build</code>. Served from{" "}
1168 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1169 </p>
1170 </div>
1171 </div>
1172 <div class="repo-settings-section-foot">
1173 <button type="submit" class="repo-settings-cta">
1174 Save preview settings <span class="arrow">→</span>
1175 </button>
1176 </div>
1177 </form>
1178 </section>
1179
ec9e3e3Claude1180 {/* ─── Branch protection ─── */}
1181 <section class="repo-settings-section" id="branch-protection">
1182 <div class="repo-settings-section-head">
1183 <div class="repo-settings-section-eyebrow">Branch protection</div>
1184 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1185 <p class="repo-settings-section-desc">
1186 Protect branches by requiring a minimum number of human approvals before
1187 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1188 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1189 </p>
1190 </div>
1191
1192 {/* Existing rules list */}
1193 {existingBranchRules.length > 0 && (
1194 <div style="padding: var(--space-3) var(--space-5) 0">
1195 {existingBranchRules.map((rule) => (
1196 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1197 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1198 {rule.pattern}
1199 </span>
1200 <span style="font-size:12.5px;color:var(--text-muted)">
1201 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1202 </span>
1203 {rule.requireHumanReview && (
1204 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1205 codeowner review required
1206 </span>
1207 )}
1208 {rule.dismissStaleReviews && (
1209 <span style="font-size:11px;color:var(--text-muted)">
1210 dismiss stale
1211 </span>
1212 )}
1213 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1214 <button type="submit"
1215 class="repo-settings-cta-secondary"
1216 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1217 onclick="return confirm('Delete this branch protection rule?')"
1218 >
1219 Delete
1220 </button>
1221 </form>
1222 </div>
1223 ))}
1224 </div>
1225 )}
1226
1227 {/* Add new rule form */}
1228 <form
1229 method="post"
1230 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1231 >
1232 <div class="repo-settings-section-body">
1233 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1234 <div class="repo-settings-field" style="margin-bottom:0">
1235 <label class="repo-settings-field-label" for="bp_pattern">
1236 Branch name pattern
1237 </label>
1238 <input
1239 class="repo-settings-input"
1240 name="pattern"
1241 id="bp_pattern"
1242 placeholder="main"
1243 required
1244 aria-label="Branch name pattern"
1245 />
1246 <div class="repo-settings-field-hint">
1247 Exact name or glob like <code>release/*</code>
1248 </div>
1249 </div>
1250 <div class="repo-settings-field" style="margin-bottom:0">
1251 <label class="repo-settings-field-label" for="bp_required">
1252 Required approvals
1253 </label>
1254 <input
1255 class="repo-settings-input"
1256 name="required_approvals"
1257 id="bp_required"
1258 type="number"
1259 min="0"
1260 max="10"
1261 value="1"
1262 aria-label="Number of required approvals"
1263 />
1264 </div>
1265 </div>
1266 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1267 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1268 <input
1269 type="checkbox"
1270 name="require_codeowner_review"
1271 value="1"
1272 />
1273 <span class="repo-settings-toggle-text">
1274 <span class="repo-settings-toggle-text-title">
1275 Require codeowner review
1276 </span>
1277 <span class="repo-settings-toggle-text-hint">
1278 At least one CODEOWNERS-matched reviewer must approve before merging.
1279 </span>
1280 </span>
1281 </label>
1282 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1283 <input
1284 type="checkbox"
1285 name="dismiss_stale_reviews"
1286 value="1"
1287 />
1288 <span class="repo-settings-toggle-text">
1289 <span class="repo-settings-toggle-text-title">
1290 Dismiss stale reviews on new push
1291 </span>
1292 <span class="repo-settings-toggle-text-hint">
1293 Prior approvals are dismissed when the head branch receives a new commit.
1294 </span>
1295 </span>
1296 </label>
1297 </div>
1298 </div>
1299 <div class="repo-settings-section-foot">
1300 <button type="submit" class="repo-settings-cta">
1301 Add rule <span class="arrow">→</span>
1302 </button>
1303 </div>
1304 </form>
1305 </section>
1306
58307aeClaude1307 {/* ─── Danger zone ─── */}
1308 <section class="repo-settings-danger">
1309 <div class="repo-settings-danger-head">
1310 <div class="repo-settings-danger-eyebrow">Danger zone</div>
1311 <h2 class="repo-settings-danger-title">Delete this repository</h2>
1312 <p class="repo-settings-danger-desc">
1313 Permanently remove this repository and every byte of its history,
1314 issues, PRs, stars, and webhooks. There is no undo.
1315 </p>
1316 </div>
1317 <div class="repo-settings-danger-body">
1318 <p class="muted">
1319 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
1320 up immediately. Open clones will fail to fetch or push.
1321 </p>
1322 <form
1323 method="post"
1324 action={`/${ownerName}/${repoName}/settings/delete`}
1325 onsubmit="return confirm('Are you sure? This cannot be undone.')"
1326 >
1327 <button type="submit" class="repo-settings-danger-btn">
1328 Delete this repository
1329 </button>
1330 </form>
1331 </div>
1332 </section>
1333 </div>
79136bbClaude1334 </Layout>
1335 );
1336});
1337
1338// Save settings
febd4f0Claude1339repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude1340 const { owner: ownerName, repo: repoName } = c.req.param();
1341 const user = c.get("user")!;
1342 const body = await c.req.parseBody();
1343
1344 const [owner] = await db
1345 .select()
1346 .from(users)
1347 .where(eq(users.username, ownerName))
1348 .limit(1);
1349
1350 if (!owner || owner.id !== user.id) {
1351 return c.redirect(`/${ownerName}/${repoName}`);
1352 }
1353
1354 await db
1355 .update(repositories)
1356 .set({
1357 description: String(body.description || "").trim() || null,
1358 defaultBranch: String(body.default_branch || "main"),
1359 isPrivate: body.visibility === "private",
1360 updatedAt: new Date(),
1361 })
1362 .where(
1363 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1364 );
1365
1366 return c.redirect(
1367 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1368 );
1369});
1370
71cd5ecClaude1371// Toggle template flag
1372repoSettings.post(
1373 "/:owner/:repo/settings/template",
1374 requireAuth,
febd4f0Claude1375 requireRepoAccess("admin"),
71cd5ecClaude1376 async (c) => {
1377 const { owner: ownerName, repo: repoName } = c.req.param();
1378 const user = c.get("user")!;
1379 const body = await c.req.parseBody();
1380 const [owner] = await db
1381 .select()
1382 .from(users)
1383 .where(eq(users.username, ownerName))
1384 .limit(1);
1385 if (!owner || owner.id !== user.id) {
1386 return c.redirect(`/${ownerName}/${repoName}`);
1387 }
1388 const target = String(body.template || "1") === "1";
1389 await db
1390 .update(repositories)
1391 .set({ isTemplate: target, updatedAt: new Date() })
1392 .where(
1393 and(
1394 eq(repositories.ownerId, owner.id),
1395 eq(repositories.name, repoName)
1396 )
1397 );
1398 return c.redirect(
1399 `/${ownerName}/${repoName}/settings?success=${
1400 target ? "Marked+as+template" : "Unmarked+as+template"
1401 }`
1402 );
1403 }
1404);
1405
1406// Transfer repository to a new owner (by username)
1407repoSettings.post(
1408 "/:owner/:repo/settings/transfer",
1409 requireAuth,
febd4f0Claude1410 requireRepoAccess("admin"),
71cd5ecClaude1411 async (c) => {
1412 const { owner: ownerName, repo: repoName } = c.req.param();
1413 const user = c.get("user")!;
1414 const body = await c.req.parseBody();
1415 const newOwnerName = String(body.new_owner || "").trim();
1416 if (!newOwnerName) {
1417 return c.redirect(
1418 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1419 );
1420 }
1421 const [owner] = await db
1422 .select()
1423 .from(users)
1424 .where(eq(users.username, ownerName))
1425 .limit(1);
1426 if (!owner || owner.id !== user.id) {
1427 return c.redirect(`/${ownerName}/${repoName}`);
1428 }
1429 const [newOwner] = await db
1430 .select()
1431 .from(users)
1432 .where(eq(users.username, newOwnerName))
1433 .limit(1);
1434 if (!newOwner) {
1435 return c.redirect(
1436 `/${ownerName}/${repoName}/settings?error=User+not+found`
1437 );
1438 }
1439 if (newOwner.id === owner.id) {
1440 return c.redirect(
1441 `/${ownerName}/${repoName}/settings?error=Same+owner`
1442 );
1443 }
1444 // Reject if new owner already has a repo by this name
1445 const [conflict] = await db
1446 .select()
1447 .from(repositories)
1448 .where(
1449 and(
1450 eq(repositories.ownerId, newOwner.id),
1451 eq(repositories.name, repoName)
1452 )
1453 )
1454 .limit(1);
1455 if (conflict) {
1456 return c.redirect(
1457 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1458 );
1459 }
1460 const [repo] = await db
1461 .select()
1462 .from(repositories)
1463 .where(
1464 and(
1465 eq(repositories.ownerId, owner.id),
1466 eq(repositories.name, repoName)
1467 )
1468 )
1469 .limit(1);
1470 if (!repo) return c.notFound();
1471 await db
1472 .update(repositories)
1473 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1474 .where(eq(repositories.id, repo.id));
1475 await db.insert(repoTransfers).values({
1476 repositoryId: repo.id,
1477 fromOwnerId: owner.id,
1478 fromOrgId: repo.orgId,
1479 toOwnerId: newOwner.id,
1480 toOrgId: null,
1481 initiatedBy: user.id,
1482 });
1483 return c.redirect(`/${newOwnerName}/${repoName}`);
1484 }
1485);
1486
1487// Archive / unarchive repository
1488repoSettings.post(
1489 "/:owner/:repo/settings/archive",
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.archive || "1") === "1";
1505 await db
1506 .update(repositories)
1507 .set({ isArchived: 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 ? "Repository+archived" : "Repository+unarchived"
1517 }`
1518 );
1519 }
1520);
1521
534f04aClaude1522// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1523repoSettings.post(
1524 "/:owner/:repo/settings/stale",
1525 requireAuth,
1526 requireRepoAccess("admin"),
1527 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 [owner] = await db
1532 .select()
1533 .from(users)
1534 .where(eq(users.username, ownerName))
1535 .limit(1);
1536 if (!owner || owner.id !== user.id) {
1537 return c.redirect(`/${ownerName}/${repoName}`);
1538 }
1539 const [repo] = await db
1540 .select()
1541 .from(repositories)
1542 .where(
1543 and(
1544 eq(repositories.ownerId, owner.id),
1545 eq(repositories.name, repoName)
1546 )
1547 )
1548 .limit(1);
1549 if (!repo) return c.notFound();
1550
1551 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1552 const newPrs = body.auto_close_stale_prs === "1";
1553 const newIssues = body.auto_close_stale_issues === "1";
1554
1555 await db
1556 .update(repositories)
1557 .set({
1558 autoCloseStalePrs: newPrs,
1559 autoCloseStaleIssues: newIssues,
1560 updatedAt: new Date(),
1561 })
1562 .where(eq(repositories.id, repo.id));
1563
1564 // Audit toggle deltas so the repo's audit log shows the change. Two
1565 // separate rows (one per flag) so the action names stay stable + grep-able.
1566 if (newPrs !== repo.autoCloseStalePrs) {
1567 await audit({
1568 userId: user.id,
1569 repositoryId: repo.id,
1570 action: "repo.auto_close_stale_prs.toggled",
1571 targetType: "repository",
1572 targetId: repo.id,
1573 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1574 });
1575 }
1576 if (newIssues !== repo.autoCloseStaleIssues) {
1577 await audit({
1578 userId: user.id,
1579 repositoryId: repo.id,
1580 action: "repo.auto_close_stale_issues.toggled",
1581 targetType: "repository",
1582 targetId: repo.id,
1583 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1584 });
1585 }
1586
1587 return c.redirect(
1588 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1589 );
1590 }
1591);
1592
1d4ff60Claude1593// AI test generator opt-in. Owner-only; audits the toggle delta so the
1594// repo's audit log shows the change.
1595repoSettings.post(
1596 "/:owner/:repo/settings/ai-tests",
1597 requireAuth,
1598 requireRepoAccess("admin"),
1599 async (c) => {
1600 const { owner: ownerName, repo: repoName } = c.req.param();
1601 const user = c.get("user")!;
1602 const body = await c.req.parseBody();
1603 const [owner] = await db
1604 .select()
1605 .from(users)
1606 .where(eq(users.username, ownerName))
1607 .limit(1);
1608 if (!owner || owner.id !== user.id) {
1609 return c.redirect(`/${ownerName}/${repoName}`);
1610 }
1611 const [repo] = await db
1612 .select()
1613 .from(repositories)
1614 .where(
1615 and(
1616 eq(repositories.ownerId, owner.id),
1617 eq(repositories.name, repoName)
1618 )
1619 )
1620 .limit(1);
1621 if (!repo) return c.notFound();
1622
1623 // Unchecked checkboxes are absent from the form payload, so coerce.
1624 const next = body.auto_generate_tests === "1";
1625
1626 await db
1627 .update(repositories)
1628 .set({
1629 autoGenerateTests: next,
1630 updatedAt: new Date(),
1631 })
1632 .where(eq(repositories.id, repo.id));
1633
1634 if (next !== repo.autoGenerateTests) {
1635 await audit({
1636 userId: user.id,
1637 repositoryId: repo.id,
1638 action: "repo.auto_generate_tests.toggled",
1639 targetType: "repository",
1640 targetId: repo.id,
1641 metadata: { from: repo.autoGenerateTests, to: next },
1642 });
1643 }
1644
1645 return c.redirect(
1646 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1647 );
1648 }
1649);
1650
9b3a183Claude1651// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1652// the toggle delta so the repo's audit log shows the change.
1653repoSettings.post(
1654 "/:owner/:repo/settings/dev-envs",
1655 requireAuth,
1656 requireRepoAccess("admin"),
1657 async (c) => {
1658 const { owner: ownerName, repo: repoName } = c.req.param();
1659 const user = c.get("user")!;
1660 const body = await c.req.parseBody();
1661 const [owner] = await db
1662 .select()
1663 .from(users)
1664 .where(eq(users.username, ownerName))
1665 .limit(1);
1666 if (!owner || owner.id !== user.id) {
1667 return c.redirect(`/${ownerName}/${repoName}`);
1668 }
1669 const [repo] = await db
1670 .select()
1671 .from(repositories)
1672 .where(
1673 and(
1674 eq(repositories.ownerId, owner.id),
1675 eq(repositories.name, repoName)
1676 )
1677 )
1678 .limit(1);
1679 if (!repo) return c.notFound();
1680
1681 const next = body.dev_envs_enabled === "1";
1682 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1683
1684 await db
1685 .update(repositories)
1686 .set({
1687 devEnvsEnabled: next,
1688 updatedAt: new Date(),
1689 })
1690 .where(eq(repositories.id, repo.id));
1691
1692 if (next !== prev) {
1693 await audit({
1694 userId: user.id,
1695 repositoryId: repo.id,
1696 action: "repo.dev_envs_enabled.toggled",
1697 targetType: "repository",
1698 targetId: repo.id,
1699 metadata: { from: prev, to: next },
1700 });
1701 }
1702
1703 return c.redirect(
1704 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1705 );
1706 }
1707);
1708
f5ad215Claude1709// Migration 0077 — toggle AI dependency auto-updater. Owner-only; audits
1710// the toggle delta so the repo's audit log shows the change.
1711repoSettings.post(
1712 "/:owner/:repo/settings/dep-updater",
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(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1732 )
1733 .limit(1);
1734 if (!repo) return c.notFound();
1735
1736 const next = body.dep_updater_enabled === "1";
1737 const prev =
1738 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ?? false;
1739
1740 await db
1741 .update(repositories)
1742 .set({
1743 depUpdaterEnabled: next,
1744 updatedAt: new Date(),
1745 })
1746 .where(eq(repositories.id, repo.id));
1747
1748 if (next !== prev) {
1749 await audit({
1750 userId: user.id,
1751 repositoryId: repo.id,
1752 action: "repo.dep_updater_enabled.toggled",
1753 targetType: "repository",
1754 targetId: repo.id,
1755 metadata: { from: prev, to: next },
1756 });
1757 }
1758
1759 return c.redirect(
1760 `/${ownerName}/${repoName}/settings?success=Dependency+update+settings+saved#dep-updater`
1761 );
1762 }
1763);
1764
79136bbClaude1765// Delete repository
1766repoSettings.post(
1767 "/:owner/:repo/settings/delete",
1768 requireAuth,
febd4f0Claude1769 requireRepoAccess("admin"),
79136bbClaude1770 async (c) => {
1771 const { owner: ownerName, repo: repoName } = c.req.param();
1772 const user = c.get("user")!;
1773
1774 const [owner] = await db
1775 .select()
1776 .from(users)
1777 .where(eq(users.username, ownerName))
1778 .limit(1);
1779
1780 if (!owner || owner.id !== user.id) {
1781 return c.redirect(`/${ownerName}/${repoName}`);
1782 }
1783
1784 const [repo] = await db
1785 .select()
1786 .from(repositories)
1787 .where(
1788 and(
1789 eq(repositories.ownerId, owner.id),
1790 eq(repositories.name, repoName)
1791 )
1792 )
1793 .limit(1);
1794
1795 if (!repo) return c.redirect(`/${ownerName}`);
1796
1797 // Delete from disk
1798 try {
1799 await rm(repo.diskPath, { recursive: true, force: true });
1800 } catch {
1801 // Disk cleanup best-effort
1802 }
1803
1804 // Delete from DB (cascades to stars, issues, etc.)
1805 await db.delete(repositories).where(eq(repositories.id, repo.id));
1806
1807 return c.redirect(`/${ownerName}`);
1808 }
1809);
1810
1df50d5Claude1811// Migration 0077 — PR preview build configuration. Owner-only.
1812repoSettings.post(
1813 "/:owner/:repo/settings/previews",
1814 requireAuth,
1815 requireRepoAccess("admin"),
1816 async (c) => {
1817 const { owner: ownerName, repo: repoName } = c.req.param();
1818 const user = c.get("user")!;
1819 const body = await c.req.parseBody();
1820
1821 const [owner] = await db
1822 .select()
1823 .from(users)
1824 .where(eq(users.username, ownerName))
1825 .limit(1);
1826 if (!owner || owner.id !== user.id) {
1827 return c.redirect(`/${ownerName}/${repoName}`);
1828 }
1829
1830 const [repo] = await db
1831 .select()
1832 .from(repositories)
1833 .where(
1834 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1835 )
1836 .limit(1);
1837 if (!repo) return c.notFound();
1838
1839 const buildCommand = String(body.preview_build_command || "").trim() || null;
1840 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1841
1842 await db
1843 .update(repositories)
1844 .set({
1845 previewBuildCommand: buildCommand,
1846 previewOutputDir: outputDir,
1847 updatedAt: new Date(),
1848 })
1849 .where(eq(repositories.id, repo.id));
1850
1851 return c.redirect(
1852 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1853 );
1854 }
1855);
1856
ec9e3e3Claude1857// ─── Branch protection: add rule ───────────────────────────────────────────
1858repoSettings.post(
1859 "/:owner/:repo/settings/branch-protection",
1860 requireAuth,
1861 requireRepoAccess("admin"),
1862 async (c) => {
1863 const { owner: ownerName, repo: repoName } = c.req.param();
1864 const user = c.get("user")!;
1865 const body = await c.req.parseBody();
1866
1867 const [owner] = await db
1868 .select()
1869 .from(users)
1870 .where(eq(users.username, ownerName))
1871 .limit(1);
1872 if (!owner || owner.id !== user.id) {
1873 return c.redirect(`/${ownerName}/${repoName}`);
1874 }
1875
1876 const [repo] = await db
1877 .select()
1878 .from(repositories)
1879 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1880 .limit(1);
1881 if (!repo) return c.notFound();
1882
1883 const pattern = String(body.pattern || "").trim();
1884 if (!pattern) {
1885 return c.redirect(
1886 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
1887 );
1888 }
1889
1890 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
1891 const requireHumanReview = body.require_codeowner_review === "1";
1892 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
1893
1894 try {
1895 await db
1896 .insert(branchProtection)
1897 .values({
1898 repositoryId: repo.id,
1899 pattern,
1900 requiredApprovals,
1901 requireHumanReview,
1902 dismissStaleReviews,
1903 })
1904 .onConflictDoUpdate({
1905 target: [branchProtection.repositoryId, branchProtection.pattern],
1906 set: {
1907 requiredApprovals,
1908 requireHumanReview,
1909 dismissStaleReviews,
1910 updatedAt: new Date(),
1911 },
1912 });
1913
1914 await audit({
1915 userId: user.id,
1916 repositoryId: repo.id,
1917 action: "branch_protection.updated",
1918 targetType: "repository",
1919 targetId: repo.id,
1920 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
1921 });
1922 } catch (err) {
1923 return c.redirect(
1924 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
1925 );
1926 }
1927
1928 return c.redirect(
1929 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
1930 );
1931 }
1932);
1933
1934// ─── Branch protection: delete rule ────────────────────────────────────────
1935repoSettings.post(
1936 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
1937 requireAuth,
1938 requireRepoAccess("admin"),
1939 async (c) => {
1940 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
1941 const user = c.get("user")!;
1942
1943 const [owner] = await db
1944 .select()
1945 .from(users)
1946 .where(eq(users.username, ownerName))
1947 .limit(1);
1948 if (!owner || owner.id !== user.id) {
1949 return c.redirect(`/${ownerName}/${repoName}`);
1950 }
1951
1952 const [repo] = await db
1953 .select()
1954 .from(repositories)
1955 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1956 .limit(1);
1957 if (!repo) return c.notFound();
1958
1959 await db
1960 .delete(branchProtection)
1961 .where(
1962 and(
1963 eq(branchProtection.id, ruleId),
1964 eq(branchProtection.repositoryId, repo.id)
1965 )
1966 );
1967
1968 await audit({
1969 userId: user.id,
1970 repositoryId: repo.id,
1971 action: "branch_protection.deleted",
1972 targetType: "repository",
1973 targetId: repo.id,
1974 metadata: { ruleId },
1975 });
1976
1977 return c.redirect(
1978 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
1979 );
1980 }
1981);
1982
79136bbClaude1983export default repoSettings;