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

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.tsxBlame496 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";
febd4f0Claude27import { requireRepoAccess } from "../middleware/repo-access";
3ef4c9dClaude28import {
29 listBranches,
30 listTags,
31 createTag,
32 deleteTag,
33 resolveRef,
34 commitsBetween,
35 getDefaultBranch,
36} from "../git/repository";
37import { generateChangelog } from "../lib/ai-generators";
38import { 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
febd4f0Claude63releasesRoute.get("/:owner/:repo/releases", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude64 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
e7e240eClaude165 method="post"
3ef4c9dClaude166 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
febd4f0Claude183releasesRoute.get("/:owner/:repo/releases/new", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude184 const user = c.get("user")!;
185 const { owner, repo } = c.req.param();
186 const repoRow = await loadRepo(owner, repo);
187 if (!repoRow) return c.notFound();
188 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
189
190 const branches = await listBranches(owner, repo);
191 const tags = await listTags(owner, repo);
192 const unread = await getUnreadCount(user.id);
193 const error = c.req.query("error");
194
195 return c.html(
196 <Layout
197 title={`Draft release — ${owner}/${repo}`}
198 user={user}
199 notificationCount={unread}
200 >
201 <RepoHeader
202 owner={owner}
203 repo={repo}
204 starCount={repoRow.starCount}
205 forkCount={repoRow.forkCount}
206 currentUser={user.username}
207 />
208 <RepoNav owner={owner} repo={repo} active="releases" />
209 <h3>Draft a new release</h3>
210 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
211 <form
e7e240eClaude212 method="post"
3ef4c9dClaude213 action={`/${owner}/${repo}/releases`}
214 style="max-width: 700px"
215 >
216 <div class="form-group">
217 <label>Tag</label>
218 <input
219 type="text"
220 name="tag"
221 required
222 placeholder="v1.0.0"
223 pattern="[A-Za-z0-9._\\-]+"
224 />
225 </div>
226 <div class="form-group">
227 <label>Target branch / commit</label>
228 <select name="target">
229 {branches.map((b) => (
230 <option value={b} selected={b === repoRow.defaultBranch}>
231 {b}
232 </option>
233 ))}
234 </select>
235 </div>
236 <div class="form-group">
237 <label>Release name</label>
238 <input type="text" name="name" required placeholder="v1.0.0 — the big one" />
239 </div>
240 <div class="form-group">
241 <label>Previous tag (for AI changelog)</label>
242 <select name="previousTag">
243 <option value="">(auto — last tag)</option>
244 {tags.map((t) => (
245 <option value={t.name}>{t.name}</option>
246 ))}
247 </select>
248 </div>
249 <div class="form-group">
250 <label>Notes (leave blank for AI-generated)</label>
251 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
252 </div>
253 <div style="display: flex; gap: 12px">
254 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
255 <input type="checkbox" name="isPrerelease" value="1" />
256 Pre-release
257 </label>
258 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
259 <input type="checkbox" name="isDraft" value="1" />
260 Save as draft
261 </label>
262 </div>
263 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
264 Publish release
265 </button>
266 </form>
267 </Layout>
268 );
269});
270
febd4f0Claude271releasesRoute.post("/:owner/:repo/releases", requireAuth, requireRepoAccess("write"), async (c) => {
3ef4c9dClaude272 const user = c.get("user")!;
273 const { owner, repo } = c.req.param();
274 const repoRow = await loadRepo(owner, repo);
275 if (!repoRow) return c.notFound();
276 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
277
278 const body = await c.req.parseBody();
279 const tag = String(body.tag || "").trim();
280 const name = String(body.name || "").trim() || tag;
281 const target = String(body.target || repoRow.defaultBranch).trim();
282 const previousTag = String(body.previousTag || "").trim();
283 const notes = String(body.body || "").trim();
284 const isDraft = !!body.isDraft;
285 const isPrerelease = !!body.isPrerelease;
286
287 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
288 return c.redirect(
289 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
290 );
291 }
292
293 const sha = await resolveRef(owner, repo, target);
294 if (!sha) {
295 return c.redirect(
296 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
297 );
298 }
299
300 // Determine previous tag for changelog
301 let autoPrev = previousTag;
302 if (!autoPrev) {
303 const tags = await listTags(owner, repo);
304 autoPrev = tags[0]?.name || "";
305 }
306
307 // Generate changelog body if none provided
308 let finalBody = notes;
309 const [settings] = await db
310 .select()
311 .from(repoSettings)
312 .where(eq(repoSettings.repositoryId, repoRow.id))
313 .limit(1);
314 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
315
316 if (!finalBody && aiEnabled) {
317 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
318 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
319 }
320
321 // Create the git tag (best-effort — if it already exists we reuse)
322 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
323 if (!existing) {
324 await createTag(owner, repo, tag, sha, name || tag);
325 }
326
327 // Persist release
328 let releaseId = "";
329 try {
330 const [row] = await db
331 .insert(releases)
332 .values({
333 repositoryId: repoRow.id,
334 authorId: user.id,
335 tag,
336 name,
337 body: finalBody,
338 targetCommit: sha,
339 isDraft,
340 isPrerelease,
341 publishedAt: isDraft ? null : new Date(),
342 })
343 .returning();
344 releaseId = row?.id || "";
345 } catch (err) {
346 console.error("[releases] insert failed:", err);
347 return c.redirect(
348 `/${owner}/${repo}/releases/new?error=Tag+already+published`
349 );
350 }
351
352 // Notify starrers (only on publish)
353 if (!isDraft) {
354 try {
355 const starUsers = await db
356 .select({ userId: stars.userId })
357 .from(stars)
358 .where(eq(stars.repositoryId, repoRow.id));
359 await notifyMany(
360 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
361 {
362 kind: "release_published",
363 title: `${owner}/${repo} ${tag} released`,
364 body: name,
365 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
366 repositoryId: repoRow.id,
367 }
368 );
369 } catch {
370 /* ignore */
371 }
372 }
373
374 await audit({
375 userId: user.id,
376 repositoryId: repoRow.id,
377 action: "release.publish",
378 targetType: "release",
379 targetId: releaseId,
380 metadata: { tag, target, isDraft, isPrerelease },
381 });
382
383 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
384});
385
febd4f0Claude386releasesRoute.get("/:owner/:repo/releases/:tag", requireRepoAccess("read"), async (c) => {
3ef4c9dClaude387 const user = c.get("user");
388 const { owner, repo } = c.req.param();
389 const tag = decodeURIComponent(c.req.param("tag"));
390 const repoRow = await loadRepo(owner, repo);
391 if (!repoRow) return c.notFound();
392
393 const [release] = await db
394 .select({
395 id: releases.id,
396 tag: releases.tag,
397 name: releases.name,
398 body: releases.body,
399 targetCommit: releases.targetCommit,
400 isDraft: releases.isDraft,
401 isPrerelease: releases.isPrerelease,
402 createdAt: releases.createdAt,
403 publishedAt: releases.publishedAt,
404 authorName: users.username,
405 })
406 .from(releases)
407 .innerJoin(users, eq(releases.authorId, users.id))
408 .where(
409 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
410 )
411 .limit(1);
412 if (!release) return c.notFound();
413
414 const unread = user ? await getUnreadCount(user.id) : 0;
415
416 return c.html(
417 <Layout
418 title={`${release.name} — ${owner}/${repo}`}
419 user={user}
420 notificationCount={unread}
421 >
422 <RepoHeader
423 owner={owner}
424 repo={repo}
425 starCount={repoRow.starCount}
426 forkCount={repoRow.forkCount}
427 currentUser={user?.username || null}
428 />
429 <RepoNav owner={owner} repo={repo} active="releases" />
430 <div style="margin-bottom: 8px">
431 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
432 </div>
433 <div class="release-card">
434 <div class="release-header">
435 <span class="release-name">{release.name}</span>
436 <span class="release-tag">{release.tag}</span>
437 </div>
438 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
439 {release.authorName} released{" "}
440 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
441 {" · "}
442 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
443 {release.targetCommit.slice(0, 7)}
444 </a>
445 {" · "}
446 <a
447 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
448 >
449 Download
450 </a>
451 </div>
452 {release.body && (
453 <div
454 class="markdown-body"
455 dangerouslySetInnerHTML={{
456 __html: renderMarkdown(release.body),
457 }}
458 ></div>
459 )}
460 </div>
461 </Layout>
462 );
463});
464
465releasesRoute.post(
466 "/:owner/:repo/releases/:tag/delete",
467 requireAuth,
febd4f0Claude468 requireRepoAccess("write"),
3ef4c9dClaude469 async (c) => {
470 const user = c.get("user")!;
471 const { owner, repo } = c.req.param();
472 const tag = decodeURIComponent(c.req.param("tag"));
473 const repoRow = await loadRepo(owner, repo);
474 if (!repoRow) return c.notFound();
475 if (repoRow.ownerId !== user.id) {
476 return c.redirect(`/${owner}/${repo}/releases`);
477 }
478
479 await db
480 .delete(releases)
481 .where(
482 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
483 );
484 await deleteTag(owner, repo, tag);
485 await audit({
486 userId: user.id,
487 repositoryId: repoRow.id,
488 action: "release.delete",
489 targetType: "release",
490 metadata: { tag },
491 });
492 return c.redirect(`/${owner}/${repo}/releases`);
493 }
494);
495
496export default releasesRoute;