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.tsxBlame489 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";
15import { rm } from "fs/promises";
bb0f894Claude16import {
17 Container,
18 Form,
19 FormGroup,
20 Input,
21 Select,
22 Button,
23 Alert,
24 EmptyState,
25 Text,
26} from "../views/ui";
79136bbClaude27
28const repoSettings = new Hono<AuthEnv>();
29
30repoSettings.use("*", softAuth);
31
32// Settings page
febd4f0Claude33repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude34 const { owner: ownerName, repo: repoName } = c.req.param();
35 const user = c.get("user")!;
36 const success = c.req.query("success");
37 const error = c.req.query("error");
38
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44
45 if (!owner || owner.id !== user.id) {
46 return c.html(
47 <Layout title="Unauthorized" user={user}>
bb0f894Claude48 <EmptyState title="Unauthorized">
79136bbClaude49 <p>Only the repository owner can access settings.</p>
bb0f894Claude50 </EmptyState>
79136bbClaude51 </Layout>,
52 403
53 );
54 }
55
56 const [repo] = await db
57 .select()
58 .from(repositories)
59 .where(
60 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
61 )
62 .limit(1);
63
64 if (!repo) return c.notFound();
65
66 const branches = await listBranches(ownerName, repoName);
67
68 return c.html(
69 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
70 <RepoHeader owner={ownerName} repo={repoName} />
bb0f894Claude71 <Container maxWidth={600}>
79136bbClaude72 <h2 style="margin-bottom: 20px">Repository settings</h2>
23d1a81Claude73 <p style="margin-bottom: 16px">
74 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
75 Manage collaborators →
76 </a>
77 </p>
79136bbClaude78 {success && (
bb0f894Claude79 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude80 )}
81 {error && (
bb0f894Claude82 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude83 )}
84
0316dbbClaude85 <Form
e7e240eClaude86 method="post"
79136bbClaude87 action={`/${ownerName}/${repoName}/settings`}
88 >
bb0f894Claude89 <FormGroup label="Description" htmlFor="description">
90 <Input
79136bbClaude91 name="description"
bb0f894Claude92 id="description"
79136bbClaude93 value={repo.description || ""}
94 placeholder="A short description"
95 />
bb0f894Claude96 </FormGroup>
97 <FormGroup label="Default branch" htmlFor="default_branch">
98 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude99 {branches.length === 0 ? (
100 <option value={repo.defaultBranch}>
101 {repo.defaultBranch}
102 </option>
103 ) : (
104 branches.map((b) => (
105 <option value={b} selected={b === repo.defaultBranch}>
106 {b}
107 </option>
108 ))
109 )}
bb0f894Claude110 </Select>
111 </FormGroup>
112 <FormGroup label="Visibility">
79136bbClaude113 <div class="visibility-options">
114 <label class="visibility-option">
115 <input
116 type="radio"
117 name="visibility"
118 value="public"
119 checked={!repo.isPrivate}
63c60ebcopilot-swe-agent[bot]120 aria-label="Public"
79136bbClaude121 />
122 <div class="vis-label">Public</div>
123 </label>
124 <label class="visibility-option">
125 <input
126 type="radio"
127 name="visibility"
128 value="private"
129 checked={repo.isPrivate}
63c60ebcopilot-swe-agent[bot]130 aria-label="Private"
79136bbClaude131 />
132 <div class="vis-label">Private</div>
133 </label>
134 </div>
bb0f894Claude135 </FormGroup>
136 <Button type="submit" variant="primary">
79136bbClaude137 Save changes
bb0f894Claude138 </Button>
139 </Form>
79136bbClaude140
141 <div
71cd5ecClaude142 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
14c3cc8Claude143 >
144 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
145 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
146 Paste a plain-English feature spec and let Claude draft a pull
147 request for you. PRs are opened as drafts — review every line
148 before merging.
149 </p>
150 <a
151 href={`/${ownerName}/${repoName}/spec`}
152 class="btn"
153 >
154 Open Spec to PR
155 </a>
156 </div>
157
158 <div
159 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude160 >
161 <h3 style="margin-bottom: 8px">Template repository</h3>
162 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
163 {repo.isTemplate
164 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
165 : "Mark this repository as a template so others can seed new repositories from its files."}
166 </p>
167 <form
e7e240eClaude168 method="post"
71cd5ecClaude169 action={`/${ownerName}/${repoName}/settings/template`}
170 >
171 <input
172 type="hidden"
173 name="template"
174 value={repo.isTemplate ? "0" : "1"}
175 />
176 <button type="submit" class="btn">
177 {repo.isTemplate
178 ? "Unmark as template"
179 : "Mark as template"}
180 </button>
181 </form>
182 </div>
183
184 <div
185 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
186 >
187 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
188 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
189 Transfer this repository to another user. The new owner can
190 accept or decline the transfer by attempting to view it.
191 </p>
192 <form
e7e240eClaude193 method="post"
71cd5ecClaude194 action={`/${ownerName}/${repoName}/settings/transfer`}
195 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
196 >
197 <input
198 type="text"
199 name="new_owner"
200 placeholder="new-owner-username"
201 required
63c60ebcopilot-swe-agent[bot]202 aria-label="New owner username"
71cd5ecClaude203 style="width:60%"
204 />{" "}
205 <button type="submit" class="btn">
206 Transfer
207 </button>
208 </form>
209 </div>
210
211 <div
212 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
213 >
214 <h3 style="margin-bottom: 8px">
215 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
216 </h3>
217 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
218 {repo.isArchived
219 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
220 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
221 </p>
222 <form
e7e240eClaude223 method="post"
71cd5ecClaude224 action={`/${ownerName}/${repoName}/settings/archive`}
225 >
226 <input
227 type="hidden"
228 name="archive"
229 value={repo.isArchived ? "0" : "1"}
230 />
231 <button type="submit" class="btn">
232 {repo.isArchived ? "Unarchive" : "Archive"} this repository
233 </button>
234 </form>
235 </div>
236
237 <div
238 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude239 >
240 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude241 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude242 Permanently delete this repository and all its data.
bb0f894Claude243 </Text>
79136bbClaude244 <form
e7e240eClaude245 method="post"
79136bbClaude246 action={`/${ownerName}/${repoName}/settings/delete`}
247 onsubmit="return confirm('Are you sure? This cannot be undone.')"
248 >
bb0f894Claude249 <Button type="submit" variant="danger">
79136bbClaude250 Delete this repository
bb0f894Claude251 </Button>
79136bbClaude252 </form>
253 </div>
bb0f894Claude254 </Container>
79136bbClaude255 </Layout>
256 );
257});
258
259// Save settings
febd4f0Claude260repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude261 const { owner: ownerName, repo: repoName } = c.req.param();
262 const user = c.get("user")!;
263 const body = await c.req.parseBody();
264
265 const [owner] = await db
266 .select()
267 .from(users)
268 .where(eq(users.username, ownerName))
269 .limit(1);
270
271 if (!owner || owner.id !== user.id) {
272 return c.redirect(`/${ownerName}/${repoName}`);
273 }
274
275 await db
276 .update(repositories)
277 .set({
278 description: String(body.description || "").trim() || null,
279 defaultBranch: String(body.default_branch || "main"),
280 isPrivate: body.visibility === "private",
281 updatedAt: new Date(),
282 })
283 .where(
284 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
285 );
286
287 return c.redirect(
288 `/${ownerName}/${repoName}/settings?success=Settings+saved`
289 );
290});
291
71cd5ecClaude292// Toggle template flag
293repoSettings.post(
294 "/:owner/:repo/settings/template",
295 requireAuth,
febd4f0Claude296 requireRepoAccess("admin"),
71cd5ecClaude297 async (c) => {
298 const { owner: ownerName, repo: repoName } = c.req.param();
299 const user = c.get("user")!;
300 const body = await c.req.parseBody();
301 const [owner] = await db
302 .select()
303 .from(users)
304 .where(eq(users.username, ownerName))
305 .limit(1);
306 if (!owner || owner.id !== user.id) {
307 return c.redirect(`/${ownerName}/${repoName}`);
308 }
309 const target = String(body.template || "1") === "1";
310 await db
311 .update(repositories)
312 .set({ isTemplate: target, updatedAt: new Date() })
313 .where(
314 and(
315 eq(repositories.ownerId, owner.id),
316 eq(repositories.name, repoName)
317 )
318 );
319 return c.redirect(
320 `/${ownerName}/${repoName}/settings?success=${
321 target ? "Marked+as+template" : "Unmarked+as+template"
322 }`
323 );
324 }
325);
326
327// Transfer repository to a new owner (by username)
328repoSettings.post(
329 "/:owner/:repo/settings/transfer",
330 requireAuth,
febd4f0Claude331 requireRepoAccess("admin"),
71cd5ecClaude332 async (c) => {
333 const { owner: ownerName, repo: repoName } = c.req.param();
334 const user = c.get("user")!;
335 const body = await c.req.parseBody();
336 const newOwnerName = String(body.new_owner || "").trim();
337 if (!newOwnerName) {
338 return c.redirect(
339 `/${ownerName}/${repoName}/settings?error=New+owner+required`
340 );
341 }
342 const [owner] = await db
343 .select()
344 .from(users)
345 .where(eq(users.username, ownerName))
346 .limit(1);
347 if (!owner || owner.id !== user.id) {
348 return c.redirect(`/${ownerName}/${repoName}`);
349 }
350 const [newOwner] = await db
351 .select()
352 .from(users)
353 .where(eq(users.username, newOwnerName))
354 .limit(1);
355 if (!newOwner) {
356 return c.redirect(
357 `/${ownerName}/${repoName}/settings?error=User+not+found`
358 );
359 }
360 if (newOwner.id === owner.id) {
361 return c.redirect(
362 `/${ownerName}/${repoName}/settings?error=Same+owner`
363 );
364 }
365 // Reject if new owner already has a repo by this name
366 const [conflict] = await db
367 .select()
368 .from(repositories)
369 .where(
370 and(
371 eq(repositories.ownerId, newOwner.id),
372 eq(repositories.name, repoName)
373 )
374 )
375 .limit(1);
376 if (conflict) {
377 return c.redirect(
378 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
379 );
380 }
381 const [repo] = await db
382 .select()
383 .from(repositories)
384 .where(
385 and(
386 eq(repositories.ownerId, owner.id),
387 eq(repositories.name, repoName)
388 )
389 )
390 .limit(1);
391 if (!repo) return c.notFound();
392 await db
393 .update(repositories)
394 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
395 .where(eq(repositories.id, repo.id));
396 await db.insert(repoTransfers).values({
397 repositoryId: repo.id,
398 fromOwnerId: owner.id,
399 fromOrgId: repo.orgId,
400 toOwnerId: newOwner.id,
401 toOrgId: null,
402 initiatedBy: user.id,
403 });
404 return c.redirect(`/${newOwnerName}/${repoName}`);
405 }
406);
407
408// Archive / unarchive repository
409repoSettings.post(
410 "/:owner/:repo/settings/archive",
411 requireAuth,
febd4f0Claude412 requireRepoAccess("admin"),
71cd5ecClaude413 async (c) => {
414 const { owner: ownerName, repo: repoName } = c.req.param();
415 const user = c.get("user")!;
416 const body = await c.req.parseBody();
417 const [owner] = await db
418 .select()
419 .from(users)
420 .where(eq(users.username, ownerName))
421 .limit(1);
422 if (!owner || owner.id !== user.id) {
423 return c.redirect(`/${ownerName}/${repoName}`);
424 }
425 const target = String(body.archive || "1") === "1";
426 await db
427 .update(repositories)
428 .set({ isArchived: target, updatedAt: new Date() })
429 .where(
430 and(
431 eq(repositories.ownerId, owner.id),
432 eq(repositories.name, repoName)
433 )
434 );
435 return c.redirect(
436 `/${ownerName}/${repoName}/settings?success=${
437 target ? "Repository+archived" : "Repository+unarchived"
438 }`
439 );
440 }
441);
442
79136bbClaude443// Delete repository
444repoSettings.post(
445 "/:owner/:repo/settings/delete",
446 requireAuth,
febd4f0Claude447 requireRepoAccess("admin"),
79136bbClaude448 async (c) => {
449 const { owner: ownerName, repo: repoName } = c.req.param();
450 const user = c.get("user")!;
451
452 const [owner] = await db
453 .select()
454 .from(users)
455 .where(eq(users.username, ownerName))
456 .limit(1);
457
458 if (!owner || owner.id !== user.id) {
459 return c.redirect(`/${ownerName}/${repoName}`);
460 }
461
462 const [repo] = await db
463 .select()
464 .from(repositories)
465 .where(
466 and(
467 eq(repositories.ownerId, owner.id),
468 eq(repositories.name, repoName)
469 )
470 )
471 .limit(1);
472
473 if (!repo) return c.redirect(`/${ownerName}`);
474
475 // Delete from disk
476 try {
477 await rm(repo.diskPath, { recursive: true, force: true });
478 } catch {
479 // Disk cleanup best-effort
480 }
481
482 // Delete from DB (cascades to stars, issues, etc.)
483 await db.delete(repositories).where(eq(repositories.id, repo.id));
484
485 return c.redirect(`/${ownerName}`);
486 }
487);
488
489export default repoSettings;