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.tsxBlame494 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";
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>
40e7738Claude77 {" · "}
78 <a href={`/${ownerName}/${repoName}/settings/integrations`}>
79 Integrations →
80 </a>
81 {" · "}
82 <a href={`/${ownerName}/${repoName}/settings/webhooks`}>
83 Webhooks →
84 </a>
23d1a81Claude85 </p>
79136bbClaude86 {success && (
bb0f894Claude87 <Alert variant="success">{decodeURIComponent(success)}</Alert>
79136bbClaude88 )}
89 {error && (
bb0f894Claude90 <Alert variant="error">{decodeURIComponent(error)}</Alert>
79136bbClaude91 )}
92
0316dbbClaude93 <Form
e7e240eClaude94 method="post"
79136bbClaude95 action={`/${ownerName}/${repoName}/settings`}
96 >
bb0f894Claude97 <FormGroup label="Description" htmlFor="description">
98 <Input
79136bbClaude99 name="description"
bb0f894Claude100 id="description"
79136bbClaude101 value={repo.description || ""}
102 placeholder="A short description"
103 />
bb0f894Claude104 </FormGroup>
105 <FormGroup label="Default branch" htmlFor="default_branch">
106 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
79136bbClaude107 {branches.length === 0 ? (
108 <option value={repo.defaultBranch}>
109 {repo.defaultBranch}
110 </option>
111 ) : (
112 branches.map((b) => (
113 <option value={b} selected={b === repo.defaultBranch}>
114 {b}
115 </option>
116 ))
117 )}
bb0f894Claude118 </Select>
119 </FormGroup>
120 <FormGroup label="Visibility">
79136bbClaude121 <div class="visibility-options">
122 <label class="visibility-option">
123 <input
124 type="radio"
125 name="visibility"
126 value="public"
127 checked={!repo.isPrivate}
128 />
129 <div class="vis-label">Public</div>
130 </label>
131 <label class="visibility-option">
132 <input
133 type="radio"
134 name="visibility"
135 value="private"
136 checked={repo.isPrivate}
137 />
138 <div class="vis-label">Private</div>
139 </label>
140 </div>
bb0f894Claude141 </FormGroup>
142 <Button type="submit" variant="primary">
79136bbClaude143 Save changes
bb0f894Claude144 </Button>
145 </Form>
79136bbClaude146
147 <div
71cd5ecClaude148 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
14c3cc8Claude149 >
150 <h3 style="margin-bottom: 8px">Spec to PR (experimental)</h3>
151 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
152 Paste a plain-English feature spec and let Claude draft a pull
153 request for you. PRs are opened as drafts — review every line
154 before merging.
155 </p>
156 <a
157 href={`/${ownerName}/${repoName}/spec`}
158 class="btn"
159 >
160 Open Spec to PR
161 </a>
162 </div>
163
164 <div
165 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
71cd5ecClaude166 >
167 <h3 style="margin-bottom: 8px">Template repository</h3>
168 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
169 {repo.isTemplate
170 ? "This repository is a template. Users can click \u201cUse this template\u201d to create a new repository with the same files."
171 : "Mark this repository as a template so others can seed new repositories from its files."}
172 </p>
173 <form
e7e240eClaude174 method="post"
71cd5ecClaude175 action={`/${ownerName}/${repoName}/settings/template`}
176 >
177 <input
178 type="hidden"
179 name="template"
180 value={repo.isTemplate ? "0" : "1"}
181 />
182 <button type="submit" class="btn">
183 {repo.isTemplate
184 ? "Unmark as template"
185 : "Mark as template"}
186 </button>
187 </form>
188 </div>
189
190 <div
191 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
192 >
193 <h3 style="margin-bottom: 8px">Transfer ownership</h3>
194 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
195 Transfer this repository to another user. The new owner can
196 accept or decline the transfer by attempting to view it.
197 </p>
198 <form
e7e240eClaude199 method="post"
71cd5ecClaude200 action={`/${ownerName}/${repoName}/settings/transfer`}
201 onsubmit="return confirm('Transfer this repository? The new owner will have full control.')"
202 >
203 <input
204 type="text"
205 name="new_owner"
206 placeholder="new-owner-username"
207 required
208 style="width:60%"
209 />{" "}
210 <button type="submit" class="btn">
211 Transfer
212 </button>
213 </form>
214 </div>
215
216 <div
217 style="margin-top: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
218 >
219 <h3 style="margin-bottom: 8px">
220 {repo.isArchived ? "Unarchive repository" : "Archive repository"}
221 </h3>
222 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
223 {repo.isArchived
224 ? "This repository is archived and read-only. Unarchive to allow pushes and issue/PR activity again."
225 : "Mark this repository as archived. It will become read-only — no pushes, no new issues or PRs. You can unarchive at any time."}
226 </p>
227 <form
e7e240eClaude228 method="post"
71cd5ecClaude229 action={`/${ownerName}/${repoName}/settings/archive`}
230 >
231 <input
232 type="hidden"
233 name="archive"
234 value={repo.isArchived ? "0" : "1"}
235 />
236 <button type="submit" class="btn">
237 {repo.isArchived ? "Unarchive" : "Archive"} this repository
238 </button>
239 </form>
240 </div>
241
242 <div
243 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
79136bbClaude244 >
245 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
bb0f894Claude246 <Text size={14} muted style="display:block;margin-bottom:12px">
79136bbClaude247 Permanently delete this repository and all its data.
bb0f894Claude248 </Text>
79136bbClaude249 <form
e7e240eClaude250 method="post"
79136bbClaude251 action={`/${ownerName}/${repoName}/settings/delete`}
252 onsubmit="return confirm('Are you sure? This cannot be undone.')"
253 >
bb0f894Claude254 <Button type="submit" variant="danger">
79136bbClaude255 Delete this repository
bb0f894Claude256 </Button>
79136bbClaude257 </form>
258 </div>
bb0f894Claude259 </Container>
79136bbClaude260 </Layout>
261 );
262});
263
264// Save settings
febd4f0Claude265repoSettings.post("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
79136bbClaude266 const { owner: ownerName, repo: repoName } = c.req.param();
267 const user = c.get("user")!;
268 const body = await c.req.parseBody();
269
270 const [owner] = await db
271 .select()
272 .from(users)
273 .where(eq(users.username, ownerName))
274 .limit(1);
275
276 if (!owner || owner.id !== user.id) {
277 return c.redirect(`/${ownerName}/${repoName}`);
278 }
279
280 await db
281 .update(repositories)
282 .set({
283 description: String(body.description || "").trim() || null,
284 defaultBranch: String(body.default_branch || "main"),
285 isPrivate: body.visibility === "private",
286 updatedAt: new Date(),
287 })
288 .where(
289 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
290 );
291
292 return c.redirect(
293 `/${ownerName}/${repoName}/settings?success=Settings+saved`
294 );
295});
296
71cd5ecClaude297// Toggle template flag
298repoSettings.post(
299 "/:owner/:repo/settings/template",
300 requireAuth,
febd4f0Claude301 requireRepoAccess("admin"),
71cd5ecClaude302 async (c) => {
303 const { owner: ownerName, repo: repoName } = c.req.param();
304 const user = c.get("user")!;
305 const body = await c.req.parseBody();
306 const [owner] = await db
307 .select()
308 .from(users)
309 .where(eq(users.username, ownerName))
310 .limit(1);
311 if (!owner || owner.id !== user.id) {
312 return c.redirect(`/${ownerName}/${repoName}`);
313 }
314 const target = String(body.template || "1") === "1";
315 await db
316 .update(repositories)
317 .set({ isTemplate: target, updatedAt: new Date() })
318 .where(
319 and(
320 eq(repositories.ownerId, owner.id),
321 eq(repositories.name, repoName)
322 )
323 );
324 return c.redirect(
325 `/${ownerName}/${repoName}/settings?success=${
326 target ? "Marked+as+template" : "Unmarked+as+template"
327 }`
328 );
329 }
330);
331
332// Transfer repository to a new owner (by username)
333repoSettings.post(
334 "/:owner/:repo/settings/transfer",
335 requireAuth,
febd4f0Claude336 requireRepoAccess("admin"),
71cd5ecClaude337 async (c) => {
338 const { owner: ownerName, repo: repoName } = c.req.param();
339 const user = c.get("user")!;
340 const body = await c.req.parseBody();
341 const newOwnerName = String(body.new_owner || "").trim();
342 if (!newOwnerName) {
343 return c.redirect(
344 `/${ownerName}/${repoName}/settings?error=New+owner+required`
345 );
346 }
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 [newOwner] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, newOwnerName))
359 .limit(1);
360 if (!newOwner) {
361 return c.redirect(
362 `/${ownerName}/${repoName}/settings?error=User+not+found`
363 );
364 }
365 if (newOwner.id === owner.id) {
366 return c.redirect(
367 `/${ownerName}/${repoName}/settings?error=Same+owner`
368 );
369 }
370 // Reject if new owner already has a repo by this name
371 const [conflict] = await db
372 .select()
373 .from(repositories)
374 .where(
375 and(
376 eq(repositories.ownerId, newOwner.id),
377 eq(repositories.name, repoName)
378 )
379 )
380 .limit(1);
381 if (conflict) {
382 return c.redirect(
383 `/${ownerName}/${repoName}/settings?error=Target+owner+already+has+a+repo+by+that+name`
384 );
385 }
386 const [repo] = await db
387 .select()
388 .from(repositories)
389 .where(
390 and(
391 eq(repositories.ownerId, owner.id),
392 eq(repositories.name, repoName)
393 )
394 )
395 .limit(1);
396 if (!repo) return c.notFound();
397 await db
398 .update(repositories)
399 .set({ ownerId: newOwner.id, orgId: null, updatedAt: new Date() })
400 .where(eq(repositories.id, repo.id));
401 await db.insert(repoTransfers).values({
402 repositoryId: repo.id,
403 fromOwnerId: owner.id,
404 fromOrgId: repo.orgId,
405 toOwnerId: newOwner.id,
406 toOrgId: null,
407 initiatedBy: user.id,
408 });
409 return c.redirect(`/${newOwnerName}/${repoName}`);
410 }
411);
412
413// Archive / unarchive repository
414repoSettings.post(
415 "/:owner/:repo/settings/archive",
416 requireAuth,
febd4f0Claude417 requireRepoAccess("admin"),
71cd5ecClaude418 async (c) => {
419 const { owner: ownerName, repo: repoName } = c.req.param();
420 const user = c.get("user")!;
421 const body = await c.req.parseBody();
422 const [owner] = await db
423 .select()
424 .from(users)
425 .where(eq(users.username, ownerName))
426 .limit(1);
427 if (!owner || owner.id !== user.id) {
428 return c.redirect(`/${ownerName}/${repoName}`);
429 }
430 const target = String(body.archive || "1") === "1";
431 await db
432 .update(repositories)
433 .set({ isArchived: target, updatedAt: new Date() })
434 .where(
435 and(
436 eq(repositories.ownerId, owner.id),
437 eq(repositories.name, repoName)
438 )
439 );
440 return c.redirect(
441 `/${ownerName}/${repoName}/settings?success=${
442 target ? "Repository+archived" : "Repository+unarchived"
443 }`
444 );
445 }
446);
447
79136bbClaude448// Delete repository
449repoSettings.post(
450 "/:owner/:repo/settings/delete",
451 requireAuth,
febd4f0Claude452 requireRepoAccess("admin"),
79136bbClaude453 async (c) => {
454 const { owner: ownerName, repo: repoName } = c.req.param();
455 const user = c.get("user")!;
456
457 const [owner] = await db
458 .select()
459 .from(users)
460 .where(eq(users.username, ownerName))
461 .limit(1);
462
463 if (!owner || owner.id !== user.id) {
464 return c.redirect(`/${ownerName}/${repoName}`);
465 }
466
467 const [repo] = await db
468 .select()
469 .from(repositories)
470 .where(
471 and(
472 eq(repositories.ownerId, owner.id),
473 eq(repositories.name, repoName)
474 )
475 )
476 .limit(1);
477
478 if (!repo) return c.redirect(`/${ownerName}`);
479
480 // Delete from disk
481 try {
482 await rm(repo.diskPath, { recursive: true, force: true });
483 } catch {
484 // Disk cleanup best-effort
485 }
486
487 // Delete from DB (cascades to stars, issues, etc.)
488 await db.delete(repositories).where(eq(repositories.id, repo.id));
489
490 return c.redirect(`/${ownerName}`);
491 }
492);
493
494export default repoSettings;