| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { FC } from "hono/jsx"; |
| 4 | import type { AppContext } from "../auth/principal.js"; |
| 5 | import { loadRepo } from "../http/common.js"; |
| 6 | import { storeAsset } from "../http/files.js"; |
| 7 | import { HttpError } from "../lib/util.js"; |
| 8 | import type { Release } from "../repo/releases.js"; |
| 9 | import { atLeast } from "../repo/types.js"; |
| 10 | import { ICONS } from "./assets.js"; |
| 11 | import { CSRF_FIELD, csrfToken, type FormData } from "./csrf.js"; |
| 12 | import { KCheckbox, KSelect } from "./kumo.js"; |
| 13 | import { 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 | |
| 20 | type 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. */ |
| 23 | const MAX_FORM_UPLOAD = 100 * 1024 * 1024; |
| 24 | /** The whole form body, files plus the other fields; checked before the body is read. */ |
| 25 | const MAX_FORM_BODY = MAX_FORM_UPLOAD + 1024 * 1024; |
| 26 | |
| 27 | async 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 | |
| 34 | function 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 | |
| 40 | const 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. */ |
| 45 | export 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 | |
| 98 | const 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 | |
| 106 | const 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 | |
| 189 | async 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 | |
| 233 | async 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 | |
| 254 | type FormValues = { tag: string; newTag: string; target: string; name: string; body: string; prerelease: boolean; latest: boolean; generate: boolean }; |
| 255 | |
| 256 | const 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 | |
| 328 | function 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). */ |
| 342 | async 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 | |
| 352 | async 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 | |
| 358 | async 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 | |
| 365 | export 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 | } |