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

projects.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.

projects.tsxBlame605 lines · 2 contributors
1e162a8Claude1/**
2 * Block E1 — Projects / Kanban boards scoped to a repo.
3 *
4 * Each project has ordered columns ("To Do" / "In Progress" / "Done" by
5 * default) and items (notes or linked issues/PRs). Items belong to exactly
6 * one column at a time. Simple v1: positions are recomputed via "max+1".
7 *
8 * Never throws — all DB paths wrapped in try/catch.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc, asc, sql } from "drizzle-orm";
13import { db } from "../db";
14import {
15 projects,
16 projectColumns,
17 projectItems,
18 repositories,
19 users,
20} from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26const DEFAULT_COLUMNS = ["To Do", "In Progress", "Done"] as const;
27
28const projectRoutes = new Hono<AuthEnv>();
29
30async function resolveRepo(ownerName: string, repoName: string) {
31 try {
32 const [owner] = await db
33 .select()
34 .from(users)
35 .where(eq(users.username, ownerName))
36 .limit(1);
37 if (!owner) return null;
38 const [repo] = await db
39 .select()
40 .from(repositories)
41 .where(
42 and(
43 eq(repositories.ownerId, owner.id),
44 eq(repositories.name, repoName)
45 )
46 )
47 .limit(1);
48 if (!repo) return null;
49 return { owner, repo };
50 } catch {
51 return null;
52 }
53}
54
55function notFound(user: any, label = "Not found") {
56 return (
57 <Layout title={label} user={user}>
58 <div class="empty-state">
59 <h2>{label}</h2>
60 </div>
61 </Layout>
62 );
63}
64
65// List
66projectRoutes.get("/:owner/:repo/projects", softAuth, async (c) => {
67 const { owner: ownerName, repo: repoName } = c.req.param();
68 const user = c.get("user");
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
71 const { repo } = resolved;
72
73 let rows: any[] = [];
74 try {
75 rows = await db
76 .select({
77 p: projects,
78 columnCount: sql<number>`(SELECT count(*) FROM project_columns WHERE project_id = ${projects.id})`,
79 itemCount: sql<number>`(SELECT count(*) FROM project_items WHERE project_id = ${projects.id})`,
80 })
81 .from(projects)
82 .where(eq(projects.repositoryId, repo.id))
83 .orderBy(desc(projects.updatedAt));
84 } catch {
85 rows = [];
86 }
87
88 return c.html(
89 <Layout title={`Projects — ${ownerName}/${repoName}`} user={user}>
90 <RepoHeader owner={ownerName} repo={repoName} />
91 <div style="display: flex; justify-content: space-between; align-items: center; margin: 16px 0;">
92 <h2 style="margin: 0;">Projects</h2>
93 {user && (
94 <a
95 href={`/${ownerName}/${repoName}/projects/new`}
96 class="btn btn-primary"
97 >
98 New project
99 </a>
100 )}
101 </div>
102 {rows.length === 0 ? (
103 <div class="empty-state">
104 <p>No projects yet.</p>
105 </div>
106 ) : (
107 <table class="file-table">
108 <tbody>
109 {rows.map((r) => (
110 <tr>
111 <td style="width: 40px; color: var(--text-muted);">
112 #{r.p.number}
113 </td>
114 <td>
115 <a
116 href={`/${ownerName}/${repoName}/projects/${r.p.number}`}
117 >
118 <strong>{r.p.title}</strong>
119 </a>
120 {r.p.state === "closed" && <span class="badge">closed</span>}
121 {r.p.description && (
122 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
123 {r.p.description}
124 </div>
125 )}
126 </td>
127 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
128 {r.columnCount} cols · {r.itemCount} items
129 </td>
130 </tr>
131 ))}
132 </tbody>
133 </table>
134 )}
135 </Layout>
136 );
137});
138
139// New form
140projectRoutes.get(
141 "/:owner/:repo/projects/new",
142 requireAuth,
143 async (c) => {
144 const { owner: ownerName, repo: repoName } = c.req.param();
145 const user = c.get("user");
146 const resolved = await resolveRepo(ownerName, repoName);
147 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
148 return c.html(
149 <Layout title="New project" user={user}>
150 <RepoHeader owner={ownerName} repo={repoName} />
151 <h2 style="margin-top: 20px;">Create a project</h2>
152 <form
e7e240eClaude153 method="post"
1e162a8Claude154 action={`/${ownerName}/${repoName}/projects`}
155 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
156 >
157 <input
158 type="text"
159 name="title"
160 placeholder="Title"
161 required
63c60ebcopilot-swe-agent[bot]162 aria-label="Project title"
1e162a8Claude163 style="padding: 8px;"
164 />
165 <textarea
166 name="description"
167 rows={4}
168 placeholder="Description (optional)"
169 style="padding: 8px; font-family: inherit;"
170 ></textarea>
171 <button type="submit" class="btn btn-primary">
172 Create
173 </button>
174 </form>
175 </Layout>
176 );
177 }
178);
179
180// Create
181projectRoutes.post(
182 "/:owner/:repo/projects",
183 requireAuth,
184 async (c) => {
185 const { owner: ownerName, repo: repoName } = c.req.param();
186 const user = c.get("user")!;
187 const resolved = await resolveRepo(ownerName, repoName);
188 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
189
190 const form = await c.req.formData();
191 const title = (form.get("title") as string || "").trim();
192 const description = (form.get("description") as string || "").trim();
193
194 if (!title) {
195 return c.redirect(`/${ownerName}/${repoName}/projects/new`);
196 }
197
198 try {
199 const [row] = await db
200 .insert(projects)
201 .values({
202 repositoryId: resolved.repo.id,
203 ownerId: user.id,
204 title,
205 description,
206 })
207 .returning({ id: projects.id, number: projects.number });
208 // Seed default columns
209 await db.insert(projectColumns).values(
210 DEFAULT_COLUMNS.map((name, i) => ({
211 projectId: row.id,
212 name,
213 position: i,
214 }))
215 );
216 return c.redirect(`/${ownerName}/${repoName}/projects/${row.number}`);
217 } catch {
218 return c.redirect(`/${ownerName}/${repoName}/projects`);
219 }
220 }
221);
222
223// Board view
224projectRoutes.get(
225 "/:owner/:repo/projects/:number",
226 softAuth,
227 async (c) => {
228 const { owner: ownerName, repo: repoName } = c.req.param();
229 const user = c.get("user");
230 const numParam = Number(c.req.param("number"));
231 const resolved = await resolveRepo(ownerName, repoName);
232 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
233
234 let project: any = null;
235 let columns: any[] = [];
236 let items: any[] = [];
237 try {
238 const [row] = await db
239 .select()
240 .from(projects)
241 .where(
242 and(
243 eq(projects.repositoryId, resolved.repo.id),
244 eq(projects.number, numParam)
245 )
246 )
247 .limit(1);
248 if (row) {
249 project = row;
250 columns = await db
251 .select()
252 .from(projectColumns)
253 .where(eq(projectColumns.projectId, row.id))
254 .orderBy(asc(projectColumns.position), asc(projectColumns.createdAt));
255 items = await db
256 .select()
257 .from(projectItems)
258 .where(eq(projectItems.projectId, row.id))
259 .orderBy(asc(projectItems.position));
260 }
261 } catch {
262 // leave nulls
263 }
264
265 if (!project) return c.html(notFound(user, "Project not found"), 404);
266
267 const isOwner = user && user.id === resolved.repo.ownerId;
268 const itemsByCol: Record<string, any[]> = {};
269 for (const col of columns) itemsByCol[col.id] = [];
270 for (const it of items) {
271 if (itemsByCol[it.columnId]) itemsByCol[it.columnId].push(it);
272 }
273
274 return c.html(
275 <Layout
276 title={`${project.title} — project #${project.number}`}
277 user={user}
278 >
279 <RepoHeader owner={ownerName} repo={repoName} />
280 <style>{`
281 .kanban { display: flex; gap: 16px; overflow-x: auto; padding: 16px 0; }
282 .kcol { background: var(--bg-soft); border: 1px solid var(--border); border-radius: 6px; min-width: 260px; flex-shrink: 0; padding: 12px; }
283 .kcol h4 { margin: 0 0 12px; display: flex; justify-content: space-between; }
284 .kcard { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; margin-bottom: 8px; font-size: 13px; }
285 .kcard form { display: inline; }
286 `}</style>
287 <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;">
288 <h1 style="margin: 0;">
289 {project.title}{" "}
290 <span style="color: var(--text-muted);">#{project.number}</span>
291 {project.state === "closed" && <span class="badge">closed</span>}
292 </h1>
293 {user && (
294 <form
e7e240eClaude295 method="post"
1e162a8Claude296 action={`/${ownerName}/${repoName}/projects/${project.number}/close`}
297 style="display: inline;"
298 >
299 <button type="submit" class="btn">
300 {project.state === "open" ? "Close" : "Reopen"}
301 </button>
302 </form>
303 )}
304 </div>
305 {project.description && (
306 <div style="color: var(--text-muted); margin-top: 4px;">
307 {project.description}
308 </div>
309 )}
310 <div class="kanban">
311 {columns.map((col) => (
312 <div class="kcol">
63c60ebcopilot-swe-agent[bot]313 <h2>
1e162a8Claude314 <span>{col.name}</span>
315 <span style="color: var(--text-muted); font-size: 13px;">
316 {(itemsByCol[col.id] || []).length}
317 </span>
63c60ebcopilot-swe-agent[bot]318 </h2>
1e162a8Claude319 {(itemsByCol[col.id] || []).map((it) => (
320 <div class="kcard">
321 <div>
322 <strong>{it.title || "(untitled)"}</strong>
323 </div>
324 {it.note && (
325 <div style="color: var(--text-muted); margin-top: 4px;">
326 {it.note}
327 </div>
328 )}
329 {user && (
330 <div style="margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap;">
331 {columns
332 .filter((oc) => oc.id !== col.id)
333 .map((oc) => (
334 <form
e7e240eClaude335 method="post"
1e162a8Claude336 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/move`}
337 >
338 <input
339 type="hidden"
340 name="column_id"
341 value={oc.id}
342 />
343 <button
344 type="submit"
345 class="btn"
346 style="font-size: 11px; padding: 2px 6px;"
347 >
348 → {oc.name}
349 </button>
350 </form>
351 ))}
352 <form
e7e240eClaude353 method="post"
1e162a8Claude354 action={`/${ownerName}/${repoName}/projects/${project.number}/items/${it.id}/delete`}
355 >
356 <button
357 type="submit"
358 class="btn"
63c60ebcopilot-swe-agent[bot]359 aria-label="Delete item"
1e162a8Claude360 style="font-size: 11px; padding: 2px 6px;"
361 >
362 ×
363 </button>
364 </form>
365 </div>
366 )}
367 </div>
368 ))}
369 {user && (
370 <form
e7e240eClaude371 method="post"
1e162a8Claude372 action={`/${ownerName}/${repoName}/projects/${project.number}/items`}
373 style="margin-top: 8px; display: flex; flex-direction: column; gap: 4px;"
374 >
375 <input type="hidden" name="column_id" value={col.id} />
376 <input
377 type="text"
378 name="title"
379 placeholder="New card title"
380 required
63c60ebcopilot-swe-agent[bot]381 aria-label="New card title"
1e162a8Claude382 style="padding: 4px; font-size: 12px;"
383 />
384 <button
385 type="submit"
386 class="btn"
387 style="font-size: 12px; padding: 4px;"
388 >
389 + Add card
390 </button>
391 </form>
392 )}
393 </div>
394 ))}
395 {user && (
396 <div class="kcol" style="background: transparent; border-style: dashed;">
397 <form
e7e240eClaude398 method="post"
1e162a8Claude399 action={`/${ownerName}/${repoName}/projects/${project.number}/columns`}
400 style="display: flex; flex-direction: column; gap: 8px;"
401 >
402 <input
403 type="text"
404 name="name"
405 placeholder="New column"
406 required
63c60ebcopilot-swe-agent[bot]407 aria-label="New column name"
1e162a8Claude408 style="padding: 6px;"
409 />
410 <button type="submit" class="btn">
411 + Add column
412 </button>
413 </form>
414 </div>
415 )}
416 </div>
417 </Layout>
418 );
419 }
420);
421
422// Add column
423projectRoutes.post(
424 "/:owner/:repo/projects/:number/columns",
425 requireAuth,
426 async (c) => {
427 const { owner: ownerName, repo: repoName } = c.req.param();
428 const numParam = Number(c.req.param("number"));
429 const resolved = await resolveRepo(ownerName, repoName);
430 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
431
432 const form = await c.req.formData();
433 const name = (form.get("name") as string || "").trim();
434 if (!name) {
435 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
436 }
437
438 try {
439 const [row] = await db
440 .select()
441 .from(projects)
442 .where(
443 and(
444 eq(projects.repositoryId, resolved.repo.id),
445 eq(projects.number, numParam)
446 )
447 )
448 .limit(1);
449 if (row) {
450 const [maxPos] = await db
451 .select({ p: sql<number>`coalesce(max(${projectColumns.position}), -1)` })
452 .from(projectColumns)
453 .where(eq(projectColumns.projectId, row.id));
454 await db.insert(projectColumns).values({
455 projectId: row.id,
456 name,
457 position: Number(maxPos?.p || -1) + 1,
458 });
459 }
460 } catch {
461 // swallow
462 }
463 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
464 }
465);
466
467// Add item
468projectRoutes.post(
469 "/:owner/:repo/projects/:number/items",
470 requireAuth,
471 async (c) => {
472 const { owner: ownerName, repo: repoName } = c.req.param();
473 const numParam = Number(c.req.param("number"));
474 const resolved = await resolveRepo(ownerName, repoName);
475 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
476
477 const form = await c.req.formData();
478 const columnId = (form.get("column_id") as string || "").trim();
479 const title = (form.get("title") as string || "").trim();
480 const note = (form.get("note") as string || "").trim();
481 if (!columnId || !title) {
482 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
483 }
484
485 try {
486 const [row] = await db
487 .select()
488 .from(projects)
489 .where(
490 and(
491 eq(projects.repositoryId, resolved.repo.id),
492 eq(projects.number, numParam)
493 )
494 )
495 .limit(1);
496 if (row) {
497 const [maxPos] = await db
498 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
499 .from(projectItems)
500 .where(eq(projectItems.columnId, columnId));
501 await db.insert(projectItems).values({
502 projectId: row.id,
503 columnId,
504 title,
505 note,
506 position: Number(maxPos?.p || -1) + 1,
507 });
508 }
509 } catch {
510 // swallow
511 }
512 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
513 }
514);
515
516// Move item
517projectRoutes.post(
518 "/:owner/:repo/projects/:number/items/:itemId/move",
519 requireAuth,
520 async (c) => {
521 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
522 const numParam = Number(c.req.param("number"));
523 const resolved = await resolveRepo(ownerName, repoName);
524 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
525
526 const form = await c.req.formData();
527 const columnId = (form.get("column_id") as string || "").trim();
528 if (!columnId) {
529 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
530 }
531
532 try {
533 const [maxPos] = await db
534 .select({ p: sql<number>`coalesce(max(${projectItems.position}), -1)` })
535 .from(projectItems)
536 .where(eq(projectItems.columnId, columnId));
537 await db
538 .update(projectItems)
539 .set({
540 columnId,
541 position: Number(maxPos?.p || -1) + 1,
542 updatedAt: new Date(),
543 })
544 .where(eq(projectItems.id, itemId));
545 } catch {
546 // swallow
547 }
548 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
549 }
550);
551
552// Delete item
553projectRoutes.post(
554 "/:owner/:repo/projects/:number/items/:itemId/delete",
555 requireAuth,
556 async (c) => {
557 const { owner: ownerName, repo: repoName, itemId } = c.req.param();
558 const numParam = Number(c.req.param("number"));
559 try {
560 await db.delete(projectItems).where(eq(projectItems.id, itemId));
561 } catch {
562 // swallow
563 }
564 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
565 }
566);
567
568// Toggle close
569projectRoutes.post(
570 "/:owner/:repo/projects/:number/close",
571 requireAuth,
572 async (c) => {
573 const { owner: ownerName, repo: repoName } = c.req.param();
574 const numParam = Number(c.req.param("number"));
575 const resolved = await resolveRepo(ownerName, repoName);
576 if (!resolved) return c.redirect(`/${ownerName}/${repoName}/projects`);
577
578 try {
579 const [row] = await db
580 .select()
581 .from(projects)
582 .where(
583 and(
584 eq(projects.repositoryId, resolved.repo.id),
585 eq(projects.number, numParam)
586 )
587 )
588 .limit(1);
589 if (row) {
590 await db
591 .update(projects)
592 .set({
593 state: row.state === "open" ? "closed" : "open",
594 updatedAt: new Date(),
595 })
596 .where(eq(projects.id, row.id));
597 }
598 } catch {
599 // swallow
600 }
601 return c.redirect(`/${ownerName}/${repoName}/projects/${numParam}`);
602 }
603);
604
605export default projectRoutes;