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.

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