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.tsxBlame1848 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
754 {/* ─── Template repository ─── */}
755 <section class="repo-settings-section">
756 <div class="repo-settings-section-head">
757 <div class="repo-settings-section-eyebrow">Template</div>
758 <h2 class="repo-settings-section-title">Template repository</h2>
759 <p class="repo-settings-section-desc">
71cd5ecClaude760 {repo.isTemplate
58307aeClaude761 ? "This repository is a template. Users can click “Use this template” to create a new repository with the same files."
762 : "Mark this repository as a template so others can seed new repositories from its files."}
763 </p>
764 </div>
765 <div class="repo-settings-section-body">
766 <span
767 class={`repo-settings-pill ${repo.isTemplate ? "is-on" : "is-off"}`}
768 >
769 <span class="dot" aria-hidden="true" />
770 {repo.isTemplate ? "Template enabled" : "Not a template"}
771 </span>
772 <form
773 method="post"
774 action={`/${ownerName}/${repoName}/settings/template`}
775 style="margin-top: var(--space-3)"
776 >
777 <input
778 type="hidden"
779 name="template"
780 value={repo.isTemplate ? "0" : "1"}
781 />
782 <button type="submit" class="repo-settings-cta-secondary">
783 {repo.isTemplate ? "Unmark as template" : "Mark as template"}
784 </button>
785 </form>
786 </div>
787 </section>
71cd5ecClaude788
58307aeClaude789 {/* ─── Transfer ownership ─── */}
790 <section class="repo-settings-section">
791 <div class="repo-settings-section-head">
792 <div class="repo-settings-section-eyebrow">Ownership</div>
793 <h2 class="repo-settings-section-title">Transfer ownership</h2>
794 <p class="repo-settings-section-desc">
795 Hand this repository to another user. The new owner can accept
796 or decline the transfer by viewing it.
797 </p>
798 </div>
71cd5ecClaude799 <form
e7e240eClaude800 method="post"
71cd5ecClaude801 action={`/${ownerName}/${repoName}/settings/transfer`}
802 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
803 >
58307aeClaude804 <div class="repo-settings-section-body">
805 <div class="repo-settings-field">
806 <label class="repo-settings-field-label" for="new_owner">
807 New owner username
808 </label>
809 <div class="repo-settings-inline-row">
810 <input
811 type="text"
812 name="new_owner"
813 id="new_owner"
814 class="repo-settings-input"
815 placeholder="new-owner-username"
816 required
817 aria-label="New owner username"
818 />
819 <button type="submit" class="repo-settings-cta-secondary">
820 Transfer
821 </button>
822 </div>
823 <div class="repo-settings-field-hint">
824 Transfers are immediate. The new owner can rename or delete
825 the repository at any time.
826 </div>
827 </div>
828 </div>
71cd5ecClaude829 </form>
58307aeClaude830 </section>
71cd5ecClaude831
58307aeClaude832 {/* ─── Archive ─── */}
833 <section class="repo-settings-section">
834 <div class="repo-settings-section-head">
835 <div class="repo-settings-section-eyebrow">Lifecycle</div>
836 <h2 class="repo-settings-section-title">
837 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
838 </h2>
839 <p class="repo-settings-section-desc">
840 {repo.isArchived
841 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
842 : "Mark this repository as archived. It becomes read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
843 </p>
844 </div>
845 <div class="repo-settings-section-body">
846 <span
847 class={`repo-settings-pill ${repo.isArchived ? "is-on" : "is-off"}`}
534f04aClaude848 >
58307aeClaude849 <span class="dot" aria-hidden="true" />
850 {repo.isArchived ? "Archived" : "Active"}
851 </span>
852 <form
853 method="post"
854 action={`/${ownerName}/${repoName}/settings/archive`}
855 style="margin-top: var(--space-3)"
534f04aClaude856 >
857 <input
58307aeClaude858 type="hidden"
859 name="archive"
860 value={repo.isArchived ? "0" : "1"}
534f04aClaude861 />
58307aeClaude862 <button type="submit" class="repo-settings-cta-secondary">
863 {repo.isArchived ? "Unarchive" : "Archive"} this repository
864 </button>
865 </form>
866 </div>
867 </section>
534f04aClaude868
1d4ff60Claude869 {/* ─── AI test generator ─── */}
870 <section class="repo-settings-section">
871 <div class="repo-settings-section-head">
872 <div class="repo-settings-section-eyebrow">AI tests</div>
873 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
874 <p class="repo-settings-section-desc">
875 When a pull request opens, Gluecron AI reads the diff and writes
876 tests for the new code, matching whatever framework your repo
877 already uses. Tests land on the same branch. Default off —
878 opt in here.
879 </p>
880 </div>
881 <form
882 method="post"
883 action={`/${ownerName}/${repoName}/settings/ai-tests`}
884 >
885 <div class="repo-settings-section-body">
886 <label
887 class="repo-settings-toggle-row"
888 aria-label="Auto-generate tests when a PR opens"
889 >
890 <input
891 type="checkbox"
892 name="auto_generate_tests"
893 value="1"
894 checked={repo.autoGenerateTests}
895 />
896 <span class="repo-settings-toggle-text">
897 <span class="repo-settings-toggle-text-title">
898 Auto-generate tests on PR open
899 </span>
900 <span class="repo-settings-toggle-text-hint">
901 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
902 source-file changes and PRs already opened by AI.
903 </span>
904 </span>
905 </label>
906 </div>
907 <div class="repo-settings-section-foot">
908 <button type="submit" class="repo-settings-cta">
909 Save AI test settings <span class="arrow">→</span>
910 </button>
911 </div>
912 </form>
913 </section>
914
9b3a183Claude915 {/* ─── Cloud dev environments ─── */}
916 <section
917 id="dev-envs"
918 class="repo-settings-section"
919 >
920 <div class="repo-settings-section-head">
921 <div class="repo-settings-section-eyebrow">Dev environments</div>
922 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
923 <p class="repo-settings-section-desc">
924 When enabled, anyone with read access can hit{" "}
925 <code>/{ownerName}/{repoName}/dev</code> to spin up a
926 hosted VS Code IDE in the browser, backed by a cold-start
927 container. We read <code>.gluecron/dev.yml</code> for the
928 image + install commands; idle envs stop themselves after
929 30 minutes. Default off — each env burns a container.
930 </p>
931 </div>
932 <form
933 method="post"
934 action={`/${ownerName}/${repoName}/settings/dev-envs`}
935 >
936 <div class="repo-settings-section-body">
937 <label
938 class="repo-settings-toggle-row"
939 aria-label="Enable cloud dev environments for this repo"
940 >
941 <input
942 type="checkbox"
943 name="dev_envs_enabled"
944 value="1"
945 checked={
946 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
947 false
948 }
949 />
950 <span class="repo-settings-toggle-text">
951 <span class="repo-settings-toggle-text-title">
952 Enable dev environments
953 </span>
954 <span class="repo-settings-toggle-text-hint">
955 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
956 route. Commit <code>.gluecron/dev.yml</code> to your
957 repo to customise the image, ports, and extensions.
958 </span>
959 </span>
960 </label>
961 </div>
962 <div class="repo-settings-section-foot">
963 <button type="submit" class="repo-settings-cta">
964 Save dev env settings <span class="arrow">→</span>
965 </button>
966 </div>
967 </form>
968 </section>
969
58307aeClaude970 {/* ─── Stale activity ─── */}
971 <section class="repo-settings-section">
972 <div class="repo-settings-section-head">
973 <div class="repo-settings-section-eyebrow">Stale sweep</div>
974 <h2 class="repo-settings-section-title">Stale activity</h2>
975 <p class="repo-settings-section-desc">
976 Autopilot pokes PRs and issues that have gone quiet, then offers
977 a one-click close path. Each toggle controls the final close
978 step — pokes always happen, but they&apos;re harmless reminders.
979 </p>
980 </div>
79136bbClaude981 <form
e7e240eClaude982 method="post"
58307aeClaude983 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude984 >
58307aeClaude985 <div class="repo-settings-section-body">
986 <label
987 class="repo-settings-toggle-row"
988 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
989 >
990 <input
991 type="checkbox"
992 name="auto_close_stale_prs"
993 value="1"
994 checked={repo.autoCloseStalePrs}
995 />
996 <span class="repo-settings-toggle-text">
997 <span class="repo-settings-toggle-text-title">
998 Auto-close stale PRs
999 </span>
1000 <span class="repo-settings-toggle-text-hint">
1001 Close PRs that go quiet for 14 days after autopilot pokes
1002 them.
1003 </span>
1004 </span>
1005 </label>
1006 <label
1007 class="repo-settings-toggle-row"
1008 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
1009 >
1010 <input
1011 type="checkbox"
1012 name="auto_close_stale_issues"
1013 value="1"
1014 checked={repo.autoCloseStaleIssues}
1015 />
1016 <span class="repo-settings-toggle-text">
1017 <span class="repo-settings-toggle-text-title">
1018 Auto-close stale issues
1019 </span>
1020 <span class="repo-settings-toggle-text-hint">
1021 Close issues that go quiet for 60 days after autopilot
1022 pokes them.
1023 </span>
1024 </span>
1025 </label>
1026 </div>
1027 <div class="repo-settings-section-foot">
1028 <button type="submit" class="repo-settings-cta">
1029 Save stale settings <span class="arrow">→</span>
1030 </button>
1031 </div>
79136bbClaude1032 </form>
58307aeClaude1033 </section>
1034
1df50d5Claude1035 {/* ─── PR preview builds (migration 0077) ─── */}
1036 <section class="repo-settings-section">
1037 <div class="repo-settings-section-head">
1038 <div class="repo-settings-section-eyebrow">Preview builds</div>
1039 <h2 class="repo-settings-section-title">PR preview environments</h2>
1040 <p class="repo-settings-section-desc">
1041 When a build command is set, every PR push automatically clones
1042 the branch, runs the command, and serves the built output from a
1043 unique preview URL — like Vercel, but native to Gluecron. Leave
1044 blank to use URL-only previews (no build runs).
1045 </p>
1046 </div>
1047 <form
1048 method="post"
1049 action={`/${ownerName}/${repoName}/settings/previews`}
1050 >
1051 <div class="repo-settings-section-body">
1052 <div class="repo-settings-field">
1053 <label class="repo-settings-field-label" for="preview_build_command">
1054 Build command
1055 </label>
1056 <input
1057 id="preview_build_command"
1058 class="repo-settings-input"
1059 type="text"
1060 name="preview_build_command"
1061 placeholder="npm run build"
1062 value={
1063 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1064 }
1065 />
1066 <p class="repo-settings-field-hint">
1067 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1068 <code>hugo</code>. Runs inside the cloned branch directory.
1069 </p>
1070 </div>
1071 <div class="repo-settings-field">
1072 <label class="repo-settings-field-label" for="preview_output_dir">
1073 Output directory
1074 </label>
1075 <input
1076 id="preview_output_dir"
1077 class="repo-settings-input"
1078 type="text"
1079 name="preview_output_dir"
1080 placeholder="dist"
1081 value={
1082 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1083 }
1084 />
1085 <p class="repo-settings-field-hint">
1086 The directory your build writes to. Common values:{" "}
1087 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1088 <code>build</code>. Served from{" "}
1089 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1090 </p>
1091 </div>
1092 </div>
1093 <div class="repo-settings-section-foot">
1094 <button type="submit" class="repo-settings-cta">
1095 Save preview settings <span class="arrow">→</span>
1096 </button>
1097 </div>
1098 </form>
1099 </section>
1100
ec9e3e3Claude1101 {/* ─── Branch protection ─── */}
1102 <section class="repo-settings-section" id="branch-protection">
1103 <div class="repo-settings-section-head">
1104 <div class="repo-settings-section-eyebrow">Branch protection</div>
1105 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1106 <p class="repo-settings-section-desc">
1107 Protect branches by requiring a minimum number of human approvals before
1108 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1109 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1110 </p>
1111 </div>
1112
1113 {/* Existing rules list */}
1114 {existingBranchRules.length > 0 && (
1115 <div style="padding: var(--space-3) var(--space-5) 0">
1116 {existingBranchRules.map((rule) => (
1117 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1118 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1119 {rule.pattern}
1120 </span>
1121 <span style="font-size:12.5px;color:var(--text-muted)">
1122 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1123 </span>
1124 {rule.requireHumanReview && (
1125 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1126 codeowner review required
1127 </span>
1128 )}
1129 {rule.dismissStaleReviews && (
1130 <span style="font-size:11px;color:var(--text-muted)">
1131 dismiss stale
1132 </span>
1133 )}
1134 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1135 <button type="submit"
1136 class="repo-settings-cta-secondary"
1137 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1138 onclick="return confirm('Delete this branch protection rule?')"
1139 >
1140 Delete
1141 </button>
1142 </form>
1143 </div>
1144 ))}
1145 </div>
1146 )}
1147
1148 {/* Add new rule form */}
1149 <form
1150 method="post"
1151 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1152 >
1153 <div class="repo-settings-section-body">
1154 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1155 <div class="repo-settings-field" style="margin-bottom:0">
1156 <label class="repo-settings-field-label" for="bp_pattern">
1157 Branch name pattern
1158 </label>
1159 <input
1160 class="repo-settings-input"
1161 name="pattern"
1162 id="bp_pattern"
1163 placeholder="main"
1164 required
1165 aria-label="Branch name pattern"
1166 />
1167 <div class="repo-settings-field-hint">
1168 Exact name or glob like <code>release/*</code>
1169 </div>
1170 </div>
1171 <div class="repo-settings-field" style="margin-bottom:0">
1172 <label class="repo-settings-field-label" for="bp_required">
1173 Required approvals
1174 </label>
1175 <input
1176 class="repo-settings-input"
1177 name="required_approvals"
1178 id="bp_required"
1179 type="number"
1180 min="0"
1181 max="10"
1182 value="1"
1183 aria-label="Number of required approvals"
1184 />
1185 </div>
1186 </div>
1187 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1188 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1189 <input
1190 type="checkbox"
1191 name="require_codeowner_review"
1192 value="1"
1193 />
1194 <span class="repo-settings-toggle-text">
1195 <span class="repo-settings-toggle-text-title">
1196 Require codeowner review
1197 </span>
1198 <span class="repo-settings-toggle-text-hint">
1199 At least one CODEOWNERS-matched reviewer must approve before merging.
1200 </span>
1201 </span>
1202 </label>
1203 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1204 <input
1205 type="checkbox"
1206 name="dismiss_stale_reviews"
1207 value="1"
1208 />
1209 <span class="repo-settings-toggle-text">
1210 <span class="repo-settings-toggle-text-title">
1211 Dismiss stale reviews on new push
1212 </span>
1213 <span class="repo-settings-toggle-text-hint">
1214 Prior approvals are dismissed when the head branch receives a new commit.
1215 </span>
1216 </span>
1217 </label>
1218 </div>
1219 </div>
1220 <div class="repo-settings-section-foot">
1221 <button type="submit" class="repo-settings-cta">
1222 Add rule <span class="arrow">→</span>
1223 </button>
1224 </div>
1225 </form>
1226 </section>
1227
58307aeClaude1228 {/* ─── Danger zone ─── */}
1229 <section class="repo-settings-danger">
1230 <div class="repo-settings-danger-head">
1231 <div class="repo-settings-danger-eyebrow">Danger zone</div>
1232 <h2 class="repo-settings-danger-title">Delete this repository</h2>
1233 <p class="repo-settings-danger-desc">
1234 Permanently remove this repository and every byte of its history,
1235 issues, PRs, stars, and webhooks. There is no undo.
1236 </p>
1237 </div>
1238 <div class="repo-settings-danger-body">
1239 <p class="muted">
1240 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
1241 up immediately. Open clones will fail to fetch or push.
1242 </p>
1243 <form
1244 method="post"
1245 action={`/${ownerName}/${repoName}/settings/delete`}
1246 onsubmit="return confirm('Are you sure? This cannot be undone.')"
1247 >
1248 <button type="submit" class="repo-settings-danger-btn">
1249 Delete this repository
1250 </button>
1251 </form>
1252 </div>
1253 </section>
1254 </div>
79136bbClaude1255 </Layout>
1256 );
1257});
1258
1259// Save settings
febd4f0Claude1260repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude1261 const { owner: ownerName, repo: repoName } = c.req.param();
1262 const user = c.get("user")!;
1263 const body = await c.req.parseBody();
1264
1265 const [owner] = await db
1266 .select()
1267 .from(users)
1268 .where(eq(users.username, ownerName))
1269 .limit(1);
1270
1271 if (!owner || owner.id !== user.id) {
1272 return c.redirect(`/${ownerName}/${repoName}`);
1273 }
1274
1275 await db
1276 .update(repositories)
1277 .set({
1278 description: String(body.description || "").trim() || null,
1279 defaultBranch: String(body.default_branch || "main"),
1280 isPrivate: body.visibility === "private",
1281 updatedAt: new Date(),
1282 })
1283 .where(
1284 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1285 );
1286
1287 return c.redirect(
1288 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1289 );
1290});
1291
71cd5ecClaude1292// Toggle template flag
1293repoSettings.post(
1294 "/:owner/:repo/settings/template",
1295 requireAuth,
febd4f0Claude1296 requireRepoAccess("admin"),
71cd5ecClaude1297 async (c) => {
1298 const { owner: ownerName, repo: repoName } = c.req.param();
1299 const user = c.get("user")!;
1300 const body = await c.req.parseBody();
1301 const [owner] = await db
1302 .select()
1303 .from(users)
1304 .where(eq(users.username, ownerName))
1305 .limit(1);
1306 if (!owner || owner.id !== user.id) {
1307 return c.redirect(`/${ownerName}/${repoName}`);
1308 }
1309 const target = String(body.template || "1") === "1";
1310 await db
1311 .update(repositories)
1312 .set({ isTemplate: target, updatedAt: new Date() })
1313 .where(
1314 and(
1315 eq(repositories.ownerId, owner.id),
1316 eq(repositories.name, repoName)
1317 )
1318 );
1319 return c.redirect(
1320 `/${ownerName}/${repoName}/settings?success=${
1321 target ? "Marked+as+template" : "Unmarked+as+template"
1322 }`
1323 );
1324 }
1325);
1326
1327// Transfer repository to a new owner (by username)
1328repoSettings.post(
1329 "/:owner/:repo/settings/transfer",
1330 requireAuth,
febd4f0Claude1331 requireRepoAccess("admin"),
71cd5ecClaude1332 async (c) => {
1333 const { owner: ownerName, repo: repoName } = c.req.param();
1334 const user = c.get("user")!;
1335 const body = await c.req.parseBody();
1336 const newOwnerName = String(body.new_owner || "").trim();
1337 if (!newOwnerName) {
1338 return c.redirect(
1339 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1340 );
1341 }
1342 const [owner] = await db
1343 .select()
1344 .from(users)
1345 .where(eq(users.username, ownerName))
1346 .limit(1);
1347 if (!owner || owner.id !== user.id) {
1348 return c.redirect(`/${ownerName}/${repoName}`);
1349 }
1350 const [newOwner] = await db
1351 .select()
1352 .from(users)
1353 .where(eq(users.username, newOwnerName))
1354 .limit(1);
1355 if (!newOwner) {
1356 return c.redirect(
1357 `/${ownerName}/${repoName}/settings?error=User+not+found`
1358 );
1359 }
1360 if (newOwner.id === owner.id) {
1361 return c.redirect(
1362 `/${ownerName}/${repoName}/settings?error=Same+owner`
1363 );
1364 }
1365 // Reject if new owner already has a repo by this name
1366 const [conflict] = await db
1367 .select()
1368 .from(repositories)
1369 .where(
1370 and(
1371 eq(repositories.ownerId, newOwner.id),
1372 eq(repositories.name, repoName)
1373 )
1374 )
1375 .limit(1);
1376 if (conflict) {
1377 return c.redirect(
1378 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1379 );
1380 }
1381 const [repo] = await db
1382 .select()
1383 .from(repositories)
1384 .where(
1385 and(
1386 eq(repositories.ownerId, owner.id),
1387 eq(repositories.name, repoName)
1388 )
1389 )
1390 .limit(1);
1391 if (!repo) return c.notFound();
1392 await db
1393 .update(repositories)
1394 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1395 .where(eq(repositories.id, repo.id));
1396 await db.insert(repoTransfers).values({
1397 repositoryId: repo.id,
1398 fromOwnerId: owner.id,
1399 fromOrgId: repo.orgId,
1400 toOwnerId: newOwner.id,
1401 toOrgId: null,
1402 initiatedBy: user.id,
1403 });
1404 return c.redirect(`/${newOwnerName}/${repoName}`);
1405 }
1406);
1407
1408// Archive / unarchive repository
1409repoSettings.post(
1410 "/:owner/:repo/settings/archive",
1411 requireAuth,
febd4f0Claude1412 requireRepoAccess("admin"),
71cd5ecClaude1413 async (c) => {
1414 const { owner: ownerName, repo: repoName } = c.req.param();
1415 const user = c.get("user")!;
1416 const body = await c.req.parseBody();
1417 const [owner] = await db
1418 .select()
1419 .from(users)
1420 .where(eq(users.username, ownerName))
1421 .limit(1);
1422 if (!owner || owner.id !== user.id) {
1423 return c.redirect(`/${ownerName}/${repoName}`);
1424 }
1425 const target = String(body.archive || "1") === "1";
1426 await db
1427 .update(repositories)
1428 .set({ isArchived: target, updatedAt: new Date() })
1429 .where(
1430 and(
1431 eq(repositories.ownerId, owner.id),
1432 eq(repositories.name, repoName)
1433 )
1434 );
1435 return c.redirect(
1436 `/${ownerName}/${repoName}/settings?success=${
1437 target ? "Repository+archived" : "Repository+unarchived"
1438 }`
1439 );
1440 }
1441);
1442
534f04aClaude1443// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1444repoSettings.post(
1445 "/:owner/:repo/settings/stale",
1446 requireAuth,
1447 requireRepoAccess("admin"),
1448 async (c) => {
1449 const { owner: ownerName, repo: repoName } = c.req.param();
1450 const user = c.get("user")!;
1451 const body = await c.req.parseBody();
1452 const [owner] = await db
1453 .select()
1454 .from(users)
1455 .where(eq(users.username, ownerName))
1456 .limit(1);
1457 if (!owner || owner.id !== user.id) {
1458 return c.redirect(`/${ownerName}/${repoName}`);
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
1472 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1473 const newPrs = body.auto_close_stale_prs === "1";
1474 const newIssues = body.auto_close_stale_issues === "1";
1475
1476 await db
1477 .update(repositories)
1478 .set({
1479 autoCloseStalePrs: newPrs,
1480 autoCloseStaleIssues: newIssues,
1481 updatedAt: new Date(),
1482 })
1483 .where(eq(repositories.id, repo.id));
1484
1485 // Audit toggle deltas so the repo's audit log shows the change. Two
1486 // separate rows (one per flag) so the action names stay stable + grep-able.
1487 if (newPrs !== repo.autoCloseStalePrs) {
1488 await audit({
1489 userId: user.id,
1490 repositoryId: repo.id,
1491 action: "repo.auto_close_stale_prs.toggled",
1492 targetType: "repository",
1493 targetId: repo.id,
1494 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1495 });
1496 }
1497 if (newIssues !== repo.autoCloseStaleIssues) {
1498 await audit({
1499 userId: user.id,
1500 repositoryId: repo.id,
1501 action: "repo.auto_close_stale_issues.toggled",
1502 targetType: "repository",
1503 targetId: repo.id,
1504 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1505 });
1506 }
1507
1508 return c.redirect(
1509 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1510 );
1511 }
1512);
1513
1d4ff60Claude1514// AI test generator opt-in. Owner-only; audits the toggle delta so the
1515// repo's audit log shows the change.
1516repoSettings.post(
1517 "/:owner/:repo/settings/ai-tests",
1518 requireAuth,
1519 requireRepoAccess("admin"),
1520 async (c) => {
1521 const { owner: ownerName, repo: repoName } = c.req.param();
1522 const user = c.get("user")!;
1523 const body = await c.req.parseBody();
1524 const [owner] = await db
1525 .select()
1526 .from(users)
1527 .where(eq(users.username, ownerName))
1528 .limit(1);
1529 if (!owner || owner.id !== user.id) {
1530 return c.redirect(`/${ownerName}/${repoName}`);
1531 }
1532 const [repo] = await db
1533 .select()
1534 .from(repositories)
1535 .where(
1536 and(
1537 eq(repositories.ownerId, owner.id),
1538 eq(repositories.name, repoName)
1539 )
1540 )
1541 .limit(1);
1542 if (!repo) return c.notFound();
1543
1544 // Unchecked checkboxes are absent from the form payload, so coerce.
1545 const next = body.auto_generate_tests === "1";
1546
1547 await db
1548 .update(repositories)
1549 .set({
1550 autoGenerateTests: next,
1551 updatedAt: new Date(),
1552 })
1553 .where(eq(repositories.id, repo.id));
1554
1555 if (next !== repo.autoGenerateTests) {
1556 await audit({
1557 userId: user.id,
1558 repositoryId: repo.id,
1559 action: "repo.auto_generate_tests.toggled",
1560 targetType: "repository",
1561 targetId: repo.id,
1562 metadata: { from: repo.autoGenerateTests, to: next },
1563 });
1564 }
1565
1566 return c.redirect(
1567 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1568 );
1569 }
1570);
1571
9b3a183Claude1572// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1573// the toggle delta so the repo's audit log shows the change.
1574repoSettings.post(
1575 "/:owner/:repo/settings/dev-envs",
1576 requireAuth,
1577 requireRepoAccess("admin"),
1578 async (c) => {
1579 const { owner: ownerName, repo: repoName } = c.req.param();
1580 const user = c.get("user")!;
1581 const body = await c.req.parseBody();
1582 const [owner] = await db
1583 .select()
1584 .from(users)
1585 .where(eq(users.username, ownerName))
1586 .limit(1);
1587 if (!owner || owner.id !== user.id) {
1588 return c.redirect(`/${ownerName}/${repoName}`);
1589 }
1590 const [repo] = await db
1591 .select()
1592 .from(repositories)
1593 .where(
1594 and(
1595 eq(repositories.ownerId, owner.id),
1596 eq(repositories.name, repoName)
1597 )
1598 )
1599 .limit(1);
1600 if (!repo) return c.notFound();
1601
1602 const next = body.dev_envs_enabled === "1";
1603 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1604
1605 await db
1606 .update(repositories)
1607 .set({
1608 devEnvsEnabled: next,
1609 updatedAt: new Date(),
1610 })
1611 .where(eq(repositories.id, repo.id));
1612
1613 if (next !== prev) {
1614 await audit({
1615 userId: user.id,
1616 repositoryId: repo.id,
1617 action: "repo.dev_envs_enabled.toggled",
1618 targetType: "repository",
1619 targetId: repo.id,
1620 metadata: { from: prev, to: next },
1621 });
1622 }
1623
1624 return c.redirect(
1625 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1626 );
1627 }
1628);
1629
79136bbClaude1630// Delete repository
1631repoSettings.post(
1632 "/:owner/:repo/settings/delete",
1633 requireAuth,
febd4f0Claude1634 requireRepoAccess("admin"),
79136bbClaude1635 async (c) => {
1636 const { owner: ownerName, repo: repoName } = c.req.param();
1637 const user = c.get("user")!;
1638
1639 const [owner] = await db
1640 .select()
1641 .from(users)
1642 .where(eq(users.username, ownerName))
1643 .limit(1);
1644
1645 if (!owner || owner.id !== user.id) {
1646 return c.redirect(`/${ownerName}/${repoName}`);
1647 }
1648
1649 const [repo] = await db
1650 .select()
1651 .from(repositories)
1652 .where(
1653 and(
1654 eq(repositories.ownerId, owner.id),
1655 eq(repositories.name, repoName)
1656 )
1657 )
1658 .limit(1);
1659
1660 if (!repo) return c.redirect(`/${ownerName}`);
1661
1662 // Delete from disk
1663 try {
1664 await rm(repo.diskPath, { recursive: true, force: true });
1665 } catch {
1666 // Disk cleanup best-effort
1667 }
1668
1669 // Delete from DB (cascades to stars, issues, etc.)
1670 await db.delete(repositories).where(eq(repositories.id, repo.id));
1671
1672 return c.redirect(`/${ownerName}`);
1673 }
1674);
1675
1df50d5Claude1676// Migration 0077 — PR preview build configuration. Owner-only.
1677repoSettings.post(
1678 "/:owner/:repo/settings/previews",
1679 requireAuth,
1680 requireRepoAccess("admin"),
1681 async (c) => {
1682 const { owner: ownerName, repo: repoName } = c.req.param();
1683 const user = c.get("user")!;
1684 const body = await c.req.parseBody();
1685
1686 const [owner] = await db
1687 .select()
1688 .from(users)
1689 .where(eq(users.username, ownerName))
1690 .limit(1);
1691 if (!owner || owner.id !== user.id) {
1692 return c.redirect(`/${ownerName}/${repoName}`);
1693 }
1694
1695 const [repo] = await db
1696 .select()
1697 .from(repositories)
1698 .where(
1699 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1700 )
1701 .limit(1);
1702 if (!repo) return c.notFound();
1703
1704 const buildCommand = String(body.preview_build_command || "").trim() || null;
1705 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1706
1707 await db
1708 .update(repositories)
1709 .set({
1710 previewBuildCommand: buildCommand,
1711 previewOutputDir: outputDir,
1712 updatedAt: new Date(),
1713 })
1714 .where(eq(repositories.id, repo.id));
1715
1716 return c.redirect(
1717 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1718 );
1719 }
1720);
1721
ec9e3e3Claude1722// ─── Branch protection: add rule ───────────────────────────────────────────
1723repoSettings.post(
1724 "/:owner/:repo/settings/branch-protection",
1725 requireAuth,
1726 requireRepoAccess("admin"),
1727 async (c) => {
1728 const { owner: ownerName, repo: repoName } = c.req.param();
1729 const user = c.get("user")!;
1730 const body = await c.req.parseBody();
1731
1732 const [owner] = await db
1733 .select()
1734 .from(users)
1735 .where(eq(users.username, ownerName))
1736 .limit(1);
1737 if (!owner || owner.id !== user.id) {
1738 return c.redirect(`/${ownerName}/${repoName}`);
1739 }
1740
1741 const [repo] = await db
1742 .select()
1743 .from(repositories)
1744 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1745 .limit(1);
1746 if (!repo) return c.notFound();
1747
1748 const pattern = String(body.pattern || "").trim();
1749 if (!pattern) {
1750 return c.redirect(
1751 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
1752 );
1753 }
1754
1755 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
1756 const requireHumanReview = body.require_codeowner_review === "1";
1757 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
1758
1759 try {
1760 await db
1761 .insert(branchProtection)
1762 .values({
1763 repositoryId: repo.id,
1764 pattern,
1765 requiredApprovals,
1766 requireHumanReview,
1767 dismissStaleReviews,
1768 })
1769 .onConflictDoUpdate({
1770 target: [branchProtection.repositoryId, branchProtection.pattern],
1771 set: {
1772 requiredApprovals,
1773 requireHumanReview,
1774 dismissStaleReviews,
1775 updatedAt: new Date(),
1776 },
1777 });
1778
1779 await audit({
1780 userId: user.id,
1781 repositoryId: repo.id,
1782 action: "branch_protection.updated",
1783 targetType: "repository",
1784 targetId: repo.id,
1785 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
1786 });
1787 } catch (err) {
1788 return c.redirect(
1789 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
1790 );
1791 }
1792
1793 return c.redirect(
1794 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
1795 );
1796 }
1797);
1798
1799// ─── Branch protection: delete rule ────────────────────────────────────────
1800repoSettings.post(
1801 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
1802 requireAuth,
1803 requireRepoAccess("admin"),
1804 async (c) => {
1805 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
1806 const user = c.get("user")!;
1807
1808 const [owner] = await db
1809 .select()
1810 .from(users)
1811 .where(eq(users.username, ownerName))
1812 .limit(1);
1813 if (!owner || owner.id !== user.id) {
1814 return c.redirect(`/${ownerName}/${repoName}`);
1815 }
1816
1817 const [repo] = await db
1818 .select()
1819 .from(repositories)
1820 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1821 .limit(1);
1822 if (!repo) return c.notFound();
1823
1824 await db
1825 .delete(branchProtection)
1826 .where(
1827 and(
1828 eq(branchProtection.id, ruleId),
1829 eq(branchProtection.repositoryId, repo.id)
1830 )
1831 );
1832
1833 await audit({
1834 userId: user.id,
1835 repositoryId: repo.id,
1836 action: "branch_protection.deleted",
1837 targetType: "repository",
1838 targetId: repo.id,
1839 metadata: { ruleId },
1840 });
1841
1842 return c.redirect(
1843 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
1844 );
1845 }
1846);
1847
79136bbClaude1848export default repoSettings;