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.

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