Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

478 lines · 19.9 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { FC } from "hono/jsx";
4import type { AppContext } from "../auth/principal.js";
5import { loadRepo } from "../http/common.js";
6import { storeAsset } from "../http/files.js";
7import { HttpError } from "../lib/util.js";
8import type { Release } from "../repo/releases.js";
9import { atLeast } from "../repo/types.js";
10import { ICONS } from "./assets.js";
11import { CSRF_FIELD, csrfToken, type FormData } from "./csrf.js";
12import { KCheckbox, KSelect } from "./kumo.js";
13import { Actor, Form, Markdown, Pager, Time, act, encPath, flash, fmtBytes, pageNum, post, prm, refNames, render, repoUrl, restOfPath, statusOf, type WebApp } from "./ui.js";
14
15/**
16 * Releases: the list, a release page per tag, and the new/edit form with
17 * asset uploads. Bytes go to R2 through the same path as the REST upload_url.
18 */
19
20type Loaded = Awaited<ReturnType<typeof loadRepo>>;
21
22/** The largest upload the web form takes in one submission; bigger files go through the API's upload_url. */
23const MAX_FORM_UPLOAD = 100 * 1024 * 1024;
24/** The whole form body, files plus the other fields; checked before the body is read. */
25const MAX_FORM_BODY = MAX_FORM_UPLOAD + 1024 * 1024;
26
27async function forAction(c: AppContext): Promise<Loaded> {
28 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
29 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
30 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
31 return l;
32}
33
34function releaseId(c: AppContext, name = "id"): number {
35 const n = Number(prm(c, name));
36 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found");
37 return n;
38}
39
40const tagHref = (base: string, tag: string) => `${base}/releases/tag/${encPath(tag)}`;
41
42// ---------------------------------------------------------------- repo home sidebar
43
44/** The "Releases" section on the repository home page, plus topics when the repo has any. */
45export const ReleasesSection: FC<{ base: string; summary: { count: number; latest: Release | null } | null; canWrite: boolean; topics?: string }> = ({ base, summary, canWrite, topics }) => {
46 let names: string[] = [];
47 try {
48 const t = JSON.parse(topics ?? "[]");
49 if (Array.isArray(t)) names = t.filter((x): x is string => typeof x === "string");
50 } catch {
51 names = [];
52 }
53 return (
54 <>
55 {names.length ? (
56 <section>
57 <h3>Topics</h3>
58 <div class="row" style="gap:6px;flex-wrap:wrap">
59 {names.map((n) => (
60 <span class="badge blue">{n}</span>
61 ))}
62 </div>
63 </section>
64 ) : null}
65 <section>
66 <h3>
67 <a href={`${base}/releases`} style="color:inherit">
68 Releases
69 </a>{" "}
70 {summary?.count ? <span class="badge">{summary.count}</span> : null}
71 </h3>
72 {summary?.latest ? (
73 <div class="row" style="align-items:flex-start">
74 <span class="st green">{raw(ICONS.tag)}</span>
75 <div>
76 <a href={tagHref(base, summary.latest.tagName)}>
77 <b>{summary.latest.name || summary.latest.tagName}</b>
78 </a>{" "}
79 <span class="badge green">Latest</span>
80 <div class="muted small">
81 <Time at={summary.latest.publishedAt ?? summary.latest.createdAt} />
82 </div>
83 </div>
84 </div>
85 ) : (
86 <p class="small muted m0">
87 No releases published.{" "}
88 {canWrite ? <a href={`${base}/releases/new`}>Create a new release</a> : null}
89 </p>
90 )}
91 </section>
92 </>
93 );
94};
95
96// ---------------------------------------------------------------- list and release pages
97
98const Badges: FC<{ r: Release }> = ({ r }) => (
99 <>
100 {r.draft ? <span class="badge amber">Draft</span> : null}
101 {r.prerelease ? <span class="badge amber">Pre-release</span> : null}
102 {r.latest ? <span class="badge green">Latest</span> : null}
103 </>
104);
105
106const ReleaseCard: FC<{ c: AppContext; l: Loaded; r: Release; full?: boolean }> = ({ c, l, r, full }) => {
107 const base = repoUrl(l.repo);
108 const canWrite = atLeast(l.caller.role, "write") && !l.repo.archived;
109 const t = csrfToken(c);
110 return (
111 <div class="box" id={`release-${r.id}`}>
112 <div class="head">
113 <div class="grow">
114 <h2 style="margin:0;font-size:20px">
115 <a href={tagHref(base, r.tagName)} style="color:var(--fg)">
116 {r.name || r.tagName}
117 </a>{" "}
118 <Badges r={r} />
119 </h2>
120 <div class="muted small" style="margin-top:4px">
121 <span class="faint">{raw(ICONS.tag)}</span>{" "}
122 <a class="mono" href={`${base}/tree/${encPath(r.tagName)}`}>
123 {r.tagName}
124 </a>{" "}
125 · <Actor login={r.author.login} kind={r.author.kind} /> {r.draft ? "drafted this" : "released this"} <Time at={r.publishedAt ?? r.createdAt} />
126 </div>
127 </div>
128 {canWrite ? (
129 <div class="row">
130 <a class="btn sm" href={`${base}/releases/edit/${encPath(r.tagName)}?id=${r.id}`}>
131 {raw(ICONS.edit)} Edit
132 </a>
133 <Form action={`${base}/releases/${r.id}/delete`} t={t} confirm={`Delete the release ${r.name || r.tagName}? The tag stays.`}>
134 <button class="btn sm danger">Delete</button>
135 </Form>
136 </div>
137 ) : null}
138 </div>
139 <div class="body">
140 <Markdown text={full || r.body.length < 4000 ? r.body : `${r.body.slice(0, 4000)}…`} base={{ owner: l.repo.owner_login, repo: l.repo.name, ref: r.tagName, dir: "" }} empty="No release notes." />
141 <h3 class="mt">
142 Assets <span class="badge">{r.assets.length + 2}</span>
143 </h3>
144 <ul class="list" style="border:1px solid var(--border);border-radius:6px">
145 {r.assets.map((a) => (
146 <li>
147 <span class="st faint">{raw(ICONS.file)}</span>
148 <div class="grow">
149 <a class="title" href={`${base}/releases/download/${encPath(r.tagName)}/${encodeURIComponent(a.name)}`} rel="nofollow">
150 {a.name}
151 </a>
152 {a.label && a.label !== a.name ? <span class="muted small"> {a.label}</span> : null}
153 </div>
154 <span class="muted small">{fmtBytes(a.size)}</span>
155 <span class="muted small" title="downloads">
156 {raw(ICONS.download)} {a.downloadCount}
157 </span>
158 {canWrite && full ? (
159 <Form action={`${base}/releases/${r.id}/assets/${a.id}/delete`} t={t} confirm={`Delete ${a.name}?`}>
160 <button class="btn sm" title="Delete asset" aria-label={`Delete ${a.name}`}>
161 {raw(ICONS.x)}
162 </button>
163 </Form>
164 ) : null}
165 </li>
166 ))}
167 {r.draft ? null : (
168 <>
169 <li>
170 <span class="st faint">{raw(ICONS.code)}</span>
171 <a class="title grow" href={`${base}/archive/refs/tags/${encPath(r.tagName)}.zip`} rel="nofollow">
172 Source code (zip)
173 </a>
174 </li>
175 <li>
176 <span class="st faint">{raw(ICONS.code)}</span>
177 <a class="title grow" href={`${base}/archive/refs/tags/${encPath(r.tagName)}.tar.gz`} rel="nofollow">
178 Source code (tar.gz)
179 </a>
180 </li>
181 </>
182 )}
183 </ul>
184 </div>
185 </div>
186 );
187};
188
189async function listPage(c: AppContext): Promise<Response> {
190 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
191 const page = pageNum(c);
192 const per = 10;
193 const res = await l.stub.listReleases({ limit: per, offset: (page - 1) * per }, l.caller);
194 const base = repoUrl(l.repo);
195 const canWrite = atLeast(l.caller.role, "write") && !l.repo.archived;
196 return render(
197 c,
198 { title: `Releases · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, description: `Releases of ${l.repo.owner_login}/${l.repo.name}` },
199 <div style="max-width:980px">
200 <div class="row between mb">
201 <h1 class="m0">Releases</h1>
202 <div class="row">
203 <a class="btn" href={`${base}/tags`}>
204 {raw(ICONS.tag)} Tags
205 </a>
206 {canWrite ? (
207 <a class="btn green" href={`${base}/releases/new`}>
208 Draft a new release
209 </a>
210 ) : null}
211 </div>
212 </div>
213 {res.items.length ? (
214 res.items.map((r) => <ReleaseCard c={c} l={l} r={r} />)
215 ) : (
216 <div class="box">
217 <div class="empty">
218 There aren't any releases here yet.
219 {canWrite ? (
220 <>
221 {" "}
222 <a href={`${base}/releases/new`}>Create the first release</a>, or publish one from a workflow with the REST API.
223 </>
224 ) : null}
225 </div>
226 </div>
227 )}
228 <Pager page={page} hasNext={page * per < res.total} href={(p) => `${base}/releases?page=${p}`} />
229 </div>,
230 );
231}
232
233async function tagPage(c: AppContext): Promise<Response> {
234 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
235 const tag = restOfPath(c, 4);
236 if (!tag) return c.redirect(`${repoUrl(l.repo)}/releases`, 302);
237 const r = await l.stub.releaseByTag(tag, l.caller, true);
238 return render(
239 c,
240 { title: `${r.name || r.tagName} · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, description: `Release ${r.tagName} of ${l.repo.owner_login}/${l.repo.name}` },
241 <div style="max-width:980px">
242 <div class="crumbs mb">
243 <a href={`${repoUrl(l.repo)}/releases`}>Releases</a>
244 <span class="faint">/</span>
245 <b>{r.tagName}</b>
246 </div>
247 <ReleaseCard c={c} l={l} r={r} full />
248 </div>,
249 );
250}
251
252// ---------------------------------------------------------------- new and edit
253
254type FormValues = { tag: string; newTag: string; target: string; name: string; body: string; prerelease: boolean; latest: boolean; generate: boolean };
255
256const ReleaseForm: FC<{ c: AppContext; l: Loaded; tags: string[]; branches: string[]; v: FormValues; r?: Release; error?: string }> = ({ c, l, tags, branches, v, r, error }) => {
257 const base = repoUrl(l.repo);
258 const action = r ? `${base}/releases/${r.id}/edit` : `${base}/releases`;
259 const targets = [...new Set([l.repo.default_branch, ...branches])];
260 return (
261 <div style="max-width:900px">
262 <h1>{r ? `Edit release ${r.tagName}` : "New release"}</h1>
263 {error ? <div class="flash error">{error}</div> : null}
264 <form method="post" action={action} enctype="multipart/form-data" class="box">
265 <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} />
266 <div class="body">
267 <div class="row" style="gap:12px;align-items:flex-start;flex-wrap:wrap">
268 <div class="field grow" style="min-width:220px">
269 <label>Tag</label>
270 <KSelect name="tag" label="Existing tag" placeholder="Choose an existing tag" value={tags.includes(v.tag) ? v.tag : undefined} options={tags.map((t) => ({ value: t, label: t }))} block />
271 <input type="text" name="new_tag" value={tags.includes(v.tag) ? "" : v.newTag || v.tag} placeholder="or type a new tag, e.g. v1.2.0" aria-label="New tag name" style="margin-top:6px" />
272 </div>
273 <div class="field" style="min-width:200px">
274 <label>Target for a new tag</label>
275 <KSelect name="target" label="Target branch" value={v.target || l.repo.default_branch} options={targets.map((b) => ({ value: b, label: b }))} block />
276 </div>
277 </div>
278 <div class="field">
279 <label for="rn">Release title</label>
280 <input id="rn" name="name" type="text" value={v.name} placeholder="Defaults to the tag name" />
281 </div>
282 <div class="field">
283 <label for="rb">Release notes</label>
284 <textarea id="rb" name="body" style="min-height:240px" placeholder="Markdown is supported.">
285 {v.body}
286 </textarea>
287 </div>
288 <div class="field">
289 <label for="ra">Attach files</label>
290 <input id="ra" name="assets" type="file" multiple />
291 <p class="small muted m0">Up to 100 MB per submission. Use the REST API's upload_url for larger files.</p>
292 </div>
293 {r?.assets.length ? (
294 <p class="small muted">
295 Attached: {r.assets.map((a) => a.name).join(", ")}. Remove files from the <a href={tagHref(base, r.tagName)}>release page</a>.
296 </p>
297 ) : null}
298 <div class="field checklist">
299 <KCheckbox name="prerelease" checked={v.prerelease} label="Set as a pre-release (not production ready)" />
300 <KCheckbox name="latest" checked={v.latest} label="Set as the latest release" />
301 {r ? null : <KCheckbox name="generate" checked={v.generate} label="Generate release notes from merged pull requests (when the notes are empty)" />}
302 </div>
303 <div class="row">
304 {r && !r.draft ? (
305 <button class="btn green" name="action" value="publish">
306 Update release
307 </button>
308 ) : (
309 <>
310 <button class="btn green" name="action" value="publish">
311 Publish release
312 </button>
313 <button class="btn" name="action" value="draft">
314 Save draft
315 </button>
316 </>
317 )}
318 <a class="btn" href={r ? tagHref(base, r.tagName) : `${base}/releases`}>
319 Cancel
320 </a>
321 </div>
322 </div>
323 </form>
324 </div>
325 );
326};
327
328function valuesOf(f: FormData): FormValues {
329 return {
330 tag: f.s("tag").trim(),
331 newTag: f.s("new_tag").trim(),
332 target: f.s("target").trim(),
333 name: f.s("name").trim(),
334 body: f.s("body").replace(/\r\n/g, "\n"),
335 prerelease: f.b("prerelease"),
336 latest: f.b("latest"),
337 generate: f.b("generate"),
338 };
339}
340
341/** The files chosen in the form's file input (empty file inputs submit a nameless, empty part). */
342async function uploadedFiles(c: AppContext): Promise<File[]> {
343 const raw = (await c.req.parseBody({ all: true })) as Record<string, string | File | Array<string | File>>;
344 const v = raw.assets;
345 const list = Array.isArray(v) ? v : v === undefined ? [] : [v];
346 const files = list.filter((x): x is File => typeof x !== "string" && !!x.name && x.size > 0);
347 const total = files.reduce((n, x) => n + x.size, 0);
348 if (total > MAX_FORM_UPLOAD) throw new HttpError(413, "the web form takes up to 100 MB of files at once; upload larger files with the REST API");
349 return files;
350}
351
352async function attach(c: AppContext, l: Loaded, id: number, files: File[]): Promise<void> {
353 for (const file of files) {
354 await storeAsset(c.env, l.stub, id, { name: file.name, contentType: file.type || "application/octet-stream" }, { stream: file.stream(), length: file.size }, l.caller);
355 }
356}
357
358async function formPage(c: AppContext, l: Loaded, v: FormValues, r?: Release, error?: string, status?: number): Promise<Response> {
359 const names = await refNames(l.stub);
360 return render(c, { title: `${r ? "Edit release" : "New release"} · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "code" }, status }, <ReleaseForm c={c} l={l} tags={[...names.tags].reverse()} branches={names.branches} v={v} r={r} error={error} />);
361}
362
363// ---------------------------------------------------------------- routes
364
365export function registerReleases(web: WebApp): void {
366 web.get("/:owner/:repo/releases", (c) => listPage(c));
367
368 web.get("/:owner/:repo/releases/latest", async (c) => {
369 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
370 const r = await l.stub.latestRelease().catch(() => null);
371 return c.redirect(r ? tagHref(repoUrl(l.repo), r.tagName) : `${repoUrl(l.repo)}/releases`, 302);
372 });
373
374 web.get("/:owner/:repo/releases/tag/*", (c) => tagPage(c));
375
376 web.get("/:owner/:repo/releases/new", async (c) => {
377 const l = await forAction(c);
378 const tag = (c.req.query("tag") ?? "").trim();
379 return formPage(c, l, { tag, newTag: tag, target: l.repo.default_branch, name: "", body: "", prerelease: false, latest: true, generate: true });
380 });
381
382 web.get("/:owner/:repo/releases/edit/*", async (c) => {
383 const l = await forAction(c);
384 const idq = Number(c.req.query("id"));
385 const r = Number.isInteger(idq) && idq > 0 ? await l.stub.getRelease(idq, l.caller) : await l.stub.releaseByTag(restOfPath(c, 4), l.caller, true);
386 return formPage(c, l, { tag: r.tagName, newTag: r.tagName, target: r.targetCommitish, name: r.name, body: r.body, prerelease: r.prerelease, latest: r.latest, generate: false }, r);
387 });
388
389 post(web, "/:owner/:repo/releases", async (c, f) => {
390 const l = await forAction(c);
391 const v = valuesOf(f);
392 const draft = f.s("action") === "draft";
393 try {
394 const files = await uploadedFiles(c);
395 const tag = v.newTag || v.tag;
396 if (!tag) throw new HttpError(422, "choose a tag or type a new one");
397 const r = await l.stub.createRelease(
398 {
399 tag_name: tag,
400 target_commitish: v.target || undefined,
401 name: v.name || null,
402 body: v.body || null,
403 draft,
404 prerelease: v.prerelease,
405 make_latest: v.latest ? "true" : "false",
406 generate_release_notes: v.generate && !v.body.trim(),
407 },
408 l.caller,
409 );
410 try {
411 await attach(c, l, r.id, files);
412 } catch (e) {
413 const s = statusOf(e);
414 if (s.status >= 500) throw e;
415 flash(c, "error", `The release was saved, but a file couldn't be attached: ${s.message}`);
416 return c.redirect(`${repoUrl(l.repo)}/releases/edit/${encPath(r.tagName)}?id=${r.id}`, 303);
417 }
418 return c.redirect(tagHref(repoUrl(l.repo), r.tagName), 303);
419 } catch (e) {
420 const s = statusOf(e);
421 if (s.status >= 500) throw e;
422 return formPage(c, l, v, undefined, s.message, s.status);
423 }
424 }, { maxBytes: MAX_FORM_BODY });
425
426 post(web, "/:owner/:repo/releases/:id/edit", async (c, f) => {
427 const l = await forAction(c);
428 const id = releaseId(c);
429 const before = await l.stub.getRelease(id, l.caller);
430 const v = valuesOf(f);
431 const publish = f.s("action") !== "draft";
432 try {
433 const files = await uploadedFiles(c);
434 const tag = v.newTag || v.tag || before.tagName;
435 const r = await l.stub.updateRelease(
436 id,
437 {
438 tag_name: tag,
439 target_commitish: v.target || undefined,
440 name: v.name,
441 body: v.body,
442 draft: before.draft ? !publish : false,
443 prerelease: v.prerelease,
444 make_latest: v.latest ? "true" : "false",
445 },
446 l.caller,
447 );
448 await attach(c, l, r.id, files);
449 return c.redirect(tagHref(repoUrl(l.repo), r.tagName), 303);
450 } catch (e) {
451 const s = statusOf(e);
452 if (s.status >= 500) throw e;
453 return formPage(c, l, v, before, s.message, s.status);
454 }
455 }, { maxBytes: MAX_FORM_BODY });
456
457 post(web, "/:owner/:repo/releases/:id/delete", async (c) => {
458 const l = await forAction(c);
459 const id = releaseId(c);
460 const base = repoUrl(l.repo);
461 return act(c, `${base}/releases`, async () => {
462 await l.stub.deleteRelease(id, l.caller);
463 return `${base}/releases`;
464 }, "Release deleted. The tag was kept.");
465 });
466
467 post(web, "/:owner/:repo/releases/:id/assets/:asset/delete", async (c) => {
468 const l = await forAction(c);
469 const id = releaseId(c);
470 const r = await l.stub.getRelease(id, l.caller);
471 const back = tagHref(repoUrl(l.repo), r.tagName);
472 const assetId = releaseId(c, "asset");
473 return act(c, back, async () => {
474 if (!r.assets.some((a) => a.id === assetId)) throw new HttpError(404, "asset not found");
475 await l.stub.deleteReleaseAsset(assetId, l.caller);
476 }, "Asset deleted.");
477 });
478}