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.tsxBlame1961 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
f5ad215Claude915 {/* ─── Dependency auto-updater ─── */}
916 <section
917 id="dep-updater"
918 class="repo-settings-section"
919 >
920 <div class="repo-settings-section-head">
921 <div class="repo-settings-section-eyebrow">AI dependency updates</div>
922 <h2 class="repo-settings-section-title">Automatic dependency updates</h2>
923 <p class="repo-settings-section-desc">
924 Once per day, Gluecron reads your <code>package.json</code>,
925 checks npm for patch and minor updates, and applies them
926 automatically. If the gate check passes, the PR is auto-merged.
927 If it fails, Gluecron opens a PR with an AI-written guide
928 explaining what broke and how to fix it. Major updates always
929 require human review and are handled separately by the migration
930 watcher. Default off.
931 </p>
932 </div>
933 <form
934 method="post"
935 action={`/${ownerName}/${repoName}/settings/dep-updater`}
936 >
937 <div class="repo-settings-section-body">
938 <label
939 class="repo-settings-toggle-row"
940 aria-label="Enable automatic dependency updates for this repo"
941 >
942 <input
943 type="checkbox"
944 name="dep_updater_enabled"
945 value="1"
946 checked={
947 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ??
948 false
949 }
950 />
951 <span class="repo-settings-toggle-text">
952 <span class="repo-settings-toggle-text-title">
953 Enable automatic dependency updates
954 </span>
955 <span class="repo-settings-toggle-text-hint">
956 Patch and minor updates only. Runs once per day; max 2
957 packages per sweep. Requires <code>DEP_UPDATER_ENABLED=1</code>{" "}
958 on the server. Auto-merges when gate passes; opens a PR
959 with an AI migration guide when it fails.
960 </span>
961 </span>
962 </label>
963 </div>
964 <div class="repo-settings-section-foot">
965 <button type="submit" class="repo-settings-cta">
966 Save dependency update settings <span class="arrow">→</span>
967 </button>
968 </div>
969 </form>
970 </section>
971
9b3a183Claude972 {/* ─── Cloud dev environments ─── */}
973 <section
974 id="dev-envs"
975 class="repo-settings-section"
976 >
977 <div class="repo-settings-section-head">
978 <div class="repo-settings-section-eyebrow">Dev environments</div>
979 <h2 class="repo-settings-section-title">Enable cloud dev environments</h2>
980 <p class="repo-settings-section-desc">
981 When enabled, anyone with read access can hit{" "}
982 <code>/{ownerName}/{repoName}/dev</code> to spin up a
983 hosted VS Code IDE in the browser, backed by a cold-start
984 container. We read <code>.gluecron/dev.yml</code> for the
985 image + install commands; idle envs stop themselves after
986 30 minutes. Default off — each env burns a container.
987 </p>
988 </div>
989 <form
990 method="post"
991 action={`/${ownerName}/${repoName}/settings/dev-envs`}
992 >
993 <div class="repo-settings-section-body">
994 <label
995 class="repo-settings-toggle-row"
996 aria-label="Enable cloud dev environments for this repo"
997 >
998 <input
999 type="checkbox"
1000 name="dev_envs_enabled"
1001 value="1"
1002 checked={
1003 (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ??
1004 false
1005 }
1006 />
1007 <span class="repo-settings-toggle-text">
1008 <span class="repo-settings-toggle-text-title">
1009 Enable dev environments
1010 </span>
1011 <span class="repo-settings-toggle-text-hint">
1012 Surfaces the <code>/{ownerName}/{repoName}/dev</code>{" "}
1013 route. Commit <code>.gluecron/dev.yml</code> to your
1014 repo to customise the image, ports, and extensions.
1015 </span>
1016 </span>
1017 </label>
1018 </div>
1019 <div class="repo-settings-section-foot">
1020 <button type="submit" class="repo-settings-cta">
1021 Save dev env settings <span class="arrow">→</span>
1022 </button>
1023 </div>
1024 </form>
1025 </section>
1026
58307aeClaude1027 {/* ─── Stale activity ─── */}
1028 <section class="repo-settings-section">
1029 <div class="repo-settings-section-head">
1030 <div class="repo-settings-section-eyebrow">Stale sweep</div>
1031 <h2 class="repo-settings-section-title">Stale activity</h2>
1032 <p class="repo-settings-section-desc">
1033 Autopilot pokes PRs and issues that have gone quiet, then offers
1034 a one-click close path. Each toggle controls the final close
1035 step — pokes always happen, but they&apos;re harmless reminders.
1036 </p>
1037 </div>
79136bbClaude1038 <form
e7e240eClaude1039 method="post"
58307aeClaude1040 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude1041 >
58307aeClaude1042 <div class="repo-settings-section-body">
1043 <label
1044 class="repo-settings-toggle-row"
1045 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
1046 >
1047 <input
1048 type="checkbox"
1049 name="auto_close_stale_prs"
1050 value="1"
1051 checked={repo.autoCloseStalePrs}
1052 />
1053 <span class="repo-settings-toggle-text">
1054 <span class="repo-settings-toggle-text-title">
1055 Auto-close stale PRs
1056 </span>
1057 <span class="repo-settings-toggle-text-hint">
1058 Close PRs that go quiet for 14 days after autopilot pokes
1059 them.
1060 </span>
1061 </span>
1062 </label>
1063 <label
1064 class="repo-settings-toggle-row"
1065 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
1066 >
1067 <input
1068 type="checkbox"
1069 name="auto_close_stale_issues"
1070 value="1"
1071 checked={repo.autoCloseStaleIssues}
1072 />
1073 <span class="repo-settings-toggle-text">
1074 <span class="repo-settings-toggle-text-title">
1075 Auto-close stale issues
1076 </span>
1077 <span class="repo-settings-toggle-text-hint">
1078 Close issues that go quiet for 60 days after autopilot
1079 pokes them.
1080 </span>
1081 </span>
1082 </label>
1083 </div>
1084 <div class="repo-settings-section-foot">
1085 <button type="submit" class="repo-settings-cta">
1086 Save stale settings <span class="arrow">→</span>
1087 </button>
1088 </div>
79136bbClaude1089 </form>
58307aeClaude1090 </section>
1091
1df50d5Claude1092 {/* ─── PR preview builds (migration 0077) ─── */}
1093 <section class="repo-settings-section">
1094 <div class="repo-settings-section-head">
1095 <div class="repo-settings-section-eyebrow">Preview builds</div>
1096 <h2 class="repo-settings-section-title">PR preview environments</h2>
1097 <p class="repo-settings-section-desc">
1098 When a build command is set, every PR push automatically clones
1099 the branch, runs the command, and serves the built output from a
1100 unique preview URL — like Vercel, but native to Gluecron. Leave
1101 blank to use URL-only previews (no build runs).
1102 </p>
1103 </div>
1104 <form
1105 method="post"
1106 action={`/${ownerName}/${repoName}/settings/previews`}
1107 >
1108 <div class="repo-settings-section-body">
1109 <div class="repo-settings-field">
1110 <label class="repo-settings-field-label" for="preview_build_command">
1111 Build command
1112 </label>
1113 <input
1114 id="preview_build_command"
1115 class="repo-settings-input"
1116 type="text"
1117 name="preview_build_command"
1118 placeholder="npm run build"
1119 value={
1120 (repo as { previewBuildCommand?: string | null }).previewBuildCommand ?? ""
1121 }
1122 />
1123 <p class="repo-settings-field-hint">
1124 e.g. <code>npm run build</code>, <code>bun run build</code>, or{" "}
1125 <code>hugo</code>. Runs inside the cloned branch directory.
1126 </p>
1127 </div>
1128 <div class="repo-settings-field">
1129 <label class="repo-settings-field-label" for="preview_output_dir">
1130 Output directory
1131 </label>
1132 <input
1133 id="preview_output_dir"
1134 class="repo-settings-input"
1135 type="text"
1136 name="preview_output_dir"
1137 placeholder="dist"
1138 value={
1139 (repo as { previewOutputDir?: string | null }).previewOutputDir ?? "dist"
1140 }
1141 />
1142 <p class="repo-settings-field-hint">
1143 The directory your build writes to. Common values:{" "}
1144 <code>dist</code>, <code>public</code>, <code>out</code>,{" "}
1145 <code>build</code>. Served from{" "}
1146 <code>/previews/{ownerName}/{repoName}/{"<branch>"}/</code>
1147 </p>
1148 </div>
1149 </div>
1150 <div class="repo-settings-section-foot">
1151 <button type="submit" class="repo-settings-cta">
1152 Save preview settings <span class="arrow">→</span>
1153 </button>
1154 </div>
1155 </form>
1156 </section>
1157
ec9e3e3Claude1158 {/* ─── Branch protection ─── */}
1159 <section class="repo-settings-section" id="branch-protection">
1160 <div class="repo-settings-section-head">
1161 <div class="repo-settings-section-eyebrow">Branch protection</div>
1162 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1163 <p class="repo-settings-section-desc">
1164 Protect branches by requiring a minimum number of human approvals before
1165 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1166 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1167 </p>
1168 </div>
1169
1170 {/* Existing rules list */}
1171 {existingBranchRules.length > 0 && (
1172 <div style="padding: var(--space-3) var(--space-5) 0">
1173 {existingBranchRules.map((rule) => (
1174 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1175 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1176 {rule.pattern}
1177 </span>
1178 <span style="font-size:12.5px;color:var(--text-muted)">
1179 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1180 </span>
1181 {rule.requireHumanReview && (
1182 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1183 codeowner review required
1184 </span>
1185 )}
1186 {rule.dismissStaleReviews && (
1187 <span style="font-size:11px;color:var(--text-muted)">
1188 dismiss stale
1189 </span>
1190 )}
1191 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1192 <button type="submit"
1193 class="repo-settings-cta-secondary"
1194 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1195 onclick="return confirm('Delete this branch protection rule?')"
1196 >
1197 Delete
1198 </button>
1199 </form>
1200 </div>
1201 ))}
1202 </div>
1203 )}
1204
1205 {/* Add new rule form */}
1206 <form
1207 method="post"
1208 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1209 >
1210 <div class="repo-settings-section-body">
1211 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1212 <div class="repo-settings-field" style="margin-bottom:0">
1213 <label class="repo-settings-field-label" for="bp_pattern">
1214 Branch name pattern
1215 </label>
1216 <input
1217 class="repo-settings-input"
1218 name="pattern"
1219 id="bp_pattern"
1220 placeholder="main"
1221 required
1222 aria-label="Branch name pattern"
1223 />
1224 <div class="repo-settings-field-hint">
1225 Exact name or glob like <code>release/*</code>
1226 </div>
1227 </div>
1228 <div class="repo-settings-field" style="margin-bottom:0">
1229 <label class="repo-settings-field-label" for="bp_required">
1230 Required approvals
1231 </label>
1232 <input
1233 class="repo-settings-input"
1234 name="required_approvals"
1235 id="bp_required"
1236 type="number"
1237 min="0"
1238 max="10"
1239 value="1"
1240 aria-label="Number of required approvals"
1241 />
1242 </div>
1243 </div>
1244 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1245 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1246 <input
1247 type="checkbox"
1248 name="require_codeowner_review"
1249 value="1"
1250 />
1251 <span class="repo-settings-toggle-text">
1252 <span class="repo-settings-toggle-text-title">
1253 Require codeowner review
1254 </span>
1255 <span class="repo-settings-toggle-text-hint">
1256 At least one CODEOWNERS-matched reviewer must approve before merging.
1257 </span>
1258 </span>
1259 </label>
1260 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1261 <input
1262 type="checkbox"
1263 name="dismiss_stale_reviews"
1264 value="1"
1265 />
1266 <span class="repo-settings-toggle-text">
1267 <span class="repo-settings-toggle-text-title">
1268 Dismiss stale reviews on new push
1269 </span>
1270 <span class="repo-settings-toggle-text-hint">
1271 Prior approvals are dismissed when the head branch receives a new commit.
1272 </span>
1273 </span>
1274 </label>
1275 </div>
1276 </div>
1277 <div class="repo-settings-section-foot">
1278 <button type="submit" class="repo-settings-cta">
1279 Add rule <span class="arrow">→</span>
1280 </button>
1281 </div>
1282 </form>
1283 </section>
1284
58307aeClaude1285 {/* ─── Danger zone ─── */}
1286 <section class="repo-settings-danger">
1287 <div class="repo-settings-danger-head">
1288 <div class="repo-settings-danger-eyebrow">Danger zone</div>
1289 <h2 class="repo-settings-danger-title">Delete this repository</h2>
1290 <p class="repo-settings-danger-desc">
1291 Permanently remove this repository and every byte of its history,
1292 issues, PRs, stars, and webhooks. There is no undo.
1293 </p>
1294 </div>
1295 <div class="repo-settings-danger-body">
1296 <p class="muted">
1297 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
1298 up immediately. Open clones will fail to fetch or push.
1299 </p>
1300 <form
1301 method="post"
1302 action={`/${ownerName}/${repoName}/settings/delete`}
1303 onsubmit="return confirm('Are you sure? This cannot be undone.')"
1304 >
1305 <button type="submit" class="repo-settings-danger-btn">
1306 Delete this repository
1307 </button>
1308 </form>
1309 </div>
1310 </section>
1311 </div>
79136bbClaude1312 </Layout>
1313 );
1314});
1315
1316// Save settings
febd4f0Claude1317repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude1318 const { owner: ownerName, repo: repoName } = c.req.param();
1319 const user = c.get("user")!;
1320 const body = await c.req.parseBody();
1321
1322 const [owner] = await db
1323 .select()
1324 .from(users)
1325 .where(eq(users.username, ownerName))
1326 .limit(1);
1327
1328 if (!owner || owner.id !== user.id) {
1329 return c.redirect(`/${ownerName}/${repoName}`);
1330 }
1331
1332 await db
1333 .update(repositories)
1334 .set({
1335 description: String(body.description || "").trim() || null,
1336 defaultBranch: String(body.default_branch || "main"),
1337 isPrivate: body.visibility === "private",
1338 updatedAt: new Date(),
1339 })
1340 .where(
1341 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1342 );
1343
1344 return c.redirect(
1345 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1346 );
1347});
1348
71cd5ecClaude1349// Toggle template flag
1350repoSettings.post(
1351 "/:owner/:repo/settings/template",
1352 requireAuth,
febd4f0Claude1353 requireRepoAccess("admin"),
71cd5ecClaude1354 async (c) => {
1355 const { owner: ownerName, repo: repoName } = c.req.param();
1356 const user = c.get("user")!;
1357 const body = await c.req.parseBody();
1358 const [owner] = await db
1359 .select()
1360 .from(users)
1361 .where(eq(users.username, ownerName))
1362 .limit(1);
1363 if (!owner || owner.id !== user.id) {
1364 return c.redirect(`/${ownerName}/${repoName}`);
1365 }
1366 const target = String(body.template || "1") === "1";
1367 await db
1368 .update(repositories)
1369 .set({ isTemplate: target, updatedAt: new Date() })
1370 .where(
1371 and(
1372 eq(repositories.ownerId, owner.id),
1373 eq(repositories.name, repoName)
1374 )
1375 );
1376 return c.redirect(
1377 `/${ownerName}/${repoName}/settings?success=${
1378 target ? "Marked+as+template" : "Unmarked+as+template"
1379 }`
1380 );
1381 }
1382);
1383
1384// Transfer repository to a new owner (by username)
1385repoSettings.post(
1386 "/:owner/:repo/settings/transfer",
1387 requireAuth,
febd4f0Claude1388 requireRepoAccess("admin"),
71cd5ecClaude1389 async (c) => {
1390 const { owner: ownerName, repo: repoName } = c.req.param();
1391 const user = c.get("user")!;
1392 const body = await c.req.parseBody();
1393 const newOwnerName = String(body.new_owner || "").trim();
1394 if (!newOwnerName) {
1395 return c.redirect(
1396 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1397 );
1398 }
1399 const [owner] = await db
1400 .select()
1401 .from(users)
1402 .where(eq(users.username, ownerName))
1403 .limit(1);
1404 if (!owner || owner.id !== user.id) {
1405 return c.redirect(`/${ownerName}/${repoName}`);
1406 }
1407 const [newOwner] = await db
1408 .select()
1409 .from(users)
1410 .where(eq(users.username, newOwnerName))
1411 .limit(1);
1412 if (!newOwner) {
1413 return c.redirect(
1414 `/${ownerName}/${repoName}/settings?error=User+not+found`
1415 );
1416 }
1417 if (newOwner.id === owner.id) {
1418 return c.redirect(
1419 `/${ownerName}/${repoName}/settings?error=Same+owner`
1420 );
1421 }
1422 // Reject if new owner already has a repo by this name
1423 const [conflict] = await db
1424 .select()
1425 .from(repositories)
1426 .where(
1427 and(
1428 eq(repositories.ownerId, newOwner.id),
1429 eq(repositories.name, repoName)
1430 )
1431 )
1432 .limit(1);
1433 if (conflict) {
1434 return c.redirect(
1435 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1436 );
1437 }
1438 const [repo] = await db
1439 .select()
1440 .from(repositories)
1441 .where(
1442 and(
1443 eq(repositories.ownerId, owner.id),
1444 eq(repositories.name, repoName)
1445 )
1446 )
1447 .limit(1);
1448 if (!repo) return c.notFound();
1449 await db
1450 .update(repositories)
1451 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1452 .where(eq(repositories.id, repo.id));
1453 await db.insert(repoTransfers).values({
1454 repositoryId: repo.id,
1455 fromOwnerId: owner.id,
1456 fromOrgId: repo.orgId,
1457 toOwnerId: newOwner.id,
1458 toOrgId: null,
1459 initiatedBy: user.id,
1460 });
1461 return c.redirect(`/${newOwnerName}/${repoName}`);
1462 }
1463);
1464
1465// Archive / unarchive repository
1466repoSettings.post(
1467 "/:owner/:repo/settings/archive",
1468 requireAuth,
febd4f0Claude1469 requireRepoAccess("admin"),
71cd5ecClaude1470 async (c) => {
1471 const { owner: ownerName, repo: repoName } = c.req.param();
1472 const user = c.get("user")!;
1473 const body = await c.req.parseBody();
1474 const [owner] = await db
1475 .select()
1476 .from(users)
1477 .where(eq(users.username, ownerName))
1478 .limit(1);
1479 if (!owner || owner.id !== user.id) {
1480 return c.redirect(`/${ownerName}/${repoName}`);
1481 }
1482 const target = String(body.archive || "1") === "1";
1483 await db
1484 .update(repositories)
1485 .set({ isArchived: target, updatedAt: new Date() })
1486 .where(
1487 and(
1488 eq(repositories.ownerId, owner.id),
1489 eq(repositories.name, repoName)
1490 )
1491 );
1492 return c.redirect(
1493 `/${ownerName}/${repoName}/settings?success=${
1494 target ? "Repository+archived" : "Repository+unarchived"
1495 }`
1496 );
1497 }
1498);
1499
534f04aClaude1500// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1501repoSettings.post(
1502 "/:owner/:repo/settings/stale",
1503 requireAuth,
1504 requireRepoAccess("admin"),
1505 async (c) => {
1506 const { owner: ownerName, repo: repoName } = c.req.param();
1507 const user = c.get("user")!;
1508 const body = await c.req.parseBody();
1509 const [owner] = await db
1510 .select()
1511 .from(users)
1512 .where(eq(users.username, ownerName))
1513 .limit(1);
1514 if (!owner || owner.id !== user.id) {
1515 return c.redirect(`/${ownerName}/${repoName}`);
1516 }
1517 const [repo] = await db
1518 .select()
1519 .from(repositories)
1520 .where(
1521 and(
1522 eq(repositories.ownerId, owner.id),
1523 eq(repositories.name, repoName)
1524 )
1525 )
1526 .limit(1);
1527 if (!repo) return c.notFound();
1528
1529 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1530 const newPrs = body.auto_close_stale_prs === "1";
1531 const newIssues = body.auto_close_stale_issues === "1";
1532
1533 await db
1534 .update(repositories)
1535 .set({
1536 autoCloseStalePrs: newPrs,
1537 autoCloseStaleIssues: newIssues,
1538 updatedAt: new Date(),
1539 })
1540 .where(eq(repositories.id, repo.id));
1541
1542 // Audit toggle deltas so the repo's audit log shows the change. Two
1543 // separate rows (one per flag) so the action names stay stable + grep-able.
1544 if (newPrs !== repo.autoCloseStalePrs) {
1545 await audit({
1546 userId: user.id,
1547 repositoryId: repo.id,
1548 action: "repo.auto_close_stale_prs.toggled",
1549 targetType: "repository",
1550 targetId: repo.id,
1551 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1552 });
1553 }
1554 if (newIssues !== repo.autoCloseStaleIssues) {
1555 await audit({
1556 userId: user.id,
1557 repositoryId: repo.id,
1558 action: "repo.auto_close_stale_issues.toggled",
1559 targetType: "repository",
1560 targetId: repo.id,
1561 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1562 });
1563 }
1564
1565 return c.redirect(
1566 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1567 );
1568 }
1569);
1570
1d4ff60Claude1571// AI test generator opt-in. Owner-only; audits the toggle delta so the
1572// repo's audit log shows the change.
1573repoSettings.post(
1574 "/:owner/:repo/settings/ai-tests",
1575 requireAuth,
1576 requireRepoAccess("admin"),
1577 async (c) => {
1578 const { owner: ownerName, repo: repoName } = c.req.param();
1579 const user = c.get("user")!;
1580 const body = await c.req.parseBody();
1581 const [owner] = await db
1582 .select()
1583 .from(users)
1584 .where(eq(users.username, ownerName))
1585 .limit(1);
1586 if (!owner || owner.id !== user.id) {
1587 return c.redirect(`/${ownerName}/${repoName}`);
1588 }
1589 const [repo] = await db
1590 .select()
1591 .from(repositories)
1592 .where(
1593 and(
1594 eq(repositories.ownerId, owner.id),
1595 eq(repositories.name, repoName)
1596 )
1597 )
1598 .limit(1);
1599 if (!repo) return c.notFound();
1600
1601 // Unchecked checkboxes are absent from the form payload, so coerce.
1602 const next = body.auto_generate_tests === "1";
1603
1604 await db
1605 .update(repositories)
1606 .set({
1607 autoGenerateTests: next,
1608 updatedAt: new Date(),
1609 })
1610 .where(eq(repositories.id, repo.id));
1611
1612 if (next !== repo.autoGenerateTests) {
1613 await audit({
1614 userId: user.id,
1615 repositoryId: repo.id,
1616 action: "repo.auto_generate_tests.toggled",
1617 targetType: "repository",
1618 targetId: repo.id,
1619 metadata: { from: repo.autoGenerateTests, to: next },
1620 });
1621 }
1622
1623 return c.redirect(
1624 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1625 );
1626 }
1627);
1628
9b3a183Claude1629// Migration 0072 — toggle cloud dev environments. Owner-only; audits
1630// the toggle delta so the repo's audit log shows the change.
1631repoSettings.post(
1632 "/:owner/:repo/settings/dev-envs",
1633 requireAuth,
1634 requireRepoAccess("admin"),
1635 async (c) => {
1636 const { owner: ownerName, repo: repoName } = c.req.param();
1637 const user = c.get("user")!;
1638 const body = await c.req.parseBody();
1639 const [owner] = await db
1640 .select()
1641 .from(users)
1642 .where(eq(users.username, ownerName))
1643 .limit(1);
1644 if (!owner || owner.id !== user.id) {
1645 return c.redirect(`/${ownerName}/${repoName}`);
1646 }
1647 const [repo] = await db
1648 .select()
1649 .from(repositories)
1650 .where(
1651 and(
1652 eq(repositories.ownerId, owner.id),
1653 eq(repositories.name, repoName)
1654 )
1655 )
1656 .limit(1);
1657 if (!repo) return c.notFound();
1658
1659 const next = body.dev_envs_enabled === "1";
1660 const prev = (repo as { devEnvsEnabled?: boolean }).devEnvsEnabled ?? false;
1661
1662 await db
1663 .update(repositories)
1664 .set({
1665 devEnvsEnabled: next,
1666 updatedAt: new Date(),
1667 })
1668 .where(eq(repositories.id, repo.id));
1669
1670 if (next !== prev) {
1671 await audit({
1672 userId: user.id,
1673 repositoryId: repo.id,
1674 action: "repo.dev_envs_enabled.toggled",
1675 targetType: "repository",
1676 targetId: repo.id,
1677 metadata: { from: prev, to: next },
1678 });
1679 }
1680
1681 return c.redirect(
1682 `/${ownerName}/${repoName}/settings?success=Dev+env+settings+saved#dev-envs`
1683 );
1684 }
1685);
1686
f5ad215Claude1687// Migration 0077 — toggle AI dependency auto-updater. Owner-only; audits
1688// the toggle delta so the repo's audit log shows the change.
1689repoSettings.post(
1690 "/:owner/:repo/settings/dep-updater",
1691 requireAuth,
1692 requireRepoAccess("admin"),
1693 async (c) => {
1694 const { owner: ownerName, repo: repoName } = c.req.param();
1695 const user = c.get("user")!;
1696 const body = await c.req.parseBody();
1697 const [owner] = await db
1698 .select()
1699 .from(users)
1700 .where(eq(users.username, ownerName))
1701 .limit(1);
1702 if (!owner || owner.id !== user.id) {
1703 return c.redirect(`/${ownerName}/${repoName}`);
1704 }
1705 const [repo] = await db
1706 .select()
1707 .from(repositories)
1708 .where(
1709 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1710 )
1711 .limit(1);
1712 if (!repo) return c.notFound();
1713
1714 const next = body.dep_updater_enabled === "1";
1715 const prev =
1716 (repo as { depUpdaterEnabled?: boolean }).depUpdaterEnabled ?? false;
1717
1718 await db
1719 .update(repositories)
1720 .set({
1721 depUpdaterEnabled: next,
1722 updatedAt: new Date(),
1723 })
1724 .where(eq(repositories.id, repo.id));
1725
1726 if (next !== prev) {
1727 await audit({
1728 userId: user.id,
1729 repositoryId: repo.id,
1730 action: "repo.dep_updater_enabled.toggled",
1731 targetType: "repository",
1732 targetId: repo.id,
1733 metadata: { from: prev, to: next },
1734 });
1735 }
1736
1737 return c.redirect(
1738 `/${ownerName}/${repoName}/settings?success=Dependency+update+settings+saved#dep-updater`
1739 );
1740 }
1741);
1742
79136bbClaude1743// Delete repository
1744repoSettings.post(
1745 "/:owner/:repo/settings/delete",
1746 requireAuth,
febd4f0Claude1747 requireRepoAccess("admin"),
79136bbClaude1748 async (c) => {
1749 const { owner: ownerName, repo: repoName } = c.req.param();
1750 const user = c.get("user")!;
1751
1752 const [owner] = await db
1753 .select()
1754 .from(users)
1755 .where(eq(users.username, ownerName))
1756 .limit(1);
1757
1758 if (!owner || owner.id !== user.id) {
1759 return c.redirect(`/${ownerName}/${repoName}`);
1760 }
1761
1762 const [repo] = await db
1763 .select()
1764 .from(repositories)
1765 .where(
1766 and(
1767 eq(repositories.ownerId, owner.id),
1768 eq(repositories.name, repoName)
1769 )
1770 )
1771 .limit(1);
1772
1773 if (!repo) return c.redirect(`/${ownerName}`);
1774
1775 // Delete from disk
1776 try {
1777 await rm(repo.diskPath, { recursive: true, force: true });
1778 } catch {
1779 // Disk cleanup best-effort
1780 }
1781
1782 // Delete from DB (cascades to stars, issues, etc.)
1783 await db.delete(repositories).where(eq(repositories.id, repo.id));
1784
1785 return c.redirect(`/${ownerName}`);
1786 }
1787);
1788
1df50d5Claude1789// Migration 0077 — PR preview build configuration. Owner-only.
1790repoSettings.post(
1791 "/:owner/:repo/settings/previews",
1792 requireAuth,
1793 requireRepoAccess("admin"),
1794 async (c) => {
1795 const { owner: ownerName, repo: repoName } = c.req.param();
1796 const user = c.get("user")!;
1797 const body = await c.req.parseBody();
1798
1799 const [owner] = await db
1800 .select()
1801 .from(users)
1802 .where(eq(users.username, ownerName))
1803 .limit(1);
1804 if (!owner || owner.id !== user.id) {
1805 return c.redirect(`/${ownerName}/${repoName}`);
1806 }
1807
1808 const [repo] = await db
1809 .select()
1810 .from(repositories)
1811 .where(
1812 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1813 )
1814 .limit(1);
1815 if (!repo) return c.notFound();
1816
1817 const buildCommand = String(body.preview_build_command || "").trim() || null;
1818 const outputDir = String(body.preview_output_dir || "").trim() || "dist";
1819
1820 await db
1821 .update(repositories)
1822 .set({
1823 previewBuildCommand: buildCommand,
1824 previewOutputDir: outputDir,
1825 updatedAt: new Date(),
1826 })
1827 .where(eq(repositories.id, repo.id));
1828
1829 return c.redirect(
1830 `/${ownerName}/${repoName}/settings?success=Preview+build+settings+saved`
1831 );
1832 }
1833);
1834
ec9e3e3Claude1835// ─── Branch protection: add rule ───────────────────────────────────────────
1836repoSettings.post(
1837 "/:owner/:repo/settings/branch-protection",
1838 requireAuth,
1839 requireRepoAccess("admin"),
1840 async (c) => {
1841 const { owner: ownerName, repo: repoName } = c.req.param();
1842 const user = c.get("user")!;
1843 const body = await c.req.parseBody();
1844
1845 const [owner] = await db
1846 .select()
1847 .from(users)
1848 .where(eq(users.username, ownerName))
1849 .limit(1);
1850 if (!owner || owner.id !== user.id) {
1851 return c.redirect(`/${ownerName}/${repoName}`);
1852 }
1853
1854 const [repo] = await db
1855 .select()
1856 .from(repositories)
1857 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1858 .limit(1);
1859 if (!repo) return c.notFound();
1860
1861 const pattern = String(body.pattern || "").trim();
1862 if (!pattern) {
1863 return c.redirect(
1864 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
1865 );
1866 }
1867
1868 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
1869 const requireHumanReview = body.require_codeowner_review === "1";
1870 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
1871
1872 try {
1873 await db
1874 .insert(branchProtection)
1875 .values({
1876 repositoryId: repo.id,
1877 pattern,
1878 requiredApprovals,
1879 requireHumanReview,
1880 dismissStaleReviews,
1881 })
1882 .onConflictDoUpdate({
1883 target: [branchProtection.repositoryId, branchProtection.pattern],
1884 set: {
1885 requiredApprovals,
1886 requireHumanReview,
1887 dismissStaleReviews,
1888 updatedAt: new Date(),
1889 },
1890 });
1891
1892 await audit({
1893 userId: user.id,
1894 repositoryId: repo.id,
1895 action: "branch_protection.updated",
1896 targetType: "repository",
1897 targetId: repo.id,
1898 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
1899 });
1900 } catch (err) {
1901 return c.redirect(
1902 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
1903 );
1904 }
1905
1906 return c.redirect(
1907 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
1908 );
1909 }
1910);
1911
1912// ─── Branch protection: delete rule ────────────────────────────────────────
1913repoSettings.post(
1914 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
1915 requireAuth,
1916 requireRepoAccess("admin"),
1917 async (c) => {
1918 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
1919 const user = c.get("user")!;
1920
1921 const [owner] = await db
1922 .select()
1923 .from(users)
1924 .where(eq(users.username, ownerName))
1925 .limit(1);
1926 if (!owner || owner.id !== user.id) {
1927 return c.redirect(`/${ownerName}/${repoName}`);
1928 }
1929
1930 const [repo] = await db
1931 .select()
1932 .from(repositories)
1933 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1934 .limit(1);
1935 if (!repo) return c.notFound();
1936
1937 await db
1938 .delete(branchProtection)
1939 .where(
1940 and(
1941 eq(branchProtection.id, ruleId),
1942 eq(branchProtection.repositoryId, repo.id)
1943 )
1944 );
1945
1946 await audit({
1947 userId: user.id,
1948 repositoryId: repo.id,
1949 action: "branch_protection.deleted",
1950 targetType: "repository",
1951 targetId: repo.id,
1952 metadata: { ruleId },
1953 });
1954
1955 return c.redirect(
1956 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
1957 );
1958 }
1959);
1960
79136bbClaude1961export default repoSettings;