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.tsxBlame1344 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 = `
27 .repo-settings-container { max-width: 880px; margin: 0 auto; padding: 0 var(--space-3); }
28
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>
79136bbClaude696 </div>
58307aeClaude697 <div class="repo-settings-section-foot">
698 <button type="submit" class="repo-settings-cta">
699 Save changes <span class="arrow">→</span>
700 </button>
701 </div>
702 </form>
703 </section>
14c3cc8Claude704
58307aeClaude705 {/* ─── Spec to PR ─── */}
706 <section class="repo-settings-section">
707 <div class="repo-settings-section-head">
708 <div class="repo-settings-section-eyebrow">
709 Automation · experimental
710 </div>
711 <h2 class="repo-settings-section-title">Spec to PR</h2>
712 <p class="repo-settings-section-desc">
713 Paste a plain-English feature spec and let Claude draft a pull
714 request for you. PRs open as drafts — review every line before
715 merging.
716 </p>
717 </div>
718 <div class="repo-settings-section-body">
719 <a
720 href={`/${ownerName}/${repoName}/spec`}
721 class="repo-settings-cta"
722 >
723 Open Spec to PR <span class="arrow">→</span>
724 </a>
725 </div>
726 </section>
727
728 {/* ─── Template repository ─── */}
729 <section class="repo-settings-section">
730 <div class="repo-settings-section-head">
731 <div class="repo-settings-section-eyebrow">Template</div>
732 <h2 class="repo-settings-section-title">Template repository</h2>
733 <p class="repo-settings-section-desc">
71cd5ecClaude734 {repo.isTemplate
58307aeClaude735 ? "This repository is a template. Users can click “Use this template” to create a new repository with the same files."
736 : "Mark this repository as a template so others can seed new repositories from its files."}
737 </p>
738 </div>
739 <div class="repo-settings-section-body">
740 <span
741 class={`repo-settings-pill ${repo.isTemplate ? "is-on" : "is-off"}`}
742 >
743 <span class="dot" aria-hidden="true" />
744 {repo.isTemplate ? "Template enabled" : "Not a template"}
745 </span>
746 <form
747 method="post"
748 action={`/${ownerName}/${repoName}/settings/template`}
749 style="margin-top: var(--space-3)"
750 >
751 <input
752 type="hidden"
753 name="template"
754 value={repo.isTemplate ? "0" : "1"}
755 />
756 <button type="submit" class="repo-settings-cta-secondary">
757 {repo.isTemplate ? "Unmark as template" : "Mark as template"}
758 </button>
759 </form>
760 </div>
761 </section>
71cd5ecClaude762
58307aeClaude763 {/* ─── Transfer ownership ─── */}
764 <section class="repo-settings-section">
765 <div class="repo-settings-section-head">
766 <div class="repo-settings-section-eyebrow">Ownership</div>
767 <h2 class="repo-settings-section-title">Transfer ownership</h2>
768 <p class="repo-settings-section-desc">
769 Hand this repository to another user. The new owner can accept
770 or decline the transfer by viewing it.
771 </p>
772 </div>
71cd5ecClaude773 <form
e7e240eClaude774 method="post"
71cd5ecClaude775 action={`/${ownerName}/${repoName}/settings/transfer`}
776 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
777 >
58307aeClaude778 <div class="repo-settings-section-body">
779 <div class="repo-settings-field">
780 <label class="repo-settings-field-label" for="new_owner">
781 New owner username
782 </label>
783 <div class="repo-settings-inline-row">
784 <input
785 type="text"
786 name="new_owner"
787 id="new_owner"
788 class="repo-settings-input"
789 placeholder="new-owner-username"
790 required
791 aria-label="New owner username"
792 />
793 <button type="submit" class="repo-settings-cta-secondary">
794 Transfer
795 </button>
796 </div>
797 <div class="repo-settings-field-hint">
798 Transfers are immediate. The new owner can rename or delete
799 the repository at any time.
800 </div>
801 </div>
802 </div>
71cd5ecClaude803 </form>
58307aeClaude804 </section>
71cd5ecClaude805
58307aeClaude806 {/* ─── Archive ─── */}
807 <section class="repo-settings-section">
808 <div class="repo-settings-section-head">
809 <div class="repo-settings-section-eyebrow">Lifecycle</div>
810 <h2 class="repo-settings-section-title">
811 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
812 </h2>
813 <p class="repo-settings-section-desc">
814 {repo.isArchived
815 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
816 : "Mark this repository as archived. It becomes read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
817 </p>
818 </div>
819 <div class="repo-settings-section-body">
820 <span
821 class={`repo-settings-pill ${repo.isArchived ? "is-on" : "is-off"}`}
534f04aClaude822 >
58307aeClaude823 <span class="dot" aria-hidden="true" />
824 {repo.isArchived ? "Archived" : "Active"}
825 </span>
826 <form
827 method="post"
828 action={`/${ownerName}/${repoName}/settings/archive`}
829 style="margin-top: var(--space-3)"
534f04aClaude830 >
831 <input
58307aeClaude832 type="hidden"
833 name="archive"
834 value={repo.isArchived ? "0" : "1"}
534f04aClaude835 />
58307aeClaude836 <button type="submit" class="repo-settings-cta-secondary">
837 {repo.isArchived ? "Unarchive" : "Archive"} this repository
838 </button>
839 </form>
840 </div>
841 </section>
534f04aClaude842
1d4ff60Claude843 {/* ─── AI test generator ─── */}
844 <section class="repo-settings-section">
845 <div class="repo-settings-section-head">
846 <div class="repo-settings-section-eyebrow">AI tests</div>
847 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
848 <p class="repo-settings-section-desc">
849 When a pull request opens, Gluecron AI reads the diff and writes
850 tests for the new code, matching whatever framework your repo
851 already uses. Tests land on the same branch. Default off —
852 opt in here.
853 </p>
854 </div>
855 <form
856 method="post"
857 action={`/${ownerName}/${repoName}/settings/ai-tests`}
858 >
859 <div class="repo-settings-section-body">
860 <label
861 class="repo-settings-toggle-row"
862 aria-label="Auto-generate tests when a PR opens"
863 >
864 <input
865 type="checkbox"
866 name="auto_generate_tests"
867 value="1"
868 checked={repo.autoGenerateTests}
869 />
870 <span class="repo-settings-toggle-text">
871 <span class="repo-settings-toggle-text-title">
872 Auto-generate tests on PR open
873 </span>
874 <span class="repo-settings-toggle-text-hint">
875 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
876 source-file changes and PRs already opened by AI.
877 </span>
878 </span>
879 </label>
880 </div>
881 <div class="repo-settings-section-foot">
882 <button type="submit" class="repo-settings-cta">
883 Save AI test settings <span class="arrow">→</span>
884 </button>
885 </div>
886 </form>
887 </section>
888
58307aeClaude889 {/* ─── Stale activity ─── */}
890 <section class="repo-settings-section">
891 <div class="repo-settings-section-head">
892 <div class="repo-settings-section-eyebrow">Stale sweep</div>
893 <h2 class="repo-settings-section-title">Stale activity</h2>
894 <p class="repo-settings-section-desc">
895 Autopilot pokes PRs and issues that have gone quiet, then offers
896 a one-click close path. Each toggle controls the final close
897 step — pokes always happen, but they&apos;re harmless reminders.
898 </p>
899 </div>
79136bbClaude900 <form
e7e240eClaude901 method="post"
58307aeClaude902 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude903 >
58307aeClaude904 <div class="repo-settings-section-body">
905 <label
906 class="repo-settings-toggle-row"
907 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
908 >
909 <input
910 type="checkbox"
911 name="auto_close_stale_prs"
912 value="1"
913 checked={repo.autoCloseStalePrs}
914 />
915 <span class="repo-settings-toggle-text">
916 <span class="repo-settings-toggle-text-title">
917 Auto-close stale PRs
918 </span>
919 <span class="repo-settings-toggle-text-hint">
920 Close PRs that go quiet for 14 days after autopilot pokes
921 them.
922 </span>
923 </span>
924 </label>
925 <label
926 class="repo-settings-toggle-row"
927 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
928 >
929 <input
930 type="checkbox"
931 name="auto_close_stale_issues"
932 value="1"
933 checked={repo.autoCloseStaleIssues}
934 />
935 <span class="repo-settings-toggle-text">
936 <span class="repo-settings-toggle-text-title">
937 Auto-close stale issues
938 </span>
939 <span class="repo-settings-toggle-text-hint">
940 Close issues that go quiet for 60 days after autopilot
941 pokes them.
942 </span>
943 </span>
944 </label>
945 </div>
946 <div class="repo-settings-section-foot">
947 <button type="submit" class="repo-settings-cta">
948 Save stale settings <span class="arrow">→</span>
949 </button>
950 </div>
79136bbClaude951 </form>
58307aeClaude952 </section>
953
954 {/* ─── Danger zone ─── */}
955 <section class="repo-settings-danger">
956 <div class="repo-settings-danger-head">
957 <div class="repo-settings-danger-eyebrow">Danger zone</div>
958 <h2 class="repo-settings-danger-title">Delete this repository</h2>
959 <p class="repo-settings-danger-desc">
960 Permanently remove this repository and every byte of its history,
961 issues, PRs, stars, and webhooks. There is no undo.
962 </p>
963 </div>
964 <div class="repo-settings-danger-body">
965 <p class="muted">
966 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
967 up immediately. Open clones will fail to fetch or push.
968 </p>
969 <form
970 method="post"
971 action={`/${ownerName}/${repoName}/settings/delete`}
972 onsubmit="return confirm('Are you sure? This cannot be undone.')"
973 >
974 <button type="submit" class="repo-settings-danger-btn">
975 Delete this repository
976 </button>
977 </form>
978 </div>
979 </section>
980 </div>
79136bbClaude981 </Layout>
982 );
983});
984
985// Save settings
febd4f0Claude986repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude987 const { owner: ownerName, repo: repoName } = c.req.param();
988 const user = c.get("user")!;
989 const body = await c.req.parseBody();
990
991 const [owner] = await db
992 .select()
993 .from(users)
994 .where(eq(users.username, ownerName))
995 .limit(1);
996
997 if (!owner || owner.id !== user.id) {
998 return c.redirect(`/${ownerName}/${repoName}`);
999 }
1000
1001 await db
1002 .update(repositories)
1003 .set({
1004 description: String(body.description || "").trim() || null,
1005 defaultBranch: String(body.default_branch || "main"),
1006 isPrivate: body.visibility === "private",
1007 updatedAt: new Date(),
1008 })
1009 .where(
1010 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
1011 );
1012
1013 return c.redirect(
1014 `/${ownerName}/${repoName}/settings?success=Settings+saved`
1015 );
1016});
1017
71cd5ecClaude1018// Toggle template flag
1019repoSettings.post(
1020 "/:owner/:repo/settings/template",
1021 requireAuth,
febd4f0Claude1022 requireRepoAccess("admin"),
71cd5ecClaude1023 async (c) => {
1024 const { owner: ownerName, repo: repoName } = c.req.param();
1025 const user = c.get("user")!;
1026 const body = await c.req.parseBody();
1027 const [owner] = await db
1028 .select()
1029 .from(users)
1030 .where(eq(users.username, ownerName))
1031 .limit(1);
1032 if (!owner || owner.id !== user.id) {
1033 return c.redirect(`/${ownerName}/${repoName}`);
1034 }
1035 const target = String(body.template || "1") === "1";
1036 await db
1037 .update(repositories)
1038 .set({ isTemplate: target, updatedAt: new Date() })
1039 .where(
1040 and(
1041 eq(repositories.ownerId, owner.id),
1042 eq(repositories.name, repoName)
1043 )
1044 );
1045 return c.redirect(
1046 `/${ownerName}/${repoName}/settings?success=${
1047 target ? "Marked+as+template" : "Unmarked+as+template"
1048 }`
1049 );
1050 }
1051);
1052
1053// Transfer repository to a new owner (by username)
1054repoSettings.post(
1055 "/:owner/:repo/settings/transfer",
1056 requireAuth,
febd4f0Claude1057 requireRepoAccess("admin"),
71cd5ecClaude1058 async (c) => {
1059 const { owner: ownerName, repo: repoName } = c.req.param();
1060 const user = c.get("user")!;
1061 const body = await c.req.parseBody();
1062 const newOwnerName = String(body.new_owner || "").trim();
1063 if (!newOwnerName) {
1064 return c.redirect(
1065 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1066 );
1067 }
1068 const [owner] = await db
1069 .select()
1070 .from(users)
1071 .where(eq(users.username, ownerName))
1072 .limit(1);
1073 if (!owner || owner.id !== user.id) {
1074 return c.redirect(`/${ownerName}/${repoName}`);
1075 }
1076 const [newOwner] = await db
1077 .select()
1078 .from(users)
1079 .where(eq(users.username, newOwnerName))
1080 .limit(1);
1081 if (!newOwner) {
1082 return c.redirect(
1083 `/${ownerName}/${repoName}/settings?error=User+not+found`
1084 );
1085 }
1086 if (newOwner.id === owner.id) {
1087 return c.redirect(
1088 `/${ownerName}/${repoName}/settings?error=Same+owner`
1089 );
1090 }
1091 // Reject if new owner already has a repo by this name
1092 const [conflict] = await db
1093 .select()
1094 .from(repositories)
1095 .where(
1096 and(
1097 eq(repositories.ownerId, newOwner.id),
1098 eq(repositories.name, repoName)
1099 )
1100 )
1101 .limit(1);
1102 if (conflict) {
1103 return c.redirect(
1104 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1105 );
1106 }
1107 const [repo] = await db
1108 .select()
1109 .from(repositories)
1110 .where(
1111 and(
1112 eq(repositories.ownerId, owner.id),
1113 eq(repositories.name, repoName)
1114 )
1115 )
1116 .limit(1);
1117 if (!repo) return c.notFound();
1118 await db
1119 .update(repositories)
1120 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1121 .where(eq(repositories.id, repo.id));
1122 await db.insert(repoTransfers).values({
1123 repositoryId: repo.id,
1124 fromOwnerId: owner.id,
1125 fromOrgId: repo.orgId,
1126 toOwnerId: newOwner.id,
1127 toOrgId: null,
1128 initiatedBy: user.id,
1129 });
1130 return c.redirect(`/${newOwnerName}/${repoName}`);
1131 }
1132);
1133
1134// Archive / unarchive repository
1135repoSettings.post(
1136 "/:owner/:repo/settings/archive",
1137 requireAuth,
febd4f0Claude1138 requireRepoAccess("admin"),
71cd5ecClaude1139 async (c) => {
1140 const { owner: ownerName, repo: repoName } = c.req.param();
1141 const user = c.get("user")!;
1142 const body = await c.req.parseBody();
1143 const [owner] = await db
1144 .select()
1145 .from(users)
1146 .where(eq(users.username, ownerName))
1147 .limit(1);
1148 if (!owner || owner.id !== user.id) {
1149 return c.redirect(`/${ownerName}/${repoName}`);
1150 }
1151 const target = String(body.archive || "1") === "1";
1152 await db
1153 .update(repositories)
1154 .set({ isArchived: target, updatedAt: new Date() })
1155 .where(
1156 and(
1157 eq(repositories.ownerId, owner.id),
1158 eq(repositories.name, repoName)
1159 )
1160 );
1161 return c.redirect(
1162 `/${ownerName}/${repoName}/settings?success=${
1163 target ? "Repository+archived" : "Repository+unarchived"
1164 }`
1165 );
1166 }
1167);
1168
534f04aClaude1169// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1170repoSettings.post(
1171 "/:owner/:repo/settings/stale",
1172 requireAuth,
1173 requireRepoAccess("admin"),
1174 async (c) => {
1175 const { owner: ownerName, repo: repoName } = c.req.param();
1176 const user = c.get("user")!;
1177 const body = await c.req.parseBody();
1178 const [owner] = await db
1179 .select()
1180 .from(users)
1181 .where(eq(users.username, ownerName))
1182 .limit(1);
1183 if (!owner || owner.id !== user.id) {
1184 return c.redirect(`/${ownerName}/${repoName}`);
1185 }
1186 const [repo] = await db
1187 .select()
1188 .from(repositories)
1189 .where(
1190 and(
1191 eq(repositories.ownerId, owner.id),
1192 eq(repositories.name, repoName)
1193 )
1194 )
1195 .limit(1);
1196 if (!repo) return c.notFound();
1197
1198 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1199 const newPrs = body.auto_close_stale_prs === "1";
1200 const newIssues = body.auto_close_stale_issues === "1";
1201
1202 await db
1203 .update(repositories)
1204 .set({
1205 autoCloseStalePrs: newPrs,
1206 autoCloseStaleIssues: newIssues,
1207 updatedAt: new Date(),
1208 })
1209 .where(eq(repositories.id, repo.id));
1210
1211 // Audit toggle deltas so the repo's audit log shows the change. Two
1212 // separate rows (one per flag) so the action names stay stable + grep-able.
1213 if (newPrs !== repo.autoCloseStalePrs) {
1214 await audit({
1215 userId: user.id,
1216 repositoryId: repo.id,
1217 action: "repo.auto_close_stale_prs.toggled",
1218 targetType: "repository",
1219 targetId: repo.id,
1220 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1221 });
1222 }
1223 if (newIssues !== repo.autoCloseStaleIssues) {
1224 await audit({
1225 userId: user.id,
1226 repositoryId: repo.id,
1227 action: "repo.auto_close_stale_issues.toggled",
1228 targetType: "repository",
1229 targetId: repo.id,
1230 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1231 });
1232 }
1233
1234 return c.redirect(
1235 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1236 );
1237 }
1238);
1239
1d4ff60Claude1240// AI test generator opt-in. Owner-only; audits the toggle delta so the
1241// repo's audit log shows the change.
1242repoSettings.post(
1243 "/:owner/:repo/settings/ai-tests",
1244 requireAuth,
1245 requireRepoAccess("admin"),
1246 async (c) => {
1247 const { owner: ownerName, repo: repoName } = c.req.param();
1248 const user = c.get("user")!;
1249 const body = await c.req.parseBody();
1250 const [owner] = await db
1251 .select()
1252 .from(users)
1253 .where(eq(users.username, ownerName))
1254 .limit(1);
1255 if (!owner || owner.id !== user.id) {
1256 return c.redirect(`/${ownerName}/${repoName}`);
1257 }
1258 const [repo] = await db
1259 .select()
1260 .from(repositories)
1261 .where(
1262 and(
1263 eq(repositories.ownerId, owner.id),
1264 eq(repositories.name, repoName)
1265 )
1266 )
1267 .limit(1);
1268 if (!repo) return c.notFound();
1269
1270 // Unchecked checkboxes are absent from the form payload, so coerce.
1271 const next = body.auto_generate_tests === "1";
1272
1273 await db
1274 .update(repositories)
1275 .set({
1276 autoGenerateTests: next,
1277 updatedAt: new Date(),
1278 })
1279 .where(eq(repositories.id, repo.id));
1280
1281 if (next !== repo.autoGenerateTests) {
1282 await audit({
1283 userId: user.id,
1284 repositoryId: repo.id,
1285 action: "repo.auto_generate_tests.toggled",
1286 targetType: "repository",
1287 targetId: repo.id,
1288 metadata: { from: repo.autoGenerateTests, to: next },
1289 });
1290 }
1291
1292 return c.redirect(
1293 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1294 );
1295 }
1296);
1297
79136bbClaude1298// Delete repository
1299repoSettings.post(
1300 "/:owner/:repo/settings/delete",
1301 requireAuth,
febd4f0Claude1302 requireRepoAccess("admin"),
79136bbClaude1303 async (c) => {
1304 const { owner: ownerName, repo: repoName } = c.req.param();
1305 const user = c.get("user")!;
1306
1307 const [owner] = await db
1308 .select()
1309 .from(users)
1310 .where(eq(users.username, ownerName))
1311 .limit(1);
1312
1313 if (!owner || owner.id !== user.id) {
1314 return c.redirect(`/${ownerName}/${repoName}`);
1315 }
1316
1317 const [repo] = await db
1318 .select()
1319 .from(repositories)
1320 .where(
1321 and(
1322 eq(repositories.ownerId, owner.id),
1323 eq(repositories.name, repoName)
1324 )
1325 )
1326 .limit(1);
1327
1328 if (!repo) return c.redirect(`/${ownerName}`);
1329
1330 // Delete from disk
1331 try {
1332 await rm(repo.diskPath, { recursive: true, force: true });
1333 } catch {
1334 // Disk cleanup best-effort
1335 }
1336
1337 // Delete from DB (cascades to stars, issues, etc.)
1338 await db.delete(repositories).where(eq(repositories.id, repo.id));
1339
1340 return c.redirect(`/${ownerName}`);
1341 }
1342);
1343
1344export default repoSettings;