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.tsxBlame1588 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";
71cd5ecClaude8import { repositories, users, repoTransfers } 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
586 return c.html(
587 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
588 <RepoHeader owner={ownerName} repo={repoName} />
58307aeClaude589 <style dangerouslySetInnerHTML={{ __html: repoSettingsStyles }} />
590 <div class="repo-settings-container">
591 <RepoSettingsHero owner={ownerName} repo={repoName} />
592
79136bbClaude593 {success && (
58307aeClaude594 <Banner kind="success" text={decodeURIComponent(success)} />
79136bbClaude595 )}
58307aeClaude596 {error && <Banner kind="error" text={decodeURIComponent(error)} />}
79136bbClaude597
58307aeClaude598 {/* ─── General: description + default branch + visibility ─── */}
599 <section class="repo-settings-section">
600 <div class="repo-settings-section-head">
601 <div class="repo-settings-section-eyebrow">General</div>
602 <h2 class="repo-settings-section-title">Repository basics</h2>
603 <p class="repo-settings-section-desc">
604 The headline details visitors see — description, the default
605 branch they land on, and who can browse the code.
606 </p>
607 </div>
608 <form
609 method="post"
610 action={`/${ownerName}/${repoName}/settings`}
611 >
612 <div class="repo-settings-section-body">
613 <div class="repo-settings-field">
614 <label class="repo-settings-field-label" for="description">
615 Description
616 </label>
79136bbClaude617 <input
58307aeClaude618 class="repo-settings-input"
619 name="description"
620 id="description"
621 value={repo.description || ""}
622 placeholder="A short description"
79136bbClaude623 />
58307aeClaude624 <div class="repo-settings-field-hint">
625 Shown on the repo home, search results, and explore pages.
626 </div>
627 </div>
628 <div class="repo-settings-field">
629 <label
630 class="repo-settings-field-label"
631 for="default_branch"
632 >
633 Default branch
634 </label>
635 <select
636 class="repo-settings-select"
637 name="default_branch"
638 id="default_branch"
639 >
640 {branches.length === 0 ? (
641 <option value={repo.defaultBranch}>
642 {repo.defaultBranch}
643 </option>
644 ) : (
645 branches.map((b) => (
646 <option value={b} selected={b === repo.defaultBranch}>
647 {b}
648 </option>
649 ))
650 )}
651 </select>
652 <div class="repo-settings-field-hint">
653 Where pulls land, where compare views start, and what
654 clones check out by default.
655 </div>
656 </div>
657 <div class="repo-settings-field">
658 <label class="repo-settings-field-label">Visibility</label>
659 <div class="repo-settings-visibility">
660 <label class="repo-settings-vis-card">
661 <input
662 type="radio"
663 name="visibility"
664 value="public"
665 checked={!repo.isPrivate}
666 class="repo-settings-vis-radio"
667 aria-label="Public"
668 />
669 <span class="repo-settings-vis-body">
670 <span class="repo-settings-vis-label">Public</span>
671 <span class="repo-settings-vis-desc">
672 Anyone can see this repository. You choose who can
673 commit.
674 </span>
675 </span>
676 </label>
677 <label class="repo-settings-vis-card">
678 <input
679 type="radio"
680 name="visibility"
681 value="private"
682 checked={repo.isPrivate}
683 class="repo-settings-vis-radio"
684 aria-label="Private"
685 />
686 <span class="repo-settings-vis-body">
687 <span class="repo-settings-vis-label">Private</span>
688 <span class="repo-settings-vis-desc">
689 Only you (and collaborators you invite) can see this
690 repository.
691 </span>
692 </span>
693 </label>
694 </div>
695 </div>
44f1a02Claude696 <div class="repo-settings-field">
697 <label class="repo-settings-field-label">Data region</label>
698 <div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
699 <span
700 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); ${
701 repo.dataRegion === "eu"
702 ? "background:rgba(54,197,214,0.10); color:#67e8f9; border:1px solid rgba(54,197,214,0.28);"
703 : "background:rgba(140,109,255,0.10); color:#c4b5fd; border:1px solid rgba(140,109,255,0.28);"
704 }`}
705 >
706 {repo.dataRegion === "eu" ? "EU · Frankfurt" : "US · Default"}
707 </span>
708 <span class="repo-settings-field-hint" style="margin:0;">
709 Data region is set at creation and cannot be changed. EU data
710 residency requires a{" "}
711 <a href="/pricing" style="color:var(--accent);text-decoration:none;">Pro plan or higher</a>.
712 </span>
713 </div>
714 </div>
79136bbClaude715 </div>
58307aeClaude716 <div class="repo-settings-section-foot">
717 <button type="submit" class="repo-settings-cta">
718 Save changes <span class="arrow">→</span>
719 </button>
720 </div>
721 </form>
722 </section>
14c3cc8Claude723
58307aeClaude724 {/* ─── Spec to PR ─── */}
725 <section class="repo-settings-section">
726 <div class="repo-settings-section-head">
727 <div class="repo-settings-section-eyebrow">
728 Automation · experimental
729 </div>
730 <h2 class="repo-settings-section-title">Spec to PR</h2>
731 <p class="repo-settings-section-desc">
732 Paste a plain-English feature spec and let Claude draft a pull
733 request for you. PRs open as drafts — review every line before
734 merging.
735 </p>
736 </div>
737 <div class="repo-settings-section-body">
738 <a
739 href={`/${ownerName}/${repoName}/spec`}
740 class="repo-settings-cta"
741 >
742 Open Spec to PR <span class="arrow">→</span>
743 </a>
744 </div>
745 </section>
746
747 {/* ─── Template repository ─── */}
748 <section class="repo-settings-section">
749 <div class="repo-settings-section-head">
750 <div class="repo-settings-section-eyebrow">Template</div>
751 <h2 class="repo-settings-section-title">Template repository</h2>
752 <p class="repo-settings-section-desc">
71cd5ecClaude753 {repo.isTemplate
58307aeClaude754 ? "This repository is a template. Users can click “Use this template” to create a new repository with the same files."
755 : "Mark this repository as a template so others can seed new repositories from its files."}
756 </p>
757 </div>
758 <div class="repo-settings-section-body">
759 <span
760 class={`repo-settings-pill ${repo.isTemplate ? "is-on" : "is-off"}`}
761 >
762 <span class="dot" aria-hidden="true" />
763 {repo.isTemplate ? "Template enabled" : "Not a template"}
764 </span>
765 <form
766 method="post"
767 action={`/${ownerName}/${repoName}/settings/template`}
768 style="margin-top: var(--space-3)"
769 >
770 <input
771 type="hidden"
772 name="template"
773 value={repo.isTemplate ? "0" : "1"}
774 />
775 <button type="submit" class="repo-settings-cta-secondary">
776 {repo.isTemplate ? "Unmark as template" : "Mark as template"}
777 </button>
778 </form>
779 </div>
780 </section>
71cd5ecClaude781
58307aeClaude782 {/* ─── Transfer ownership ─── */}
783 <section class="repo-settings-section">
784 <div class="repo-settings-section-head">
785 <div class="repo-settings-section-eyebrow">Ownership</div>
786 <h2 class="repo-settings-section-title">Transfer ownership</h2>
787 <p class="repo-settings-section-desc">
788 Hand this repository to another user. The new owner can accept
789 or decline the transfer by viewing it.
790 </p>
791 </div>
71cd5ecClaude792 <form
e7e240eClaude793 method="post"
71cd5ecClaude794 action={`/${ownerName}/${repoName}/settings/transfer`}
795 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
796 >
58307aeClaude797 <div class="repo-settings-section-body">
798 <div class="repo-settings-field">
799 <label class="repo-settings-field-label" for="new_owner">
800 New owner username
801 </label>
802 <div class="repo-settings-inline-row">
803 <input
804 type="text"
805 name="new_owner"
806 id="new_owner"
807 class="repo-settings-input"
808 placeholder="new-owner-username"
809 required
810 aria-label="New owner username"
811 />
812 <button type="submit" class="repo-settings-cta-secondary">
813 Transfer
814 </button>
815 </div>
816 <div class="repo-settings-field-hint">
817 Transfers are immediate. The new owner can rename or delete
818 the repository at any time.
819 </div>
820 </div>
821 </div>
71cd5ecClaude822 </form>
58307aeClaude823 </section>
71cd5ecClaude824
58307aeClaude825 {/* ─── Archive ─── */}
826 <section class="repo-settings-section">
827 <div class="repo-settings-section-head">
828 <div class="repo-settings-section-eyebrow">Lifecycle</div>
829 <h2 class="repo-settings-section-title">
830 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
831 </h2>
832 <p class="repo-settings-section-desc">
833 {repo.isArchived
834 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
835 : "Mark this repository as archived. It becomes read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
836 </p>
837 </div>
838 <div class="repo-settings-section-body">
839 <span
840 class={`repo-settings-pill ${repo.isArchived ? "is-on" : "is-off"}`}
534f04aClaude841 >
58307aeClaude842 <span class="dot" aria-hidden="true" />
843 {repo.isArchived ? "Archived" : "Active"}
844 </span>
845 <form
846 method="post"
847 action={`/${ownerName}/${repoName}/settings/archive`}
848 style="margin-top: var(--space-3)"
534f04aClaude849 >
850 <input
58307aeClaude851 type="hidden"
852 name="archive"
853 value={repo.isArchived ? "0" : "1"}
534f04aClaude854 />
58307aeClaude855 <button type="submit" class="repo-settings-cta-secondary">
856 {repo.isArchived ? "Unarchive" : "Archive"} this repository
857 </button>
858 </form>
859 </div>
860 </section>
534f04aClaude861
1d4ff60Claude862 {/* ─── AI test generator ─── */}
863 <section class="repo-settings-section">
864 <div class="repo-settings-section-head">
865 <div class="repo-settings-section-eyebrow">AI tests</div>
866 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
867 <p class="repo-settings-section-desc">
868 When a pull request opens, Gluecron AI reads the diff and writes
869 tests for the new code, matching whatever framework your repo
870 already uses. Tests land on the same branch. Default off —
871 opt in here.
872 </p>
873 </div>
874 <form
875 method="post"
876 action={`/${ownerName}/${repoName}/settings/ai-tests`}
877 >
878 <div class="repo-settings-section-body">
879 <label
880 class="repo-settings-toggle-row"
881 aria-label="Auto-generate tests when a PR opens"
882 >
883 <input
884 type="checkbox"
885 name="auto_generate_tests"
886 value="1"
887 checked={repo.autoGenerateTests}
888 />
889 <span class="repo-settings-toggle-text">
890 <span class="repo-settings-toggle-text-title">
891 Auto-generate tests on PR open
892 </span>
893 <span class="repo-settings-toggle-text-hint">
894 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
895 source-file changes and PRs already opened by AI.
896 </span>
897 </span>
898 </label>
899 </div>
900 <div class="repo-settings-section-foot">
901 <button type="submit" class="repo-settings-cta">
902 Save AI test settings <span class="arrow">→</span>
903 </button>
904 </div>
905 </form>
906 </section>
907
9b3a183Claude908 {/* ─── Cloud dev environments ─── */}
909 <section
910 id="dev-envs"
911 class="repo-settings-section"
912 >
913 <div class="repo-settings-section-head">
914 <div class="repo-settings-section-eyebrow">Dev environments</div>
915 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
916 <p class="repo-settings-section-desc">
917 When enabled, anyone with read access can hit{" "}
918 <code>/{ownerName}/{repoName}/dev</code> to spin up a
919 hosted VS Code IDE in the browser, backed by a cold-start
920 container. We read <code>.gluecron/dev.yml</code> for the
921 image + install commands; idle envs stop themselves after
922 30 minutes. Default off — each env burns a container.
923 </p>
924 </div>
925 <form
926 method="post"
927 action={`/${ownerName}/${repoName}/settings/dev-envs`}
928 >
929 <div class="repo-settings-section-body">
930 <label
931 class="repo-settings-toggle-row"
932 aria-label="Enable cloud dev environments for this repo"
933 >
934 <input
935 type="checkbox"
936 name="dev_envs_enabled"
937 value="1"
938 checked={
939 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
940 false
941 }
942 />
943 <span class="repo-settings-toggle-text">
944 <span class="repo-settings-toggle-text-title">
945 Enable dev environments
946 </span>
947 <span class="repo-settings-toggle-text-hint">
948 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
949 route. Commit <code>.gluecron/dev.yml</code> to your
950 repo to customise the image, ports, and extensions.
951 </span>
952 </span>
953 </label>
954 </div>
955 <div class="repo-settings-section-foot">
956 <button type="submit" class="repo-settings-cta">
957 Save dev env settings <span class="arrow">→</span>
958 </button>
959 </div>
960 </form>
961 </section>
962
58307aeClaude963 {/* ─── Stale activity ─── */}
964 <section class="repo-settings-section">
965 <div class="repo-settings-section-head">
966 <div class="repo-settings-section-eyebrow">Stale sweep</div>
967 <h2 class="repo-settings-section-title">Stale activity</h2>
968 <p class="repo-settings-section-desc">
969 Autopilot pokes PRs and issues that have gone quiet, then offers
970 a one-click close path. Each toggle controls the final close
971 step — pokes always happen, but they&apos;re harmless reminders.
972 </p>
973 </div>
79136bbClaude974 <form
e7e240eClaude975 method="post"
58307aeClaude976 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude977 >
58307aeClaude978 <div class="repo-settings-section-body">
979 <label
980 class="repo-settings-toggle-row"
981 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
982 >
983 <input
984 type="checkbox"
985 name="auto_close_stale_prs"
986 value="1"
987 checked={repo.autoCloseStalePrs}
988 />
989 <span class="repo-settings-toggle-text">
990 <span class="repo-settings-toggle-text-title">
991 Auto-close stale PRs
992 </span>
993 <span class="repo-settings-toggle-text-hint">
994 Close PRs that go quiet for 14 days after autopilot pokes
995 them.
996 </span>
997 </span>
998 </label>
999 <label
1000 class="repo-settings-toggle-row"
1001 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
1002 >
1003 <input
1004 type="checkbox"
1005 name="auto_close_stale_issues"
1006 value="1"
1007 checked={repo.autoCloseStaleIssues}
1008 />
1009 <span class="repo-settings-toggle-text">
1010 <span class="repo-settings-toggle-text-title">
1011 Auto-close stale issues
1012 </span>
1013 <span class="repo-settings-toggle-text-hint">
1014 Close issues that go quiet for 60 days after autopilot
1015 pokes them.
1016 </span>
1017 </span>
1018 </label>
1019 </div>
1020 <div class="repo-settings-section-foot">
1021 <button type="submit" class="repo-settings-cta">
1022 Save stale settings <span class="arrow">→</span>
1023 </button>
1024 </div>
79136bbClaude1025 </form>
58307aeClaude1026 </section>
1027
1df50d5Claude1028 {/* ─── PR preview builds (migration 0077) ─── */}
1029 <section class="repo-settings-section">
1030 <div class="repo-settings-section-head">
1031 <div class="repo-settings-section-eyebrow">Preview builds</div>
1032 <h2 class="repo-settings-section-title">PR preview environments</h2>
1033 <p class="repo-settings-section-desc">
1034 When a build command is set, every PR push automatically clones
1035 the branch, runs the command, and serves the built output from a
1036 unique preview URL — like Vercel, but native to Gluecron. Leave
1037 blank to use URL-only previews (no build runs).
1038 </p>
1039 </div>
1040 <form
1041 method="post"
1042 action={`/${ownerName}/${repoName}/settings/previews`}
1043 >
1044 <div class="repo-settings-section-body">
1045 <div class="repo-settings-field">
1046 <label class="repo-settings-field-label" for="preview_build_command">
1047 Build command
1048 </label>
1049 <input
1050 id="preview_build_command"
1051 class="repo-settings-input"
1052 type="text"
1053 name="preview_build_command"
1054 placeholder="npm run build"
1055 value={
1056 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1057 }
1058 />
1059 <p class="repo-settings-field-hint">
1060 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1061 <code>hugo</code>. Runs inside the cloned branch directory.
1062 </p>
1063 </div>
1064 <div class="repo-settings-field">
1065 <label class="repo-settings-field-label" for="preview_output_dir">
1066 Output directory
1067 </label>
1068 <input
1069 id="preview_output_dir"
1070 class="repo-settings-input"
1071 type="text"
1072 name="preview_output_dir"
1073 placeholder="dist"
1074 value={
1075 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1076 }
1077 />
1078 <p class="repo-settings-field-hint">
1079 The directory your build writes to. Common values:{" "}
1080 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1081 <code>build</code>. Served from{" "}
1082 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1083 </p>
1084 </div>
1085 </div>
1086 <div class="repo-settings-section-foot">
1087 <button type="submit" class="repo-settings-cta">
1088 Save preview settings <span class="arrow">→</span>
1089 </button>
1090 </div>
1091 </form>
1092 </section>
1093
58307aeClaude1094 {/* ─── Danger zone ─── */}
1095 <section class="repo-settings-danger">
1096 <div class="repo-settings-danger-head">
1097 <div class="repo-settings-danger-eyebrow">Danger zone</div>
1098 <h2 class="repo-settings-danger-title">Delete this repository</h2>
1099 <p class="repo-settings-danger-desc">
1100 Permanently remove this repository and every byte of its history,
1101 issues, PRs, stars, and webhooks. There is no undo.
1102 </p>
1103 </div>
1104 <div class="repo-settings-danger-body">
1105 <p class="muted">
1106 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
1107 up immediately. Open clones will fail to fetch or push.
1108 </p>
1109 <form
1110 method="post"
1111 action={`/${ownerName}/${repoName}/settings/delete`}
1112 onsubmit="return confirm('Are you sure? This cannot be undone.')"
1113 >
1114 <button type="submit" class="repo-settings-danger-btn">
1115 Delete this repository
1116 </button>
1117 </form>
1118 </div>
1119 </section>
1120 </div>
79136bbClaude1121 </Layout>
1122 );
1123});
1124
1125// Save settings
febd4f0Claude1126repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude1127 const { owner: ownerName, repo: repoName } = c.req.param();
1128 const user = c.get("user")!;
1129 const body = await c.req.parseBody();
1130
1131 const [owner] = await db
1132 .select()
1133 .from(users)
1134 .where(eq(users.username, ownerName))
1135 .limit(1);
1136
1137 if (!owner || owner.id !== user.id) {
1138 return c.redirect(`/${ownerName}/${repoName}`);
1139 }
1140
1141 await db
1142 .update(repositories)
1143 .set({
1144 description: String(body.description || "").trim() || null,
1145 defaultBranch: String(body.default_branch || "main"),
1146 isPrivate: body.visibility === "private",
1147 updatedAt: new Date(),
1148 })
1149 .where(
1150 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1151 );
1152
1153 return c.redirect(
1154 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1155 );
1156});
1157
71cd5ecClaude1158// Toggle template flag
1159repoSettings.post(
1160 "/:owner/:repo/settings/template",
1161 requireAuth,
febd4f0Claude1162 requireRepoAccess("admin"),
71cd5ecClaude1163 async (c) => {
1164 const { owner: ownerName, repo: repoName } = c.req.param();
1165 const user = c.get("user")!;
1166 const body = await c.req.parseBody();
1167 const [owner] = await db
1168 .select()
1169 .from(users)
1170 .where(eq(users.username, ownerName))
1171 .limit(1);
1172 if (!owner || owner.id !== user.id) {
1173 return c.redirect(`/${ownerName}/${repoName}`);
1174 }
1175 const target = String(body.template || "1") === "1";
1176 await db
1177 .update(repositories)
1178 .set({ isTemplate: target, updatedAt: new Date() })
1179 .where(
1180 and(
1181 eq(repositories.ownerId, owner.id),
1182 eq(repositories.name, repoName)
1183 )
1184 );
1185 return c.redirect(
1186 `/${ownerName}/${repoName}/settings?success=${
1187 target ? "Marked+as+template" : "Unmarked+as+template"
1188 }`
1189 );
1190 }
1191);
1192
1193// Transfer repository to a new owner (by username)
1194repoSettings.post(
1195 "/:owner/:repo/settings/transfer",
1196 requireAuth,
febd4f0Claude1197 requireRepoAccess("admin"),
71cd5ecClaude1198 async (c) => {
1199 const { owner: ownerName, repo: repoName } = c.req.param();
1200 const user = c.get("user")!;
1201 const body = await c.req.parseBody();
1202 const newOwnerName = String(body.new_owner || "").trim();
1203 if (!newOwnerName) {
1204 return c.redirect(
1205 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1206 );
1207 }
1208 const [owner] = await db
1209 .select()
1210 .from(users)
1211 .where(eq(users.username, ownerName))
1212 .limit(1);
1213 if (!owner || owner.id !== user.id) {
1214 return c.redirect(`/${ownerName}/${repoName}`);
1215 }
1216 const [newOwner] = await db
1217 .select()
1218 .from(users)
1219 .where(eq(users.username, newOwnerName))
1220 .limit(1);
1221 if (!newOwner) {
1222 return c.redirect(
1223 `/${ownerName}/${repoName}/settings?error=User+not+found`
1224 );
1225 }
1226 if (newOwner.id === owner.id) {
1227 return c.redirect(
1228 `/${ownerName}/${repoName}/settings?error=Same+owner`
1229 );
1230 }
1231 // Reject if new owner already has a repo by this name
1232 const [conflict] = await db
1233 .select()
1234 .from(repositories)
1235 .where(
1236 and(
1237 eq(repositories.ownerId, newOwner.id),
1238 eq(repositories.name, repoName)
1239 )
1240 )
1241 .limit(1);
1242 if (conflict) {
1243 return c.redirect(
1244 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1245 );
1246 }
1247 const [repo] = await db
1248 .select()
1249 .from(repositories)
1250 .where(
1251 and(
1252 eq(repositories.ownerId, owner.id),
1253 eq(repositories.name, repoName)
1254 )
1255 )
1256 .limit(1);
1257 if (!repo) return c.notFound();
1258 await db
1259 .update(repositories)
1260 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1261 .where(eq(repositories.id, repo.id));
1262 await db.insert(repoTransfers).values({
1263 repositoryId: repo.id,
1264 fromOwnerId: owner.id,
1265 fromOrgId: repo.orgId,
1266 toOwnerId: newOwner.id,
1267 toOrgId: null,
1268 initiatedBy: user.id,
1269 });
1270 return c.redirect(`/${newOwnerName}/${repoName}`);
1271 }
1272);
1273
1274// Archive / unarchive repository
1275repoSettings.post(
1276 "/:owner/:repo/settings/archive",
1277 requireAuth,
febd4f0Claude1278 requireRepoAccess("admin"),
71cd5ecClaude1279 async (c) => {
1280 const { owner: ownerName, repo: repoName } = c.req.param();
1281 const user = c.get("user")!;
1282 const body = await c.req.parseBody();
1283 const [owner] = await db
1284 .select()
1285 .from(users)
1286 .where(eq(users.username, ownerName))
1287 .limit(1);
1288 if (!owner || owner.id !== user.id) {
1289 return c.redirect(`/${ownerName}/${repoName}`);
1290 }
1291 const target = String(body.archive || "1") === "1";
1292 await db
1293 .update(repositories)
1294 .set({ isArchived: target, updatedAt: new Date() })
1295 .where(
1296 and(
1297 eq(repositories.ownerId, owner.id),
1298 eq(repositories.name, repoName)
1299 )
1300 );
1301 return c.redirect(
1302 `/${ownerName}/${repoName}/settings?success=${
1303 target ? "Repository+archived" : "Repository+unarchived"
1304 }`
1305 );
1306 }
1307);
1308
534f04aClaude1309// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1310repoSettings.post(
1311 "/:owner/:repo/settings/stale",
1312 requireAuth,
1313 requireRepoAccess("admin"),
1314 async (c) => {
1315 const { owner: ownerName, repo: repoName } = c.req.param();
1316 const user = c.get("user")!;
1317 const body = await c.req.parseBody();
1318 const [owner] = await db
1319 .select()
1320 .from(users)
1321 .where(eq(users.username, ownerName))
1322 .limit(1);
1323 if (!owner || owner.id !== user.id) {
1324 return c.redirect(`/${ownerName}/${repoName}`);
1325 }
1326 const [repo] = await db
1327 .select()
1328 .from(repositories)
1329 .where(
1330 and(
1331 eq(repositories.ownerId, owner.id),
1332 eq(repositories.name, repoName)
1333 )
1334 )
1335 .limit(1);
1336 if (!repo) return c.notFound();
1337
1338 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1339 const newPrs = body.auto_close_stale_prs === "1";
1340 const newIssues = body.auto_close_stale_issues === "1";
1341
1342 await db
1343 .update(repositories)
1344 .set({
1345 autoCloseStalePrs: newPrs,
1346 autoCloseStaleIssues: newIssues,
1347 updatedAt: new Date(),
1348 })
1349 .where(eq(repositories.id, repo.id));
1350
1351 // Audit toggle deltas so the repo's audit log shows the change. Two
1352 // separate rows (one per flag) so the action names stay stable + grep-able.
1353 if (newPrs !== repo.autoCloseStalePrs) {
1354 await audit({
1355 userId: user.id,
1356 repositoryId: repo.id,
1357 action: "repo.auto_close_stale_prs.toggled",
1358 targetType: "repository",
1359 targetId: repo.id,
1360 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1361 });
1362 }
1363 if (newIssues !== repo.autoCloseStaleIssues) {
1364 await audit({
1365 userId: user.id,
1366 repositoryId: repo.id,
1367 action: "repo.auto_close_stale_issues.toggled",
1368 targetType: "repository",
1369 targetId: repo.id,
1370 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1371 });
1372 }
1373
1374 return c.redirect(
1375 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1376 );
1377 }
1378);
1379
1d4ff60Claude1380// AI test generator opt-in. Owner-only; audits the toggle delta so the
1381// repo's audit log shows the change.
1382repoSettings.post(
1383 "/:owner/:repo/settings/ai-tests",
1384 requireAuth,
1385 requireRepoAccess("admin"),
1386 async (c) => {
1387 const { owner: ownerName, repo: repoName } = c.req.param();
1388 const user = c.get("user")!;
1389 const body = await c.req.parseBody();
1390 const [owner] = await db
1391 .select()
1392 .from(users)
1393 .where(eq(users.username, ownerName))
1394 .limit(1);
1395 if (!owner || owner.id !== user.id) {
1396 return c.redirect(`/${ownerName}/${repoName}`);
1397 }
1398 const [repo] = await db
1399 .select()
1400 .from(repositories)
1401 .where(
1402 and(
1403 eq(repositories.ownerId, owner.id),
1404 eq(repositories.name, repoName)
1405 )
1406 )
1407 .limit(1);
1408 if (!repo) return c.notFound();
1409
1410 // Unchecked checkboxes are absent from the form payload, so coerce.
1411 const next = body.auto_generate_tests === "1";
1412
1413 await db
1414 .update(repositories)
1415 .set({
1416 autoGenerateTests: next,
1417 updatedAt: new Date(),
1418 })
1419 .where(eq(repositories.id, repo.id));
1420
1421 if (next !== repo.autoGenerateTests) {
1422 await audit({
1423 userId: user.id,
1424 repositoryId: repo.id,
1425 action: "repo.auto_generate_tests.toggled",
1426 targetType: "repository",
1427 targetId: repo.id,
1428 metadata: { from: repo.autoGenerateTests, to: next },
1429 });
1430 }
1431
1432 return c.redirect(
1433 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1434 );
1435 }
1436);
1437
9b3a183Claude1438// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1439// the toggle delta so the repo's audit log shows the change.
1440repoSettings.post(
1441 "/:owner/:repo/settings/dev-envs",
1442 requireAuth,
1443 requireRepoAccess("admin"),
1444 async (c) => {
1445 const { owner: ownerName, repo: repoName } = c.req.param();
1446 const user = c.get("user")!;
1447 const body = await c.req.parseBody();
1448 const [owner] = await db
1449 .select()
1450 .from(users)
1451 .where(eq(users.username, ownerName))
1452 .limit(1);
1453 if (!owner || owner.id !== user.id) {
1454 return c.redirect(`/${ownerName}/${repoName}`);
1455 }
1456 const [repo] = await db
1457 .select()
1458 .from(repositories)
1459 .where(
1460 and(
1461 eq(repositories.ownerId, owner.id),
1462 eq(repositories.name, repoName)
1463 )
1464 )
1465 .limit(1);
1466 if (!repo) return c.notFound();
1467
1468 const next = body.dev_envs_enabled === "1";
1469 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1470
1471 await db
1472 .update(repositories)
1473 .set({
1474 devEnvsEnabled: next,
1475 updatedAt: new Date(),
1476 })
1477 .where(eq(repositories.id, repo.id));
1478
1479 if (next !== prev) {
1480 await audit({
1481 userId: user.id,
1482 repositoryId: repo.id,
1483 action: "repo.dev_envs_enabled.toggled",
1484 targetType: "repository",
1485 targetId: repo.id,
1486 metadata: { from: prev, to: next },
1487 });
1488 }
1489
1490 return c.redirect(
1491 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1492 );
1493 }
1494);
1495
79136bbClaude1496// Delete repository
1497repoSettings.post(
1498 "/:owner/:repo/settings/delete",
1499 requireAuth,
febd4f0Claude1500 requireRepoAccess("admin"),
79136bbClaude1501 async (c) => {
1502 const { owner: ownerName, repo: repoName } = c.req.param();
1503 const user = c.get("user")!;
1504
1505 const [owner] = await db
1506 .select()
1507 .from(users)
1508 .where(eq(users.username, ownerName))
1509 .limit(1);
1510
1511 if (!owner || owner.id !== user.id) {
1512 return c.redirect(`/${ownerName}/${repoName}`);
1513 }
1514
1515 const [repo] = await db
1516 .select()
1517 .from(repositories)
1518 .where(
1519 and(
1520 eq(repositories.ownerId, owner.id),
1521 eq(repositories.name, repoName)
1522 )
1523 )
1524 .limit(1);
1525
1526 if (!repo) return c.redirect(`/${ownerName}`);
1527
1528 // Delete from disk
1529 try {
1530 await rm(repo.diskPath, { recursive: true, force: true });
1531 } catch {
1532 // Disk cleanup best-effort
1533 }
1534
1535 // Delete from DB (cascades to stars, issues, etc.)
1536 await db.delete(repositories).where(eq(repositories.id, repo.id));
1537
1538 return c.redirect(`/${ownerName}`);
1539 }
1540);
1541
1df50d5Claude1542// Migration 0077 — PR preview build configuration. Owner-only.
1543repoSettings.post(
1544 "/:owner/:repo/settings/previews",
1545 requireAuth,
1546 requireRepoAccess("admin"),
1547 async (c) => {
1548 const { owner: ownerName, repo: repoName } = c.req.param();
1549 const user = c.get("user")!;
1550 const body = await c.req.parseBody();
1551
1552 const [owner] = await db
1553 .select()
1554 .from(users)
1555 .where(eq(users.username, ownerName))
1556 .limit(1);
1557 if (!owner || owner.id !== user.id) {
1558 return c.redirect(`/${ownerName}/${repoName}`);
1559 }
1560
1561 const [repo] = await db
1562 .select()
1563 .from(repositories)
1564 .where(
1565 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1566 )
1567 .limit(1);
1568 if (!repo) return c.notFound();
1569
1570 const buildCommand = String(body.preview_build_command || "").trim() || null;
1571 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1572
1573 await db
1574 .update(repositories)
1575 .set({
1576 previewBuildCommand: buildCommand,
1577 previewOutputDir: outputDir,
1578 updatedAt: new Date(),
1579 })
1580 .where(eq(repositories.id, repo.id));
1581
1582 return c.redirect(
1583 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1584 );
1585 }
1586);
1587
79136bbClaude1588export default repoSettings;