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.

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