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

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

releases.tsxBlame675 lines · 1 contributor
3ef4c9dClaude1/**
2 * Releases — tagged snapshots with AI-generated changelogs.
3 *
4 * GET /:owner/:repo/releases — list
5 * GET /:owner/:repo/releases/new — create form (tag + target + AI notes)
6 * POST /:owner/:repo/releases — create release + git tag + changelog
7 * GET /:owner/:repo/releases/:tag — view single release
8 * POST /:owner/:repo/releases/:tag/delete — owner-only delete (also removes git tag)
9 *
10 * Publishing a release fans out `release_published` notifications to starrers.
11 */
12
13import { Hono } from "hono";
14import { eq, and, desc } from "drizzle-orm";
15import { db } from "../db";
16import {
17 releases,
18 repositories,
19 users,
20 stars,
21 repoSettings,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import {
28 listBranches,
29 listTags,
30 createTag,
31 deleteTag,
32 resolveRef,
33 commitsBetween,
34 getDefaultBranch,
35} from "../git/repository";
36import { generateChangelog } from "../lib/ai-generators";
a53ceabClaude37import { renderNotesMarkdown } from "../lib/release-notes";
3ef4c9dClaude38import { notifyMany, audit } from "../lib/notify";
39import { renderMarkdown } from "../lib/markdown";
40import { getUnreadCount } from "../lib/unread";
41
42const releasesRoute = new Hono<AuthEnv>();
43releasesRoute.use("*", softAuth);
44
45async function loadRepo(owner: string, repo: string) {
46 const [row] = await db
47 .select({
48 id: repositories.id,
49 name: repositories.name,
50 defaultBranch: repositories.defaultBranch,
51 ownerId: repositories.ownerId,
52 starCount: repositories.starCount,
53 forkCount: repositories.forkCount,
54 forkedFromId: repositories.forkedFromId,
55 })
56 .from(repositories)
57 .innerJoin(users, eq(repositories.ownerId, users.id))
58 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
59 .limit(1);
60 return row;
61}
62
63releasesRoute.get("/:owner/:repo/releases", async (c) => {
64 const user = c.get("user");
65 const { owner, repo } = c.req.param();
66 const repoRow = await loadRepo(owner, repo);
67 if (!repoRow) return c.notFound();
68
69 const rows = await db
70 .select({
71 id: releases.id,
72 tag: releases.tag,
73 name: releases.name,
74 body: releases.body,
75 targetCommit: releases.targetCommit,
76 isDraft: releases.isDraft,
77 isPrerelease: releases.isPrerelease,
78 createdAt: releases.createdAt,
79 publishedAt: releases.publishedAt,
80 authorName: users.username,
81 })
82 .from(releases)
83 .innerJoin(users, eq(releases.authorId, users.id))
84 .where(eq(releases.repositoryId, repoRow.id))
85 .orderBy(desc(releases.createdAt));
86
87 const unread = user ? await getUnreadCount(user.id) : 0;
88
89 return c.html(
90 <Layout
91 title={`Releases — ${owner}/${repo}`}
92 user={user}
93 notificationCount={unread}
94 >
95 <RepoHeader
96 owner={owner}
97 repo={repo}
98 starCount={repoRow.starCount}
99 forkCount={repoRow.forkCount}
100 currentUser={user?.username || null}
101 />
102 <RepoNav owner={owner} repo={repo} active="releases" />
103 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
104 <h3>Releases</h3>
105 {user && user.id === repoRow.ownerId && (
106 <a href={`/${owner}/${repo}/releases/new`} class="btn btn-primary">
107 + Draft release
108 </a>
109 )}
110 </div>
111
112 {rows.length === 0 ? (
113 <div class="empty-state">
114 <h2>No releases yet</h2>
115 <p>Tag a commit and share a changelog with your users.</p>
116 </div>
117 ) : (
118 <div>
119 {rows.map((r, i) => (
120 <div class="release-card">
121 <div class="release-header">
122 <div>
123 <span class="release-name">
124 <a href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}>
125 {r.name}
126 </a>
127 </span>
128 {i === 0 && !r.isDraft && !r.isPrerelease && (
129 <span class="release-tag release-latest" style="margin-left: 8px">
130 Latest
131 </span>
132 )}
133 {r.isDraft && (
134 <span class="badge" style="margin-left: 8px; color: var(--yellow); border-color: var(--yellow)">
135 Draft
136 </span>
137 )}
138 {r.isPrerelease && (
139 <span class="badge" style="margin-left: 8px; color: var(--accent); border-color: var(--accent)">
140 Pre-release
141 </span>
142 )}
143 </div>
144 <span class="release-tag">{r.tag}</span>
145 </div>
146 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px">
147 {r.authorName} released{" "}
148 {new Date(r.publishedAt || r.createdAt).toLocaleDateString()}
149 {" · "}
150 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
151 {r.targetCommit.slice(0, 7)}
152 </a>
153 </div>
154 {r.body && (
155 <div
156 class="markdown-body"
157 style="font-size: 13px; max-height: 200px; overflow: hidden; position: relative"
158 dangerouslySetInnerHTML={{
159 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
160 }}
161 ></div>
162 )}
163 {user && user.id === repoRow.ownerId && (
164 <form
165 method="POST"
166 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
167 style="margin-top: 12px"
168 onsubmit="return confirm('Delete this release?')"
169 >
170 <button type="submit" class="btn btn-sm btn-danger">
171 Delete
172 </button>
173 </form>
174 )}
175 </div>
176 ))}
177 </div>
178 )}
179 </Layout>
180 );
181});
182
a53ceabClaude183// Shared form renderer — used by GET /new and POST /generate-notes.
184async function renderNewReleaseForm(
185 c: any,
186 user: any,
187 owner: string,
188 repo: string,
189 repoRow: any,
190 prefill: {
191 tag?: string;
192 target?: string;
193 name?: string;
194 previousTag?: string;
195 body?: string;
196 isPrerelease?: boolean;
197 isDraft?: boolean;
198 notice?: string | null;
199 } = {}
200) {
3ef4c9dClaude201 const branches = await listBranches(owner, repo);
202 const tags = await listTags(owner, repo);
203 const unread = await getUnreadCount(user.id);
204 const error = c.req.query("error");
205
206 return c.html(
207 <Layout
208 title={`Draft release — ${owner}/${repo}`}
209 user={user}
210 notificationCount={unread}
211 >
212 <RepoHeader
213 owner={owner}
214 repo={repo}
215 starCount={repoRow.starCount}
216 forkCount={repoRow.forkCount}
217 currentUser={user.username}
218 />
219 <RepoNav owner={owner} repo={repo} active="releases" />
220 <h3>Draft a new release</h3>
221 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
a53ceabClaude222 {prefill.notice && (
223 <div
224 style="padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; font-size: 13px"
225 >
226 {prefill.notice}
227 </div>
228 )}
3ef4c9dClaude229 <form
230 method="POST"
231 action={`/${owner}/${repo}/releases`}
232 style="max-width: 700px"
233 >
234 <div class="form-group">
235 <label>Tag</label>
236 <input
237 type="text"
238 name="tag"
239 required
240 placeholder="v1.0.0"
241 pattern="[A-Za-z0-9._\\-]+"
a53ceabClaude242 value={prefill.tag || ""}
3ef4c9dClaude243 />
244 </div>
245 <div class="form-group">
246 <label>Target branch / commit</label>
247 <select name="target">
248 {branches.map((b) => (
a53ceabClaude249 <option
250 value={b}
251 selected={
252 prefill.target
253 ? b === prefill.target
254 : b === repoRow.defaultBranch
255 }
256 >
3ef4c9dClaude257 {b}
258 </option>
259 ))}
260 </select>
261 </div>
262 <div class="form-group">
263 <label>Release name</label>
a53ceabClaude264 <input
265 type="text"
266 name="name"
267 required
268 placeholder="v1.0.0 — the big one"
269 value={prefill.name || ""}
270 />
3ef4c9dClaude271 </div>
272 <div class="form-group">
a53ceabClaude273 <label>Previous tag (for changelog)</label>
3ef4c9dClaude274 <select name="previousTag">
a53ceabClaude275 <option value="" selected={!prefill.previousTag}>
276 (auto — last tag)
277 </option>
3ef4c9dClaude278 {tags.map((t) => (
a53ceabClaude279 <option value={t.name} selected={t.name === prefill.previousTag}>
280 {t.name}
281 </option>
3ef4c9dClaude282 ))}
283 </select>
284 </div>
285 <div class="form-group">
286 <label>Notes (leave blank for AI-generated)</label>
a53ceabClaude287 <textarea
288 name="body"
289 rows={14}
290 placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits, or click 'Generate from commits' to prefill a deterministic conventional-commit grouping."
291 >
292 {prefill.body || ""}
293 </textarea>
3ef4c9dClaude294 </div>
a53ceabClaude295 <div style="display: flex; gap: 12px; align-items: center">
3ef4c9dClaude296 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
a53ceabClaude297 <input
298 type="checkbox"
299 name="isPrerelease"
300 value="1"
301 checked={!!prefill.isPrerelease}
302 />
3ef4c9dClaude303 Pre-release
304 </label>
305 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
a53ceabClaude306 <input
307 type="checkbox"
308 name="isDraft"
309 value="1"
310 checked={!!prefill.isDraft}
311 />
3ef4c9dClaude312 Save as draft
313 </label>
314 </div>
a53ceabClaude315 <div style="display: flex; gap: 8px; margin-top: 16px">
316 <button type="submit" class="btn btn-primary">
317 Publish release
318 </button>
319 <button
320 type="submit"
321 class="btn"
322 formaction={`/${owner}/${repo}/releases/generate-notes`}
323 formnovalidate
324 title="Classify commits by conventional-commit prefix and prefill notes"
325 >
326 Generate from commits
327 </button>
328 </div>
3ef4c9dClaude329 </form>
330 </Layout>
331 );
a53ceabClaude332}
333
334releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
335 const user = c.get("user")!;
336 const { owner, repo } = c.req.param();
337 const repoRow = await loadRepo(owner, repo);
338 if (!repoRow) return c.notFound();
339 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
340 return renderNewReleaseForm(c, user, owner, repo, repoRow);
3ef4c9dClaude341});
342
a53ceabClaude343// J15 — deterministic notes prefill from conventional commits.
344releasesRoute.post(
345 "/:owner/:repo/releases/generate-notes",
346 requireAuth,
347 async (c) => {
348 const user = c.get("user")!;
349 const { owner, repo } = c.req.param();
350 const repoRow = await loadRepo(owner, repo);
351 if (!repoRow) return c.notFound();
352 if (repoRow.ownerId !== user.id) {
353 return c.redirect(`/${owner}/${repo}/releases`);
354 }
355
356 const body = await c.req.parseBody();
357 const tag = String(body.tag || "").trim();
358 const name = String(body.name || "").trim();
359 const target = String(body.target || repoRow.defaultBranch).trim();
360 const previousTag = String(body.previousTag || "").trim();
361 const existingBody = String(body.body || "");
362 const isDraft = !!body.isDraft;
363 const isPrerelease = !!body.isPrerelease;
364
365 let autoPrev = previousTag;
366 if (!autoPrev) {
367 try {
368 const tags = await listTags(owner, repo);
369 autoPrev = tags[0]?.name || "";
370 } catch {
371 autoPrev = "";
372 }
373 }
374
375 let notice = "Prefilled notes from conventional-commit grouping.";
376 let generated = "";
377 try {
378 const sha = await resolveRef(owner, repo, target);
379 if (!sha) {
380 notice = `Could not resolve target "${target}". Notes left unchanged.`;
381 return renderNewReleaseForm(c, user, owner, repo, repoRow, {
382 tag,
383 target,
384 name,
385 previousTag: autoPrev,
386 body: existingBody,
387 isDraft,
388 isPrerelease,
389 notice,
390 });
391 }
392 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
393 generated = renderNotesMarkdown(
394 commits.map((cmt) => ({
395 sha: cmt.sha,
396 message: cmt.message,
397 author: cmt.author,
398 })),
399 {
400 ownerRepo: `${owner}/${repo}`,
401 previousTag: autoPrev || null,
402 newTag: tag || "HEAD",
403 }
404 );
405 if (commits.length === 0) {
406 notice = `No commits between ${autoPrev || "<root>"} and ${target}.`;
407 }
408 } catch (err) {
409 console.error("[releases] generate-notes failed:", err);
410 notice = "Failed to read commits — please try again.";
411 }
412
413 // Preserve whatever the owner had typed; append generated draft below.
414 const combined =
415 existingBody.trim().length === 0
416 ? generated
417 : `${existingBody.trimEnd()}\n\n${generated}`;
418
419 return renderNewReleaseForm(c, user, owner, repo, repoRow, {
420 tag,
421 target,
422 name,
423 previousTag: autoPrev,
424 body: combined,
425 isDraft,
426 isPrerelease,
427 notice,
428 });
429 }
430);
431
3ef4c9dClaude432releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
433 const user = c.get("user")!;
434 const { owner, repo } = c.req.param();
435 const repoRow = await loadRepo(owner, repo);
436 if (!repoRow) return c.notFound();
437 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
438
439 const body = await c.req.parseBody();
440 const tag = String(body.tag || "").trim();
441 const name = String(body.name || "").trim() || tag;
442 const target = String(body.target || repoRow.defaultBranch).trim();
443 const previousTag = String(body.previousTag || "").trim();
444 const notes = String(body.body || "").trim();
445 const isDraft = !!body.isDraft;
446 const isPrerelease = !!body.isPrerelease;
447
448 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
449 return c.redirect(
450 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
451 );
452 }
453
454 const sha = await resolveRef(owner, repo, target);
455 if (!sha) {
456 return c.redirect(
457 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
458 );
459 }
460
461 // Determine previous tag for changelog
462 let autoPrev = previousTag;
463 if (!autoPrev) {
464 const tags = await listTags(owner, repo);
465 autoPrev = tags[0]?.name || "";
466 }
467
468 // Generate changelog body if none provided
469 let finalBody = notes;
470 const [settings] = await db
471 .select()
472 .from(repoSettings)
473 .where(eq(repoSettings.repositoryId, repoRow.id))
474 .limit(1);
475 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
476
477 if (!finalBody && aiEnabled) {
478 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
479 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
a53ceabClaude480 } else if (!finalBody) {
481 // J15 — AI disabled: deterministic grouping from conventional commits.
482 try {
483 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
484 finalBody = renderNotesMarkdown(
485 commits.map((cmt) => ({
486 sha: cmt.sha,
487 message: cmt.message,
488 author: cmt.author,
489 })),
490 {
491 ownerRepo: `${owner}/${repo}`,
492 previousTag: autoPrev || null,
493 newTag: tag,
494 }
495 );
496 } catch {
497 finalBody = "";
498 }
3ef4c9dClaude499 }
500
501 // Create the git tag (best-effort — if it already exists we reuse)
502 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
503 if (!existing) {
504 await createTag(owner, repo, tag, sha, name || tag);
505 }
506
507 // Persist release
508 let releaseId = "";
509 try {
510 const [row] = await db
511 .insert(releases)
512 .values({
513 repositoryId: repoRow.id,
514 authorId: user.id,
515 tag,
516 name,
517 body: finalBody,
518 targetCommit: sha,
519 isDraft,
520 isPrerelease,
521 publishedAt: isDraft ? null : new Date(),
522 })
523 .returning();
524 releaseId = row?.id || "";
525 } catch (err) {
526 console.error("[releases] insert failed:", err);
527 return c.redirect(
528 `/${owner}/${repo}/releases/new?error=Tag+already+published`
529 );
530 }
531
532 // Notify starrers (only on publish)
533 if (!isDraft) {
534 try {
535 const starUsers = await db
536 .select({ userId: stars.userId })
537 .from(stars)
538 .where(eq(stars.repositoryId, repoRow.id));
539 await notifyMany(
540 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
541 {
542 kind: "release_published",
543 title: `${owner}/${repo} ${tag} released`,
544 body: name,
545 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
546 repositoryId: repoRow.id,
547 }
548 );
549 } catch {
550 /* ignore */
551 }
552 }
553
554 await audit({
555 userId: user.id,
556 repositoryId: repoRow.id,
557 action: "release.publish",
558 targetType: "release",
559 targetId: releaseId,
560 metadata: { tag, target, isDraft, isPrerelease },
561 });
562
563 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
564});
565
566releasesRoute.get("/:owner/:repo/releases/:tag", async (c) => {
567 const user = c.get("user");
568 const { owner, repo } = c.req.param();
569 const tag = decodeURIComponent(c.req.param("tag"));
570 const repoRow = await loadRepo(owner, repo);
571 if (!repoRow) return c.notFound();
572
573 const [release] = await db
574 .select({
575 id: releases.id,
576 tag: releases.tag,
577 name: releases.name,
578 body: releases.body,
579 targetCommit: releases.targetCommit,
580 isDraft: releases.isDraft,
581 isPrerelease: releases.isPrerelease,
582 createdAt: releases.createdAt,
583 publishedAt: releases.publishedAt,
584 authorName: users.username,
585 })
586 .from(releases)
587 .innerJoin(users, eq(releases.authorId, users.id))
588 .where(
589 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
590 )
591 .limit(1);
592 if (!release) return c.notFound();
593
594 const unread = user ? await getUnreadCount(user.id) : 0;
595
596 return c.html(
597 <Layout
598 title={`${release.name} — ${owner}/${repo}`}
599 user={user}
600 notificationCount={unread}
601 >
602 <RepoHeader
603 owner={owner}
604 repo={repo}
605 starCount={repoRow.starCount}
606 forkCount={repoRow.forkCount}
607 currentUser={user?.username || null}
608 />
609 <RepoNav owner={owner} repo={repo} active="releases" />
610 <div style="margin-bottom: 8px">
611 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
612 </div>
613 <div class="release-card">
614 <div class="release-header">
615 <span class="release-name">{release.name}</span>
616 <span class="release-tag">{release.tag}</span>
617 </div>
618 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
619 {release.authorName} released{" "}
620 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
621 {" · "}
622 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
623 {release.targetCommit.slice(0, 7)}
624 </a>
625 {" · "}
626 <a
627 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
628 >
629 Download
630 </a>
631 </div>
632 {release.body && (
633 <div
634 class="markdown-body"
635 dangerouslySetInnerHTML={{
636 __html: renderMarkdown(release.body),
637 }}
638 ></div>
639 )}
640 </div>
641 </Layout>
642 );
643});
644
645releasesRoute.post(
646 "/:owner/:repo/releases/:tag/delete",
647 requireAuth,
648 async (c) => {
649 const user = c.get("user")!;
650 const { owner, repo } = c.req.param();
651 const tag = decodeURIComponent(c.req.param("tag"));
652 const repoRow = await loadRepo(owner, repo);
653 if (!repoRow) return c.notFound();
654 if (repoRow.ownerId !== user.id) {
655 return c.redirect(`/${owner}/${repo}/releases`);
656 }
657
658 await db
659 .delete(releases)
660 .where(
661 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
662 );
663 await deleteTag(owner, repo, tag);
664 await audit({
665 userId: user.id,
666 repositoryId: repoRow.id,
667 action: "release.delete",
668 targetType: "release",
669 metadata: { tag },
670 });
671 return c.redirect(`/${owner}/${repo}/releases`);
672 }
673);
674
675export default releasesRoute;