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.tsxBlame606 lines · 2 contributors
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";
bb0f894Claude17import {
18 Container,
19 Form,
20 FormGroup,
21 Input,
22 Select,
23 Button,
24 Alert,
25 EmptyState,
26 Text,
27} from "../views/ui";
79136bbClaude28
29const repoSettings = new Hono<AuthEnv>();
30
31repoSettings.use("*", softAuth);
32
33// Settings page
febd4f0Claude34repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude35 const { owner: ownerName, repo: repoName } = c.req.param();
36 const user = c.get("user")!;
37 const success = c.req.query("success");
38 const error = c.req.query("error");
39
40 const [owner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45
46 if (!owner || owner.id !== user.id) {
47 return c.html(
48 <Layout title="Unauthorized" user={user}>
bb0f894Claude49 <EmptyState title="Unauthorized">
79136bbClaude50 <p>Only the repository owner can access settings.</p>
bb0f894Claude51 </EmptyState>
79136bbClaude52 </Layout>,
53 403
54 );
55 }
56
57 const [repo] = await db
58 .select()
59 .from(repositories)
60 .where(
61 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
62 )
63 .limit(1);
64
65 if (!repo) return c.notFound();
66
67 const branches = await listBranches(ownerName, repoName);
68
69 return c.html(
70 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
71 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude72 <Container maxWidth={600}>
79136bbClaude73 <h2 style="margin-bottom: 20px">Repository settings</h2>
23d1a81Claude74 <p style="margin-bottom: 16px">
75 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
76 Manage collaborators →
77 </a>
78 </p>
79136bbClaude79 {success && (
bb0f894Claude80 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude81 )}
82 {error && (
bb0f894Claude83 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude84 )}
85
0316dbbClaude86 <Form
e7e240eClaude87 method="post"
79136bbClaude88 action={`/${ownerName}/${repoName}/settings`}
89 >
bb0f894Claude90 <FormGroup label="Description" htmlFor="description">
91 <Input
79136bbClaude92 name="description"
bb0f894Claude93 id="description"
79136bbClaude94 value={repo.description || ""}
95 placeholder="A short description"
96 />
bb0f894Claude97 </FormGroup>
98 <FormGroup label="Default branch" htmlFor="default_branch">
99 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude100 {branches.length === 0 ? (
101 <option value={repo.defaultBranch}>
102 {repo.defaultBranch}
103 </option>
104 ) : (
105 branches.map((b) => (
106 <option value={b} selected={b === repo.defaultBranch}>
107 {b}
108 </option>
109 ))
110 )}
bb0f894Claude111 </Select>
112 </FormGroup>
113 <FormGroup label="Visibility">
79136bbClaude114 <div class="visibility-options">
115 <label class="visibility-option">
116 <input
117 type="radio"
118 name="visibility"
119 value="public"
120 checked={!repo.isPrivate}
63c60ebcopilot-swe-agent[bot]121 aria-label="Public"
79136bbClaude122 />
123 <div class="vis-label">Public</div>
124 </label>
125 <label class="visibility-option">
126 <input
127 type="radio"
128 name="visibility"
129 value="private"
130 checked={repo.isPrivate}
63c60ebcopilot-swe-agent[bot]131 aria-label="Private"
79136bbClaude132 />
133 <div class="vis-label">Private</div>
134 </label>
135 </div>
bb0f894Claude136 </FormGroup>
137 <Button type="submit" variant="primary">
79136bbClaude138 Save changes
bb0f894Claude139 </Button>
140 </Form>
79136bbClaude141
142 <div
71cd5ecClaude143 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
14c3cc8Claude144 >
145 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
146 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
147 Paste a plain-English feature spec and let Claude draft a pull
148 request for you. PRs are opened as drafts — review every line
149 before merging.
150 </p>
151 <a
152 href={`/${ownerName}/${repoName}/spec`}
153 class="btn"
154 >
155 Open Spec to PR
156 </a>
157 </div>
158
159 <div
160 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude161 >
162 <h3 style="margin-bottom: 8px">Template repository</h3>
163 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
164 {repo.isTemplate
165 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
166 : "Mark this repository as a template so others can seed new repositories from its files."}
167 </p>
168 <form
e7e240eClaude169 method="post"
71cd5ecClaude170 action={`/${ownerName}/${repoName}/settings/template`}
171 >
172 <input
173 type="hidden"
174 name="template"
175 value={repo.isTemplate ? "0" : "1"}
176 />
177 <button type="submit" class="btn">
178 {repo.isTemplate
179 ? "Unmark as template"
180 : "Mark as template"}
181 </button>
182 </form>
183 </div>
184
185 <div
186 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
187 >
188 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
189 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
190 Transfer this repository to another user. The new owner can
191 accept or decline the transfer by attempting to view it.
192 </p>
193 <form
e7e240eClaude194 method="post"
71cd5ecClaude195 action={`/${ownerName}/${repoName}/settings/transfer`}
196 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
197 >
198 <input
199 type="text"
200 name="new_owner"
201 placeholder="new-owner-username"
202 required
63c60ebcopilot-swe-agent[bot]203 aria-label="New owner username"
71cd5ecClaude204 style="width:60%"
205 />{" "}
206 <button type="submit" class="btn">
207 Transfer
208 </button>
209 </form>
210 </div>
211
212 <div
213 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
214 >
215 <h3 style="margin-bottom: 8px">
216 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
217 </h3>
218 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
219 {repo.isArchived
220 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
221 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
222 </p>
223 <form
e7e240eClaude224 method="post"
71cd5ecClaude225 action={`/${ownerName}/${repoName}/settings/archive`}
226 >
227 <input
228 type="hidden"
229 name="archive"
230 value={repo.isArchived ? "0" : "1"}
231 />
232 <button type="submit" class="btn">
233 {repo.isArchived ? "Unarchive" : "Archive"} this repository
234 </button>
235 </form>
236 </div>
237
534f04aClaude238 <div
239 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
240 >
241 <h3 style="margin-bottom: 8px">Stale activity</h3>
242 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
243 Autopilot pokes PRs and issues that have gone quiet, then offers
244 a one-click close path. Each setting controls the final close
245 step — pokes always happen, but they're harmless reminders.
246 </p>
247 <form
248 method="post"
249 action={`/${ownerName}/${repoName}/settings/stale`}
250 >
251 <label
252 style="display:block;margin-bottom:8px;font-size:14px"
253 aria-label="Auto-close stale PRs after 14 days of no activity post-poke"
254 >
255 <input
256 type="checkbox"
257 name="auto_close_stale_prs"
258 value="1"
259 checked={repo.autoCloseStalePrs}
260 style="margin-right:8px"
261 />
262 Auto-close stale PRs after 14 days of no activity post-poke
263 </label>
264 <label
265 style="display:block;margin-bottom:12px;font-size:14px"
266 aria-label="Auto-close stale issues after 60 days of no activity post-poke"
267 >
268 <input
269 type="checkbox"
270 name="auto_close_stale_issues"
271 value="1"
272 checked={repo.autoCloseStaleIssues}
273 style="margin-right:8px"
274 />
275 Auto-close stale issues after 60 days of no activity post-poke
276 </label>
277 <button type="submit" class="btn">
278 Save stale settings
279 </button>
280 </form>
281 </div>
282
71cd5ecClaude283 <div
284 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude285 >
286 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude287 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude288 Permanently delete this repository and all its data.
bb0f894Claude289 </Text>
79136bbClaude290 <form
e7e240eClaude291 method="post"
79136bbClaude292 action={`/${ownerName}/${repoName}/settings/delete`}
293 onsubmit="return confirm('Are you sure? This cannot be undone.')"
294 >
bb0f894Claude295 <Button type="submit" variant="danger">
79136bbClaude296 Delete this repository
bb0f894Claude297 </Button>
79136bbClaude298 </form>
299 </div>
bb0f894Claude300 </Container>
79136bbClaude301 </Layout>
302 );
303});
304
305// Save settings
febd4f0Claude306repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude307 const { owner: ownerName, repo: repoName } = c.req.param();
308 const user = c.get("user")!;
309 const body = await c.req.parseBody();
310
311 const [owner] = await db
312 .select()
313 .from(users)
314 .where(eq(users.username, ownerName))
315 .limit(1);
316
317 if (!owner || owner.id !== user.id) {
318 return c.redirect(`/${ownerName}/${repoName}`);
319 }
320
321 await db
322 .update(repositories)
323 .set({
324 description: String(body.description || "").trim() || null,
325 defaultBranch: String(body.default_branch || "main"),
326 isPrivate: body.visibility === "private",
327 updatedAt: new Date(),
328 })
329 .where(
330 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
331 );
332
333 return c.redirect(
334 `/${ownerName}/${repoName}/settings?success=Settings+saved`
335 );
336});
337
71cd5ecClaude338// Toggle template flag
339repoSettings.post(
340 "/:owner/:repo/settings/template",
341 requireAuth,
febd4f0Claude342 requireRepoAccess("admin"),
71cd5ecClaude343 async (c) => {
344 const { owner: ownerName, repo: repoName } = c.req.param();
345 const user = c.get("user")!;
346 const body = await c.req.parseBody();
347 const [owner] = await db
348 .select()
349 .from(users)
350 .where(eq(users.username, ownerName))
351 .limit(1);
352 if (!owner || owner.id !== user.id) {
353 return c.redirect(`/${ownerName}/${repoName}`);
354 }
355 const target = String(body.template || "1") === "1";
356 await db
357 .update(repositories)
358 .set({ isTemplate: target, updatedAt: new Date() })
359 .where(
360 and(
361 eq(repositories.ownerId, owner.id),
362 eq(repositories.name, repoName)
363 )
364 );
365 return c.redirect(
366 `/${ownerName}/${repoName}/settings?success=${
367 target ? "Marked+as+template" : "Unmarked+as+template"
368 }`
369 );
370 }
371);
372
373// Transfer repository to a new owner (by username)
374repoSettings.post(
375 "/:owner/:repo/settings/transfer",
376 requireAuth,
febd4f0Claude377 requireRepoAccess("admin"),
71cd5ecClaude378 async (c) => {
379 const { owner: ownerName, repo: repoName } = c.req.param();
380 const user = c.get("user")!;
381 const body = await c.req.parseBody();
382 const newOwnerName = String(body.new_owner || "").trim();
383 if (!newOwnerName) {
384 return c.redirect(
385 `/${ownerName}/${repoName}/settings?error=New+owner+required`
386 );
387 }
388 const [owner] = await db
389 .select()
390 .from(users)
391 .where(eq(users.username, ownerName))
392 .limit(1);
393 if (!owner || owner.id !== user.id) {
394 return c.redirect(`/${ownerName}/${repoName}`);
395 }
396 const [newOwner] = await db
397 .select()
398 .from(users)
399 .where(eq(users.username, newOwnerName))
400 .limit(1);
401 if (!newOwner) {
402 return c.redirect(
403 `/${ownerName}/${repoName}/settings?error=User+not+found`
404 );
405 }
406 if (newOwner.id === owner.id) {
407 return c.redirect(
408 `/${ownerName}/${repoName}/settings?error=Same+owner`
409 );
410 }
411 // Reject if new owner already has a repo by this name
412 const [conflict] = await db
413 .select()
414 .from(repositories)
415 .where(
416 and(
417 eq(repositories.ownerId, newOwner.id),
418 eq(repositories.name, repoName)
419 )
420 )
421 .limit(1);
422 if (conflict) {
423 return c.redirect(
424 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
425 );
426 }
427 const [repo] = await db
428 .select()
429 .from(repositories)
430 .where(
431 and(
432 eq(repositories.ownerId, owner.id),
433 eq(repositories.name, repoName)
434 )
435 )
436 .limit(1);
437 if (!repo) return c.notFound();
438 await db
439 .update(repositories)
440 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
441 .where(eq(repositories.id, repo.id));
442 await db.insert(repoTransfers).values({
443 repositoryId: repo.id,
444 fromOwnerId: owner.id,
445 fromOrgId: repo.orgId,
446 toOwnerId: newOwner.id,
447 toOrgId: null,
448 initiatedBy: user.id,
449 });
450 return c.redirect(`/${newOwnerName}/${repoName}`);
451 }
452);
453
454// Archive / unarchive repository
455repoSettings.post(
456 "/:owner/:repo/settings/archive",
457 requireAuth,
febd4f0Claude458 requireRepoAccess("admin"),
71cd5ecClaude459 async (c) => {
460 const { owner: ownerName, repo: repoName } = c.req.param();
461 const user = c.get("user")!;
462 const body = await c.req.parseBody();
463 const [owner] = await db
464 .select()
465 .from(users)
466 .where(eq(users.username, ownerName))
467 .limit(1);
468 if (!owner || owner.id !== user.id) {
469 return c.redirect(`/${ownerName}/${repoName}`);
470 }
471 const target = String(body.archive || "1") === "1";
472 await db
473 .update(repositories)
474 .set({ isArchived: target, updatedAt: new Date() })
475 .where(
476 and(
477 eq(repositories.ownerId, owner.id),
478 eq(repositories.name, repoName)
479 )
480 );
481 return c.redirect(
482 `/${ownerName}/${repoName}/settings?success=${
483 target ? "Repository+archived" : "Repository+unarchived"
484 }`
485 );
486 }
487);
488
534f04aClaude489// Block M5: stale activity opt-out flags. Owner-only; audits each toggle.
490repoSettings.post(
491 "/:owner/:repo/settings/stale",
492 requireAuth,
493 requireRepoAccess("admin"),
494 async (c) => {
495 const { owner: ownerName, repo: repoName } = c.req.param();
496 const user = c.get("user")!;
497 const body = await c.req.parseBody();
498 const [owner] = await db
499 .select()
500 .from(users)
501 .where(eq(users.username, ownerName))
502 .limit(1);
503 if (!owner || owner.id !== user.id) {
504 return c.redirect(`/${ownerName}/${repoName}`);
505 }
506 const [repo] = await db
507 .select()
508 .from(repositories)
509 .where(
510 and(
511 eq(repositories.ownerId, owner.id),
512 eq(repositories.name, repoName)
513 )
514 )
515 .limit(1);
516 if (!repo) return c.notFound();
517
518 // Unchecked checkboxes are absent from the form payload, so coerce to bool.
519 const newPrs = body.auto_close_stale_prs === "1";
520 const newIssues = body.auto_close_stale_issues === "1";
521
522 await db
523 .update(repositories)
524 .set({
525 autoCloseStalePrs: newPrs,
526 autoCloseStaleIssues: newIssues,
527 updatedAt: new Date(),
528 })
529 .where(eq(repositories.id, repo.id));
530
531 // Audit toggle deltas so the repo's audit log shows the change. Two
532 // separate rows (one per flag) so the action names stay stable + grep-able.
533 if (newPrs !== repo.autoCloseStalePrs) {
534 await audit({
535 userId: user.id,
536 repositoryId: repo.id,
537 action: "repo.auto_close_stale_prs.toggled",
538 targetType: "repository",
539 targetId: repo.id,
540 metadata: { from: repo.autoCloseStalePrs, to: newPrs },
541 });
542 }
543 if (newIssues !== repo.autoCloseStaleIssues) {
544 await audit({
545 userId: user.id,
546 repositoryId: repo.id,
547 action: "repo.auto_close_stale_issues.toggled",
548 targetType: "repository",
549 targetId: repo.id,
550 metadata: { from: repo.autoCloseStaleIssues, to: newIssues },
551 });
552 }
553
554 return c.redirect(
555 `/${ownerName}/${repoName}/settings?success=Stale+settings+saved`
556 );
557 }
558);
559
79136bbClaude560// Delete repository
561repoSettings.post(
562 "/:owner/:repo/settings/delete",
563 requireAuth,
febd4f0Claude564 requireRepoAccess("admin"),
79136bbClaude565 async (c) => {
566 const { owner: ownerName, repo: repoName } = c.req.param();
567 const user = c.get("user")!;
568
569 const [owner] = await db
570 .select()
571 .from(users)
572 .where(eq(users.username, ownerName))
573 .limit(1);
574
575 if (!owner || owner.id !== user.id) {
576 return c.redirect(`/${ownerName}/${repoName}`);
577 }
578
579 const [repo] = await db
580 .select()
581 .from(repositories)
582 .where(
583 and(
584 eq(repositories.ownerId, owner.id),
585 eq(repositories.name, repoName)
586 )
587 )
588 .limit(1);
589
590 if (!repo) return c.redirect(`/${ownerName}`);
591
592 // Delete from disk
593 try {
594 await rm(repo.diskPath, { recursive: true, force: true });
595 } catch {
596 // Disk cleanup best-effort
597 }
598
599 // Delete from DB (cascades to stars, issues, etc.)
600 await db.delete(repositories).where(eq(repositories.id, repo.id));
601
602 return c.redirect(`/${ownerName}`);
603 }
604);
605
606export default repoSettings;