Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1240 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
58307aeClaude843 {/* ─── Stale activity ─── */}
844 <section class="repo-settings-section">
845 <div class="repo-settings-section-head">
846 <div class="repo-settings-section-eyebrow">Stale sweep</div>
847 <h2 class="repo-settings-section-title">Stale activity</h2>
848 <p class="repo-settings-section-desc">
849 Autopilot pokes PRs and issues that have gone quiet, then offers
850 a one-click close path. Each toggle controls the final close
851 step — pokes always happen, but they&apos;re harmless reminders.
852 </p>
853 </div>
79136bbClaude854 <form
e7e240eClaude855 method="post"
58307aeClaude856 action={`/${ownerName}/${repoName}/settings/stale`}
79136bbClaude857 >
58307aeClaude858 <div class="repo-settings-section-body">
859 <label
860 class="repo-settings-toggle-row"
861 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
862 >
863 <input
864 type="checkbox"
865 name="auto_close_stale_prs"
866 value="1"
867 checked={repo.autoCloseStalePrs}
868 />
869 <span class="repo-settings-toggle-text">
870 <span class="repo-settings-toggle-text-title">
871 Auto-close stale PRs
872 </span>
873 <span class="repo-settings-toggle-text-hint">
874 Close PRs that go quiet for 14 days after autopilot pokes
875 them.
876 </span>
877 </span>
878 </label>
879 <label
880 class="repo-settings-toggle-row"
881 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
882 >
883 <input
884 type="checkbox"
885 name="auto_close_stale_issues"
886 value="1"
887 checked={repo.autoCloseStaleIssues}
888 />
889 <span class="repo-settings-toggle-text">
890 <span class="repo-settings-toggle-text-title">
891 Auto-close stale issues
892 </span>
893 <span class="repo-settings-toggle-text-hint">
894 Close issues that go quiet for 60 days after autopilot
895 pokes them.
896 </span>
897 </span>
898 </label>
899 </div>
900 <div class="repo-settings-section-foot">
901 <button type="submit" class="repo-settings-cta">
902 Save stale settings <span class="arrow">→</span>
903 </button>
904 </div>
79136bbClaude905 </form>
58307aeClaude906 </section>
907
908 {/* ─── Danger zone ─── */}
909 <section class="repo-settings-danger">
910 <div class="repo-settings-danger-head">
911 <div class="repo-settings-danger-eyebrow">Danger zone</div>
912 <h2 class="repo-settings-danger-title">Delete this repository</h2>
913 <p class="repo-settings-danger-desc">
914 Permanently remove this repository and every byte of its history,
915 issues, PRs, stars, and webhooks. There is no undo.
916 </p>
917 </div>
918 <div class="repo-settings-danger-body">
919 <p class="muted">
920 Once deleted, the URL <code>{ownerName}/{repoName}</code> frees
921 up immediately. Open clones will fail to fetch or push.
922 </p>
923 <form
924 method="post"
925 action={`/${ownerName}/${repoName}/settings/delete`}
926 onsubmit="return confirm('Are you sure? This cannot be undone.')"
927 >
928 <button type="submit" class="repo-settings-danger-btn">
929 Delete this repository
930 </button>
931 </form>
932 </div>
933 </section>
934 </div>
79136bbClaude935 </Layout>
936 );
937});
938
939// Save settings
febd4f0Claude940repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude941 const { owner: ownerName, repo: repoName } = c.req.param();
942 const user = c.get("user")!;
943 const body = await c.req.parseBody();
944
945 const [owner] = await db
946 .select()
947 .from(users)
948 .where(eq(users.username, ownerName))
949 .limit(1);
950
951 if (!owner || owner.id !== user.id) {
952 return c.redirect(`/${ownerName}/${repoName}`);
953 }
954
955 await db
956 .update(repositories)
957 .set({
958 description: String(body.description || "").trim() || null,
959 defaultBranch: String(body.default_branch || "main"),
960 isPrivate: body.visibility === "private",
961 updatedAt: new Date(),
962 })
963 .where(
964 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
965 );
966
967 return c.redirect(
968 `/${ownerName}/${repoName}/settings?success=Settings+saved`
969 );
970});
971
71cd5ecClaude972// Toggle template flag
973repoSettings.post(
974 "/:owner/:repo/settings/template",
975 requireAuth,
febd4f0Claude976 requireRepoAccess("admin"),
71cd5ecClaude977 async (c) => {
978 const { owner: ownerName, repo: repoName } = c.req.param();
979 const user = c.get("user")!;
980 const body = await c.req.parseBody();
981 const [owner] = await db
982 .select()
983 .from(users)
984 .where(eq(users.username, ownerName))
985 .limit(1);
986 if (!owner || owner.id !== user.id) {
987 return c.redirect(`/${ownerName}/${repoName}`);
988 }
989 const target = String(body.template || "1") === "1";
990 await db
991 .update(repositories)
992 .set({ isTemplate: target, updatedAt: new Date() })
993 .where(
994 and(
995 eq(repositories.ownerId, owner.id),
996 eq(repositories.name, repoName)
997 )
998 );
999 return c.redirect(
1000 `/${ownerName}/${repoName}/settings?success=${
1001 target ? "Marked+as+template" : "Unmarked+as+template"
1002 }`
1003 );
1004 }
1005);
1006
1007// Transfer repository to a new owner (by username)
1008repoSettings.post(
1009 "/:owner/:repo/settings/transfer",
1010 requireAuth,
febd4f0Claude1011 requireRepoAccess("admin"),
71cd5ecClaude1012 async (c) => {
1013 const { owner: ownerName, repo: repoName } = c.req.param();
1014 const user = c.get("user")!;
1015 const body = await c.req.parseBody();
1016 const newOwnerName = String(body.new_owner || "").trim();
1017 if (!newOwnerName) {
1018 return c.redirect(
1019 `/${ownerName}/${repoName}/settings?error=New+owner+required`
1020 );
1021 }
1022 const [owner] = await db
1023 .select()
1024 .from(users)
1025 .where(eq(users.username, ownerName))
1026 .limit(1);
1027 if (!owner || owner.id !== user.id) {
1028 return c.redirect(`/${ownerName}/${repoName}`);
1029 }
1030 const [newOwner] = await db
1031 .select()
1032 .from(users)
1033 .where(eq(users.username, newOwnerName))
1034 .limit(1);
1035 if (!newOwner) {
1036 return c.redirect(
1037 `/${ownerName}/${repoName}/settings?error=User+not+found`
1038 );
1039 }
1040 if (newOwner.id === owner.id) {
1041 return c.redirect(
1042 `/${ownerName}/${repoName}/settings?error=Same+owner`
1043 );
1044 }
1045 // Reject if new owner already has a repo by this name
1046 const [conflict] = await db
1047 .select()
1048 .from(repositories)
1049 .where(
1050 and(
1051 eq(repositories.ownerId, newOwner.id),
1052 eq(repositories.name, repoName)
1053 )
1054 )
1055 .limit(1);
1056 if (conflict) {
1057 return c.redirect(
1058 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
1059 );
1060 }
1061 const [repo] = await db
1062 .select()
1063 .from(repositories)
1064 .where(
1065 and(
1066 eq(repositories.ownerId, owner.id),
1067 eq(repositories.name, repoName)
1068 )
1069 )
1070 .limit(1);
1071 if (!repo) return c.notFound();
1072 await db
1073 .update(repositories)
1074 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
1075 .where(eq(repositories.id, repo.id));
1076 await db.insert(repoTransfers).values({
1077 repositoryId: repo.id,
1078 fromOwnerId: owner.id,
1079 fromOrgId: repo.orgId,
1080 toOwnerId: newOwner.id,
1081 toOrgId: null,
1082 initiatedBy: user.id,
1083 });
1084 return c.redirect(`/${newOwnerName}/${repoName}`);
1085 }
1086);
1087
1088// Archive / unarchive repository
1089repoSettings.post(
1090 "/:owner/:repo/settings/archive",
1091 requireAuth,
febd4f0Claude1092 requireRepoAccess("admin"),
71cd5ecClaude1093 async (c) => {
1094 const { owner: ownerName, repo: repoName } = c.req.param();
1095 const user = c.get("user")!;
1096 const body = await c.req.parseBody();
1097 const [owner] = await db
1098 .select()
1099 .from(users)
1100 .where(eq(users.username, ownerName))
1101 .limit(1);
1102 if (!owner || owner.id !== user.id) {
1103 return c.redirect(`/${ownerName}/${repoName}`);
1104 }
1105 const target = String(body.archive || "1") === "1";
1106 await db
1107 .update(repositories)
1108 .set({ isArchived: target, updatedAt: new Date() })
1109 .where(
1110 and(
1111 eq(repositories.ownerId, owner.id),
1112 eq(repositories.name, repoName)
1113 )
1114 );
1115 return c.redirect(
1116 `/${ownerName}/${repoName}/settings?success=${
1117 target ? "Repository+archived" : "Repository+unarchived"
1118 }`
1119 );
1120 }
1121);
1122
534f04aClaude1123// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
1124repoSettings.post(
1125 "/:owner/:repo/settings/stale",
1126 requireAuth,
1127 requireRepoAccess("admin"),
1128 async (c) => {
1129 const { owner: ownerName, repo: repoName } = c.req.param();
1130 const user = c.get("user")!;
1131 const body = await c.req.parseBody();
1132 const [owner] = await db
1133 .select()
1134 .from(users)
1135 .where(eq(users.username, ownerName))
1136 .limit(1);
1137 if (!owner || owner.id !== user.id) {
1138 return c.redirect(`/${ownerName}/${repoName}`);
1139 }
1140 const [repo] = await db
1141 .select()
1142 .from(repositories)
1143 .where(
1144 and(
1145 eq(repositories.ownerId, owner.id),
1146 eq(repositories.name, repoName)
1147 )
1148 )
1149 .limit(1);
1150 if (!repo) return c.notFound();
1151
1152 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
1153 const newPrs = body.auto_close_stale_prs === "1";
1154 const newIssues = body.auto_close_stale_issues === "1";
1155
1156 await db
1157 .update(repositories)
1158 .set({
1159 autoCloseStalePrs: newPrs,
1160 autoCloseStaleIssues: newIssues,
1161 updatedAt: new Date(),
1162 })
1163 .where(eq(repositories.id, repo.id));
1164
1165 // Audit toggle deltas so the repo's audit log shows the change. Two
1166 // separate rows (one per flag) so the action names stay stable + grep-able.
1167 if (newPrs !== repo.autoCloseStalePrs) {
1168 await audit({
1169 userId: user.id,
1170 repositoryId: repo.id,
1171 action: "repo.auto_close_stale_prs.toggled",
1172 targetType: "repository",
1173 targetId: repo.id,
1174 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
1175 });
1176 }
1177 if (newIssues !== repo.autoCloseStaleIssues) {
1178 await audit({
1179 userId: user.id,
1180 repositoryId: repo.id,
1181 action: "repo.auto_close_stale_issues.toggled",
1182 targetType: "repository",
1183 targetId: repo.id,
1184 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
1185 });
1186 }
1187
1188 return c.redirect(
1189 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
1190 );
1191 }
1192);
1193
79136bbClaude1194// Delete repository
1195repoSettings.post(
1196 "/:owner/:repo/settings/delete",
1197 requireAuth,
febd4f0Claude1198 requireRepoAccess("admin"),
79136bbClaude1199 async (c) => {
1200 const { owner: ownerName, repo: repoName } = c.req.param();
1201 const user = c.get("user")!;
1202
1203 const [owner] = await db
1204 .select()
1205 .from(users)
1206 .where(eq(users.username, ownerName))
1207 .limit(1);
1208
1209 if (!owner || owner.id !== user.id) {
1210 return c.redirect(`/${ownerName}/${repoName}`);
1211 }
1212
1213 const [repo] = await db
1214 .select()
1215 .from(repositories)
1216 .where(
1217 and(
1218 eq(repositories.ownerId, owner.id),
1219 eq(repositories.name, repoName)
1220 )
1221 )
1222 .limit(1);
1223
1224 if (!repo) return c.redirect(`/${ownerName}`);
1225
1226 // Delete from disk
1227 try {
1228 await rm(repo.diskPath, { recursive: true, force: true });
1229 } catch {
1230 // Disk cleanup best-effort
1231 }
1232
1233 // Delete from DB (cascades to stars, issues, etc.)
1234 await db.delete(repositories).where(eq(repositories.id, repo.id));
1235
1236 return c.redirect(`/${ownerName}`);
1237 }
1238);
1239
1240export default repoSettings;