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.

892 lines · 37.4 KBRaw History
1/** @jsxImportSource hono/jsx */
2import type { Hono } from "hono";
3import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4import { html, raw } from "hono/html";
5import type { Child, FC, PropsWithChildren } from "hono/jsx";
6import type { Account, Repo } from "../lib/directory.js";
7import type { Env } from "../env.js";
8import type { AppContext, AppVars } from "../auth/principal.js";
9import { HttpError, randomToken } from "../lib/util.js";
10import { atLeast, type Caller } from "../repo/types.js";
11import type { repoStub } from "../http/common.js";
12import { CSS, ICONS, JS, type IconName } from "./assets.js";
13import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
14import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js";
15import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js";
16import { KSelect, kumoAssets } from "./kumo.js";
17import { MailStore, type WatchLevel } from "../mail/prefs.js";
18import { SeoHead, SITE } from "./seo.js";
19import { avatarPath } from "../http/avatars.js";
20
21export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
22export type Stub = ReturnType<typeof repoStub>;
23
24// ---------------------------------------------------------------- small helpers
25
26export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
27export const repoUrl = (r: Pick<Repo, "owner_login" | "name">): string => `/${r.owner_login}/${r.name}`;
28export const shortSha = (sha: string): string => sha.slice(0, 7);
29export const firstLine = (s: string): string => s.split("\n")[0] ?? "";
30
31export function publicUrl(c: AppContext): string {
32 return (c.env.PUBLIC_URL || new URL(c.req.url).origin).replace(/\/$/, "");
33}
34
35export function cloneUrl(c: AppContext, repo: Repo): string {
36 return `${publicUrl(c)}/${repo.owner_login}/${repo.name}.git`;
37}
38
39export function fmtBytes(n: number | undefined | null): string {
40 if (n === undefined || n === null) return "";
41 if (n < 1024) return `${n} B`;
42 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
43 if (n < 1024 ** 3) return `${(n / 1024 / 1024).toFixed(1)} MB`;
44 return `${(n / 1024 ** 3).toFixed(2)} GB`;
45}
46
47export function fmtDuration(ms: number): string {
48 if (!Number.isFinite(ms) || ms < 0) return "";
49 const s = Math.round(ms / 1000);
50 if (s < 60) return `${s}s`;
51 const m = Math.floor(s / 60);
52 if (m < 60) return `${m}m ${s % 60}s`;
53 return `${Math.floor(m / 60)}h ${m % 60}m`;
54}
55
56export function duration(start: string | null | undefined, end: string | null | undefined): string {
57 if (!start) return "";
58 return fmtDuration((end ? Date.parse(end) : Date.now()) - Date.parse(start));
59}
60
61export function ago(iso: string | null | undefined): string {
62 if (!iso) return "";
63 const t = Date.parse(iso);
64 if (!Number.isFinite(t)) return iso;
65 const s = Math.round((Date.now() - t) / 1000);
66 if (s < 45) return "just now";
67 const m = Math.round(s / 60);
68 if (m < 60) return `${m} minute${m === 1 ? "" : "s"} ago`;
69 const h = Math.round(m / 60);
70 if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
71 const d = Math.round(h / 24);
72 if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
73 return new Date(t).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
74}
75
76export const Time: FC<{ at: string | null | undefined }> = ({ at }) => (at ? <time datetime={at} title={at}>{ago(at)}</time> : <span />);
77
78export const Icon: FC<{ name: IconName; class?: string }> = ({ name, class: cls }) => <span class={`st ${cls ?? ""}`}>{raw(ICONS[name])}</span>;
79
80/** Status icon for runs, jobs, checks and steps. */
81export const Status: FC<{ status?: string | null; conclusion?: string | null }> = ({ status, conclusion }) => {
82 if (status && status !== "completed") {
83 if (status === "in_progress") return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
84 return <span class="st warn" title={status === "waiting" ? "Waiting" : status === "pending" ? "Pending" : "Queued"}>{raw(ICONS.queued)}</span>;
85 }
86 switch (conclusion) {
87 case "success":
88 return <span class="st ok" title="Success">{raw(ICONS.success)}</span>;
89 case "failure":
90 case "timed_out":
91 case "startup_failure":
92 return <span class="st bad" title={conclusion === "timed_out" ? "Timed out" : "Failed"}>{raw(ICONS.failure)}</span>;
93 case "cancelled":
94 return <span class="st faint" title="Cancelled">{raw(ICONS.cancelled)}</span>;
95 case "skipped":
96 return <span class="st faint" title="Skipped">{raw(ICONS.skipped)}</span>;
97 case "in_progress":
98 return <span class="st spin run" title="In progress">{raw(ICONS.running)}</span>;
99 case "queued":
100 case "waiting":
101 case "pending":
102 return <span class="st warn" title="Queued">{raw(ICONS.queued)}</span>;
103 default:
104 return <span class="st faint" title={conclusion ?? "Neutral"}>{raw(ICONS.neutral)}</span>;
105 }
106};
107
108/** CSS pixels of each avatar size, and the stored square (2×) served for it. */
109const AVATAR_PX = { sm: [20, 64], lg: [40, 128], xl: [96, 256] } as const;
110
111/** Profile picture, or generated initials. Pass the account's avatar_version when it is at hand, so a new upload shows at once. */
112export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl"; version?: number | null }> = ({ login, kind, size, version }) => {
113 const [css, px] = AVATAR_PX[size ?? "sm"];
114 return <img class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} src={avatarPath(login, px, version)} width={css} height={css} alt="" loading="lazy" decoding="async" />;
115};
116
117/**
118 * Linked actor name with an "agent" badge and the human it acts for. People imported from
119 * GitHub without a Diffinery account (kind "github", or logins stored as "github:<login>")
120 * are shown with a "GitHub" badge and no link.
121 */
122export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean; version?: number | null }> = ({ login, kind, onBehalfOf, avatar = true, version }) =>
123 kind === "github" || login.startsWith("github:") ? (
124 <span class="who" title="Imported from GitHub; not linked to a Diffinery account">
125 {avatar ? <Avatar login={login.replace(/^github:/, "")} /> : null}
126 <span>{login.replace(/^github:/, "")}</span>
127 <span class="badge">GitHub</span>
128 </span>
129 ) : (
130 <span class="who">
131 {avatar ? <Avatar login={login} kind={kind} version={version} /> : null}
132 {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>}
133 {kind === "agent" ? <span class="badge agent">agent</span> : null}
134 {kind === "system" ? <span class="badge">bot</span> : null}
135 {onBehalfOf ? (
136 <span class="muted small" style="font-weight:400">
137 on behalf of <a href={`/${encodeURIComponent(onBehalfOf)}`}>{onBehalfOf}</a>
138 </span>
139 ) : null}
140 </span>
141);
142
143export const Label: FC<{ name: string; color?: string; href?: string }> = ({ name, color, href }) => {
144 const c = color && /^[0-9a-fA-F]{6}$/.test(color) ? `#${color}` : "#8b929c";
145 return href ? (
146 <a class="lbl" style={`--lc:${c}`} href={href}>
147 {name}
148 </a>
149 ) : (
150 <span class="lbl" style={`--lc:${c}`}>{name}</span>
151 );
152};
153
154export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = async ({ text, base, empty }) =>
155 text.trim() ? (
156 <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)) }))}</div>
157 ) : (
158 <p class="muted m0">
159 <em>{empty ?? "No description provided."}</em>
160 </p>
161 );
162
163/** Highlighted code tokens (children of an element with class "sh"). */
164export const tokens = (line: TokLine): Child[] => line.map((t) => (t.style ? <span style={t.style}>{t.text}</span> : t.text));
165
166/** CSRF-protected POST form. `avatar` makes it a multipart picture upload (shrunk in the browser first, see assets.ts). */
167export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string; avatar?: boolean }>> = (p) => (
168 <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style} enctype={p.avatar ? "multipart/form-data" : undefined} data-avatar={p.avatar ? "1" : undefined}>
169 <input type="hidden" name={CSRF_FIELD} value={p.t} />
170 {p.children}
171 </form>
172);
173
174/** Copy-to-clipboard button: small by default; `size="base"` next to 36px fields, `icon` for an icon-only button. */
175export const CopyButton: FC<{ text: string; label?: string; class?: string; size?: "sm" | "base"; icon?: boolean }> = ({ text, label, class: cls, size = "sm", icon }) => (
176 <button type="button" class={`btn ${size === "sm" ? "sm" : ""} ${icon ? "icon" : ""} ${cls ?? ""}`} data-copy={text} data-copy-icon={icon ? "1" : undefined} title="Copy to clipboard" aria-label={icon ? "Copy to clipboard" : undefined}>
177 {icon ? raw(ICONS.copy) : (label ?? "Copy")}
178 </button>
179);
180
181export const Pager: FC<{ page: number; hasNext: boolean; href: (page: number) => string }> = ({ page, hasNext, href }) =>
182 page > 1 || hasNext ? (
183 <div class="pager">
184 {page > 1 ? <a class="btn sm" href={href(page - 1)}>← Newer</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">← Newer</span>}
185 <span class="muted small" style="align-self:center">Page {page}</span>
186 {hasNext ? <a class="btn sm" href={href(page + 1)}>Older →</a> : <span class="btn sm" aria-disabled="true" style="opacity:.4">Older →</span>}
187 </div>
188 ) : null;
189
190export function qs(params: Record<string, string | number | undefined | null | false>): string {
191 const u = new URLSearchParams();
192 for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "" && v !== false) u.set(k, String(v));
193 const s = u.toString();
194 return s ? `?${s}` : "";
195}
196
197/** A required route parameter (404 when missing). */
198export function prm(c: AppContext, name: string): string {
199 const v = c.req.param(name);
200 if (!v) throw new HttpError(404, "not found");
201 return v;
202}
203
204export const pageNum = (c: AppContext): number => Math.max(1, Math.min(1000, parseInt(c.req.query("page") ?? "1", 10) || 1));
205
206// ---------------------------------------------------------------- errors, flash and actions
207
208export function statusOf(e: unknown): { status: number; message: string } {
209 if (e instanceof HttpError) return { status: e.status, message: e.detail };
210 const msg = (e as Error)?.message ?? String(e);
211 const m = /^(\d{3}): ([\s\S]*)$/.exec(msg);
212 if (m) return { status: Number(m[1]), message: m[2]! };
213 return { status: 500, message: "internal error" };
214}
215
216const FLASH = "dfy_flash";
217
218export function flash(c: AppContext, kind: "error" | "notice", message: string): void {
219 setCookie(c, FLASH, encodeURIComponent(JSON.stringify({ kind, message: message.slice(0, 600) })), { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 60 });
220}
221
222function takeFlash(c: AppContext): { kind: "error" | "notice"; message: string } | null {
223 const v = getCookie(c, FLASH);
224 if (!v) return null;
225 deleteCookie(c, FLASH, { path: "/", secure: true });
226 try {
227 const f = JSON.parse(decodeURIComponent(v)) as { kind: string; message: string };
228 if (typeof f.message !== "string") return null;
229 return { kind: f.kind === "notice" ? "notice" : "error", message: f.message };
230 } catch {
231 return null;
232 }
233}
234
235/** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */
236export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> {
237 try {
238 const to = await fn();
239 if (notice) flash(c, "notice", notice);
240 return c.redirect(to ?? back, 303);
241 } catch (e) {
242 const s = statusOf(e);
243 if (s.status >= 500) throw e;
244 flash(c, "error", s.message);
245 return c.redirect(back, 303);
246 }
247}
248
249/** Register a CSRF-checked POST route. */
250export function post(app: WebApp, path: string, handler: (c: AppContext, form: FormData) => Promise<Response>, opts?: { maxBytes?: number }): void {
251 app.post(path, async (c) => handler(c, await verifyCsrf(c, opts?.maxBytes)));
252}
253
254export function principalAccount(c: AppContext): Account | null {
255 const p = c.get("principal");
256 return p?.type === "account" ? p.account : null;
257}
258
259// ---------------------------------------------------------------- layout
260
261export interface RepoCtx {
262 repo: Repo;
263 caller: Caller;
264 tab?: "code" | "issues" | "pulls" | "actions" | "packages" | "settings" | "";
265 /** Current ref, used by the palette's "go to file". */
266 ref?: string;
267 counts?: { issues?: number; pulls?: number };
268}
269
270export interface PageOpts {
271 title: string;
272 repo?: RepoCtx;
273 /** WebSocket topics for live updates (e.g. "run:abc,pr:3"). */
274 live?: string;
275 liveMode?: "reload" | "log";
276 lastSeq?: number;
277 status?: number;
278 /** Full-width page (diffs, logs). */
279 wide?: boolean;
280 /** Meta description for search results and link previews. */
281 description?: string;
282 /** The home page: a descriptive title and structured data for search engines. */
283 home?: boolean;
284 /** Extra elements for <head> (e.g. alternate links). */
285 head?: Child;
286 /** A page script, run after the site's own (it gets the page's CSP nonce). */
287 script?: string;
288 /** Reload the page every this many seconds (progress pages without live updates). */
289 refresh?: number;
290}
291
292export const WATCH_OPTIONS = [
293 { value: "participating", label: "Participating and @mentions" },
294 { value: "all", label: "All activity" },
295 { value: "ignore", label: "Ignore" },
296];
297
298/** Watch / unwatch control in the repository header. */
299const WatchControl: FC<{ base: string; level: WatchLevel; t: string }> = ({ base, level, t }) => (
300 <form method="post" action={`${base}/subscription`} class="inline" title="Which activity in this repository notifies you">
301 <input type="hidden" name={CSRF_FIELD} value={t} />
302 <KSelect name="level" options={WATCH_OPTIONS.map((o) => ({ value: o.value, label: o.value === "participating" ? "Watch: participating" : o.value === "all" ? "Watch: all activity" : "Watch: ignore" }))} value={level} size="sm" label="Watch" autosubmit />
303 </form>
304);
305
306const RepoHeader: FC<{ r: RepoCtx; watch?: WatchLevel; t: string }> = ({ r, watch, t }) => {
307 const base = repoUrl(r.repo);
308 const tab = r.tab ?? "";
309 const vis = r.repo.visibility;
310 return (
311 <div class="repohead">
312 <div class="wrap">
313 {watch ? (
314 <div style="float:right">
315 <WatchControl base={base} level={watch} t={t} />
316 </div>
317 ) : null}
318 <h1>
319 <span class="st faint">{raw(vis === "public" ? ICONS.globe : ICONS.lock)}</span>
320 <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a>
321 <span class="faint">/</span>
322 <strong>
323 <a href={base}>{r.repo.name}</a>
324 </strong>
325 <span class="badge">{vis}</span>
326 {r.repo.archived ? <span class="badge amber">archived</span> : null}
327 </h1>
328 {r.repo.description ? <p class="muted m0">{r.repo.description}</p> : null}
329 <nav class="tabs">
330 <a href={base} class={tab === "code" ? "on" : ""}>
331 {raw(ICONS.code)} Code
332 </a>
333 <a href={`${base}/issues`} class={tab === "issues" ? "on" : ""}>
334 {raw(ICONS.issueOpen)} Issues {r.counts?.issues ? <span class="n">{r.counts.issues}</span> : null}
335 </a>
336 <a href={`${base}/pulls`} class={tab === "pulls" ? "on" : ""}>
337 {raw(ICONS.pr)} Pull requests {r.counts?.pulls ? <span class="n">{r.counts.pulls}</span> : null}
338 </a>
339 <a href={`${base}/actions`} class={tab === "actions" ? "on" : ""}>
340 {raw(ICONS.play)} Actions
341 </a>
342 <a href={`${base}/packages`} class={tab === "packages" ? "on" : ""}>
343 {raw(ICONS.package)} Packages
344 </a>
345 {atLeast(r.caller.role, "admin") ? (
346 <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}>
347 {raw(ICONS.gear)} Settings
348 </a>
349 ) : null}
350 </nav>
351 </div>
352 </div>
353 );
354};
355
356function csp(nonce: string, c: AppContext): string {
357 const host = new URL(c.req.url).host;
358 return [
359 "default-src 'self'",
360 `script-src 'nonce-${nonce}'`,
361 "style-src 'self' 'unsafe-inline'",
362 // blob: previews a picture before it is uploaded.
363 "img-src 'self' https: data: blob:",
364 `connect-src 'self' wss://${host} ws://${host}`,
365 "font-src 'self'",
366 "object-src 'none'",
367 "base-uri 'none'",
368 "form-action 'self'",
369 "frame-ancestors 'none'",
370 ].join("; ");
371}
372
373/** Render a full page. */
374export function render(c: AppContext, opts: PageOpts, body: Child): Response | Promise<Response> {
375 const p = c.get("principal");
376 const me = p?.type === "account" ? p.account : null;
377 if (opts.repo && me?.status === "active" && me.kind === "user" && c.env.DB) {
378 return new MailStore(c.env.DB)
379 .watchLevel(me.id, opts.repo.repo.id)
380 .catch(() => "participating" as const)
381 .then((w) => renderPage(c, opts, body, w));
382 }
383 return renderPage(c, opts, body);
384}
385
386export type Appearance = "light" | "dark";
387export const APPEARANCE_COOKIE = "dfy_theme";
388
389/** The chosen theme, or null to follow the system. Signed-out visitors keep the last choice made in this browser. */
390export function appearanceOf(c: AppContext): Appearance | null {
391 const p = c.get("principal");
392 const v = p?.type === "account" ? p.account.appearance : getCookie(c, APPEARANCE_COOKIE);
393 return v === "light" || v === "dark" ? v : null;
394}
395
396function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> {
397 csrfToken(c);
398 const nonce = randomToken(16);
399 const fl = takeFlash(c);
400 const p = c.get("principal");
401 const me = p?.type === "account" ? p.account : null;
402 const active = me?.status === "active";
403 c.header("content-security-policy", csp(nonce, c));
404 c.header("x-frame-options", "DENY");
405 c.header("x-content-type-options", "nosniff");
406 c.header("referrer-policy", "same-origin");
407 c.header("cache-control", "private, no-store");
408 const r = opts.repo;
409 const title = opts.home ? `${SITE.name}: ${SITE.tagline}` : opts.title ? `${opts.title} · ${SITE.name}` : SITE.name;
410 const theme = appearanceOf(c);
411 const page = (
412 <html lang="en" data-theme={theme ?? undefined} data-mode={theme ?? undefined}>
413 <head>
414 <meta charset="utf-8" />
415 <meta name="viewport" content="width=device-width, initial-scale=1" />
416 <meta name="color-scheme" content={theme ?? "light dark"} />
417 {opts.refresh ? <meta http-equiv="refresh" content={String(opts.refresh)} /> : null}
418 <title>{title}</title>
419 {SeoHead(c, {
420 title,
421 description: opts.description ?? (r?.repo.description ? `${r.repo.description} · ${r.repo.owner_login}/${r.repo.name} on Diffinery` : r ? `${r.repo.owner_login}/${r.repo.name} on Diffinery` : undefined),
422 noindex: (opts.status ?? 200) >= 400 || (r !== undefined && r.repo.visibility !== "public"),
423 home: opts.home,
424 nonce,
425 })}
426 <style>{raw(CSS)}</style>
427 <link rel="stylesheet" href={kumoAssets().css} />
428 {opts.head ?? null}
429 </head>
430 <body
431 data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined}
432 data-ref={r?.ref ?? r?.repo.default_branch}
433 data-live={opts.live}
434 data-live-mode={opts.live ? (opts.liveMode ?? "reload") : undefined}
435 data-last-seq={opts.lastSeq !== undefined ? String(opts.lastSeq) : undefined}
436 >
437 <header class="top">
438 <div class="wrap">
439 <a class="brand" href="/">
440 {raw(ICONS.logo)} Diffinery
441 </a>
442 {active ? (
443 <button type="button" class="pal-btn" data-palette="1" aria-label="Open command palette">
444 {raw(ICONS.search)}
445 <span>Jump to…</span>
446 <kbd>Ctrl K</kbd>
447 </button>
448 ) : null}
449 <nav>
450 <a href="/docs" title="Documentation">
451 Docs
452 </a>
453 {active && me ? (
454 <>
455 <a href="/new" title="New repository">
456 {raw(ICONS.plus)}
457 </a>
458 <a href="/notifications" title="Notifications">
459 {raw(ICONS.bell)}
460 </a>
461 <a href="/settings" title="Settings" class="who">
462 <Avatar login={me.login} version={me.avatar_version} /> <span>{me.login}</span>
463 </a>
464 </>
465 ) : null}
466 {p?.type === "account" && p.via === "session" ? (
467 <form method="post" action="/auth/logout" class="inline">
468 <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} />
469 <button class="navbtn">Sign out</button>
470 </form>
471 ) : null}
472 {!p || p.type === "anonymous" ? (
473 c.req.path === "/login" ? null : (
474 <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm">
475 Sign in
476 </a>
477 )
478 ) : null}
479 </nav>
480 </div>
481 </header>
482 {r ? <RepoHeader r={r} watch={watch} t={csrfToken(c)} /> : null}
483 <main class="wrap" style={opts.wide ? "max-width:none" : undefined}>
484 {fl ? <div class={`flash ${fl.kind}`} role={fl.kind === "error" ? "alert" : "status"}>{fl.message}</div> : null}
485 {body}
486 </main>
487 {active ? (
488 <dialog id="palette" aria-label="Command palette">
489 <input type="text" placeholder="Jump to a repository, file, page or #number…" autocomplete="off" spellcheck={false} />
490 <ul role="listbox"></ul>
491 <div class="foot">
492 <span>
493 <kbd>↑</kbd> <kbd>↓</kbd> navigate
494 </span>
495 <span>
496 <kbd>Enter</kbd> open
497 </span>
498 <span>
499 <kbd>g</kbd> then <kbd>c</kbd>/<kbd>i</kbd>/<kbd>p</kbd>/<kbd>a</kbd> code, issues, pulls, actions
500 </span>
501 </div>
502 </dialog>
503 ) : null}
504 <script nonce={nonce} src={kumoAssets().js} defer></script>
505 <script nonce={nonce}>{raw(JS)}</script>
506 {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null}
507 </body>
508 </html>
509 );
510 // `html` waits for async components (highlighted Markdown and diffs).
511 return c.html(html`<!doctype html>${page}`, (opts.status ?? 200) as 200);
512}
513
514/** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */
515export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
516 const s = statusOf(e);
517 if (s.status >= 500) console.error(e);
518 // Signed-out visitors who open a page go straight to sign-in, and come back afterwards.
519 if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) {
520 const url = new URL(c.req.url);
521 return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
522 }
523 const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" };
524 const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed");
525 const p = c.get("principal");
526 return render(
527 c,
528 { title, status: s.status },
529 <div class="blank" style="max-width:640px;margin:48px auto">
530 <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div>
531 <h1>{title}</h1>
532 {s.status === 401 ? (
533 <>
534 <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p>
535 <p>
536 <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}>
537 Sign in
538 </a>
539 </p>
540 </>
541 ) : s.status === 404 ? (
542 <p class="muted">The page or repository does not exist, or you do not have access to it.</p>
543 ) : (
544 <p class="muted">{s.status >= 500 ? "An unexpected error occurred. Please try again." : s.message}</p>
545 )}
546 {s.status === 403 && p?.type === "account" && p.account.status === "pending" ? (
547 <p>
548 <a class="btn primary" href="/">
549 Request access
550 </a>
551 </p>
552 ) : s.status === 401 ? null : (
553 <p>
554 <a class="btn" href="/">
555 Go to dashboard
556 </a>
557 </p>
558 )}
559 </div>,
560 );
561}
562
563// ---------------------------------------------------------------- refs and paths
564
565/** Path segments after a fixed number of leading segments, decoded. */
566export function restOfPath(c: AppContext, skip: number): string {
567 const parts = new URL(c.req.url).pathname.split("/").slice(skip + 1);
568 return parts
569 .filter((p) => p !== "")
570 .map((p) => {
571 try {
572 return decodeURIComponent(p);
573 } catch {
574 return p;
575 }
576 })
577 .join("/");
578}
579
580/** Split "feature/x/src/a.ts" into the longest matching ref and the path inside it. */
581export async function splitRefPath(stub: Stub, rest: string): Promise<{ ref: string; path: string }> {
582 const segs = rest.split("/").filter(Boolean);
583 if (segs.length <= 1) return { ref: segs[0] ?? "", path: "" };
584 const names = new Set((await stub.refs("refs/")).map((r) => r.name.replace(/^refs\/(heads|tags)\//, "")));
585 for (let i = segs.length; i >= 1; i--) {
586 const cand = segs.slice(0, i).join("/");
587 if (names.has(cand)) return { ref: cand, path: segs.slice(i).join("/") };
588 }
589 return { ref: segs[0]!, path: segs.slice(1).join("/") };
590}
591
592/** Branch/tag switcher; `to` builds the target URL for a ref. */
593export const RefSwitcher: FC<{ current: string; branches: string[]; tags: string[]; to: (ref: string) => string }> = ({ current, branches, tags, to }) => (
594 <details class="menu">
595 <summary class="btn sm">
596 {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)}
597 <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span>
598 {raw(ICONS.caret)}
599 </summary>
600 <div class="pop">
601 <h4>Branches</h4>
602 {branches.length ? (
603 branches.map((b) => (
604 <a href={to(b)}>
605 {b === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {b}
606 </a>
607 ))
608 ) : (
609 <p class="muted small" style="margin:4px 8px">No branches</p>
610 )}
611 {tags.length ? <h4>Tags</h4> : null}
612 {tags.map((t) => (
613 <a href={to(t)}>
614 {t === current ? raw(ICONS.check) : <span style="width:16px;display:inline-block" />} {t}
615 </a>
616 ))}
617 </div>
618 </details>
619);
620
621export async function refNames(stub: Stub): Promise<{ branches: string[]; tags: string[] }> {
622 const refs = await stub.refs("refs/");
623 return {
624 branches: refs.filter((r) => r.name.startsWith("refs/heads/")).map((r) => r.name.slice(11)),
625 tags: refs.filter((r) => r.name.startsWith("refs/tags/")).map((r) => r.name.slice(10)),
626 };
627}
628
629// ---------------------------------------------------------------- diffs
630
631export interface DiffHunk {
632 oldStart: number;
633 oldLines: number;
634 newStart: number;
635 newLines: number;
636 lines: string[];
637}
638
639export interface DiffFile {
640 path: string;
641 status: "added" | "modified" | "deleted";
642 binary: boolean;
643 additions: number;
644 deletions: number;
645 hunks: DiffHunk[];
646 truncated?: boolean;
647}
648
649export interface LineThread {
650 side: "LEFT" | "RIGHT";
651 line: number;
652 node: Child;
653}
654
655const StatBar: FC<{ a: number; d: number }> = ({ a, d }) => {
656 const total = a + d;
657 const blocks = 5;
658 const ga = total ? Math.round((a / total) * blocks) : 0;
659 const gd = total ? Math.min(blocks - ga, Math.round((d / total) * blocks)) : 0;
660 return (
661 <span class="bar" aria-hidden="true">
662 {Array.from({ length: blocks }, (_, i) => (
663 <i class={i < ga ? "a" : i < ga + gd ? "d" : ""} />
664 ))}
665 </span>
666 );
667};
668
669export const DiffStats: FC<{ files: number; additions: number; deletions: number }> = ({ files, additions, deletions }) => (
670 <span class="row" style="gap:10px">
671 <span>
672 <b>{files}</b> file{files === 1 ? "" : "s"} changed
673 </span>
674 <span class="stat-add">+{additions}</span>
675 <span class="stat-del">−{deletions}</span>
676 <StatBar a={additions} d={deletions} />
677 </span>
678);
679
680export interface DiffOptions {
681 /** Link to view the file at the new revision. */
682 viewHref?: (path: string) => string | null;
683 /** Show "+" buttons for line comments (requires the line comment template on the page). */
684 commentable?: boolean;
685 /** Existing review threads per file path. */
686 threads?: Map<string, LineThread[]>;
687}
688
689const LINE_BUDGET = 20000;
690const HIGHLIGHT_BUDGET = 3000;
691
692const diffLines = (f: DiffFile): number => f.hunks.reduce((n, h) => n + h.lines.length, 0);
693
694/** Syntax tokens per file, hunk and line, for the rendered files that fit the highlighting budget. */
695async function diffTokens(files: DiffFile[]): Promise<Map<DiffFile, Array<Array<TokLine | null>>>> {
696 const out = new Map<DiffFile, Array<Array<TokLine | null>>>();
697 const picked: Array<[DiffFile, Lang]> = [];
698 let budget = LINE_BUDGET;
699 let hlBudget = HIGHLIGHT_BUDGET;
700 for (const f of files) {
701 const lines = diffLines(f);
702 const rendered = budget > 0 && !f.binary && !f.truncated;
703 budget -= lines;
704 const lang = langForPath(f.path);
705 if (!rendered || !lang || lines > LIMITS.lines || lines > hlBudget) continue;
706 hlBudget -= lines;
707 picked.push([f, lang]);
708 }
709 const hl = picked.length ? await ready(picked.map(([, l]) => l)) : null;
710 if (hl) for (const [f, lang] of picked) out.set(f, f.hunks.map((h) => diffLineTokens(h.lines, (code) => tokenize(hl, code, lang))));
711 return out;
712}
713
714/** Unified diff with line numbers; each file is collapsible. */
715export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = async ({ files, opts = {} }) => {
716 const toks = await diffTokens(files);
717 let budget = LINE_BUDGET;
718 return (
719 <div>
720 {files.map((f) => {
721 const lines = diffLines(f);
722 const big = lines > 800;
723 const ft = toks.get(f);
724 const render = budget > 0 && !f.binary && !f.truncated;
725 budget -= lines;
726 const threads = opts.threads?.get(f.path) ?? [];
727 const view = opts.viewHref?.(f.path);
728 return (
729 <details class="file" open={!big && render} id={`diff-${encodeURIComponent(f.path)}`}>
730 <summary class="filehead">
731 <span class={`badge ${f.status === "added" ? "green" : f.status === "deleted" ? "red" : "amber"}`}>{f.status}</span>
732 <span class="mono grow" style="font-weight:600;word-break:break-all">{f.path}</span>
733 <span class="stat-add small">+{f.additions}</span>
734 <span class="stat-del small">−{f.deletions}</span>
735 {view && f.status !== "deleted" ? (
736 <a class="btn sm" href={view}>
737 View file
738 </a>
739 ) : null}
740 </summary>
741 {f.binary ? (
742 <div class="empty">Binary file not shown.</div>
743 ) : f.truncated ? (
744 <div class="empty">This diff is too large to display.</div>
745 ) : !render ? (
746 <div class="empty">Diff not rendered to keep this page fast. {view ? <a href={view}>View the file</a> : null}</div>
747 ) : f.hunks.length === 0 ? (
748 <div class="empty">{f.status === "added" ? "Empty file added." : f.status === "deleted" ? "Empty file deleted." : "Mode change only."}</div>
749 ) : (
750 <div class="code-wrap">
751 <table class="diff">
752 {f.hunks.map((h, hi) => {
753 const ht = ft?.[hi];
754 let oldN = h.oldStart;
755 let newN = h.newStart;
756 return (
757 <>
758 <tr class="hunk">
759 {opts.commentable ? <td class="act" /> : null}
760 <td class="ln" />
761 <td class="ln" />
762 <td class="code">
763 @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@
764 </td>
765 </tr>
766 {h.lines.map((l, li) => {
767 const k = l[0];
768 const lt = ht?.[li];
769 if (k === "\\") {
770 return (
771 <tr class="meta">
772 {opts.commentable ? <td class="act" /> : null}
773 <td class="ln" />
774 <td class="ln" />
775 <td class="code">{l}</td>
776 </tr>
777 );
778 }
779 const cls = k === "+" ? "add" : k === "-" ? "del" : "ctx";
780 const o = k === "+" ? null : oldN++;
781 const n = k === "-" ? null : newN++;
782 const side = k === "-" ? "LEFT" : "RIGHT";
783 const lineNo = side === "LEFT" ? o : n;
784 const here = threads.filter((t) => t.side === side && t.line === lineNo);
785 return (
786 <>
787 <tr class={cls}>
788 {opts.commentable ? (
789 <td class="act">
790 <button type="button" class="addc" title="Comment on this line" data-comment-line={String(lineNo)} data-path={f.path} data-side={side}>
791 +
792 </button>
793 </td>
794 ) : null}
795 <td class="ln">{o ?? ""}</td>
796 <td class="ln">{n ?? ""}</td>
797 <td class={lt ? "code sh" : "code"}>
798 {k === "+" || k === "-" ? k : " "}
799 {lt ? tokens(lt) : l.slice(1)}
800 </td>
801 </tr>
802 {here.length ? (
803 <tr class="thread">
804 <td colspan={opts.commentable ? 4 : 3}>{here.map((t) => t.node)}</td>
805 </tr>
806 ) : null}
807 </>
808 );
809 })}
810 </>
811 );
812 })}
813 </table>
814 </div>
815 )}
816 </details>
817 );
818 })}
819 </div>
820 );
821};
822
823/** Changed-files summary list with anchors into the diff. */
824export const FileSummary: FC<{ files: DiffFile[] }> = ({ files }) =>
825 files.length > 3 ? (
826 <details class="box">
827 <summary class="head" style="cursor:pointer">
828 <span>Files ({files.length})</span>
829 </summary>
830 <ul class="list">
831 {files.map((f) => (
832 <li style="padding:6px 14px">
833 <a class="mono grow" href={`#diff-${encodeURIComponent(f.path)}`}>
834 {f.path}
835 </a>
836 <span class="stat-add small">+{f.additions}</span>
837 <span class="stat-del small">−{f.deletions}</span>
838 </li>
839 ))}
840 </ul>
841 </details>
842 ) : null;
843
844/** Commit list row. */
845export const CommitRow: FC<{ base: string; sha: string; message: string; author: { name: string; email?: string; timestamp: number }; signed?: boolean; extra?: Child }> = ({ base, sha, message, author, signed, extra }) => (
846 <li>
847 <div class="grow">
848 <a class="title" href={`${base}/commit/${sha}`}>
849 {firstLine(message)}
850 </a>
851 <div class="meta">
852 <b>{author.name}</b> committed <Time at={new Date(author.timestamp * 1000).toISOString()} />
853 {signed ? <span class="badge green" style="margin-left:6px">signed</span> : null}
854 </div>
855 </div>
856 {extra}
857 <span class="btngroup">
858 <CopyButton text={sha} icon />
859 <a class="btn sm mono" href={`${base}/commit/${sha}`} title="View commit">
860 {shortSha(sha)}
861 </a>
862 <a class="btn sm icon" href={`${base}/tree/${sha}`} title="Browse the repository at this point" aria-label="Browse the repository at this point">
863 {raw(ICONS.code)}
864 </a>
865 </span>
866 </li>
867);
868
869/** Look up agents' owners for "on behalf of" labels. */
870export async function onBehalfOf(c: AppContext, actors: Array<{ login: string; kind: string }>): Promise<Map<string, string>> {
871 const out = new Map<string, string>();
872 const dir = c.get("dir");
873 const logins = [...new Set(actors.filter((a) => a.kind === "agent").map((a) => a.login))].slice(0, 20);
874 await Promise.all(
875 logins.map(async (login) => {
876 const a = await dir.accountByLogin(login);
877 if (a?.owner_id) {
878 const owner = await dir.account(a.owner_id);
879 if (owner) out.set(login, owner.login);
880 }
881 }),
882 );
883 return out;
884}
885
886export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const;
887
888/** "write" → "Write", for showing a stored value as a label. */
889export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
890
891/** The browser's check for usernames, organization and agent names; the server's isValidLogin agrees (no dots). */
892export const LOGIN_PATTERN = "[A-Za-z0-9][A-Za-z0-9_\\-]{0,99}";