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.

644 lines · 31.6 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import { requireAccount, type AppContext } from "../auth/principal.js";
5import type { Account } from "../lib/directory.js";
6import { loadRepo } from "../http/common.js";
7import { ImportService, type Candidate, type ImportRow, type ImportSummary, type StepState, type StoredRequest } from "../imports/service.js";
8import { webHostOf } from "../imports/options.js";
9import { HttpError } from "../lib/util.js";
10import { SettingsNav } from "./account.js";
11import { csrfToken } from "./csrf.js";
12import { KCheckbox, KRadio, KSelect } from "./kumo.js";
13import { Actor, Form, Status, Time, act, fmtBytes, flash, post, prm, qs, render, repoUrl, type WebApp } from "./ui.js";
14
15/**
16 * Import from GitHub (Kumo controls throughout): the start form (one repository,
17 * or many from an organization or user), the repository picker for bulk imports,
18 * auto-refreshing progress pages, the list of past imports, the page for setting
19 * secret values afterwards, and the linked-accounts settings page.
20 */
21
22const OPTIONS: Array<[keyof StoredRequest["options"], string, string]> = [
23 ["issues", "Issues and pull requests", "With comments, reviews, labels and milestones, keeping their numbers."],
24 ["labels", "Labels and milestones", "Even without issues."],
25 ["protection", "Branch protection", "Mapped to Diffinery's rules; required checks are matched to this repository's workflows."],
26 ["variables", "Actions variables and secret names", "Variable values and the names of secrets (never their values)."],
27 ["releases", "Releases", "Release notes and their assets. The tags come with the Git data either way."],
28 ["lfs", "Git LFS objects", "Large files tracked with Git LFS."],
29];
30
31const statusBadge = (s: string) =>
32 s === "succeeded" ? <span class="badge green">succeeded</span> : s === "failed" ? <span class="badge red">failed</span> : s === "cancelled" ? <span class="badge">cancelled</span> : s === "running" ? <span class="badge amber">running</span> : s === "draft" ? <span class="badge">draft</span> : <span class="badge">{s}</span>;
33
34const StartForm: FC<{ t: string; owners: string[]; values: Record<string, string>; error?: string }> = ({ t, owners, values, error }) => (
35 <div style="max-width:760px;margin:0 auto">
36 <h1>Import from GitHub</h1>
37 <p class="muted">Copy a repository — or every repository of an organization or user — from GitHub.com or GitHub Enterprise, keeping its history, issues, pull requests, reviews, labels and more.</p>
38 {error ? <div class="flash error">{error}</div> : null}
39 <Form action="/new/import" t={t} class="box">
40 <div class="body">
41 <div class="field">
42 <label>What to import</label>
43 <KRadio
44 name="mode"
45 label="What to import"
46 value={values.mode === "owner" ? "owner" : "repo"}
47 items={[
48 { value: "repo", label: "One repository", description: "owner/name, or a GitHub URL." },
49 { value: "owner", label: "A whole organization or user", description: "Choose which of its repositories to import on the next step." },
50 ]}
51 />
52 </div>
53 <div class="field">
54 <label for="source">GitHub repository, or organization/user</label>
55 <input id="source" name="source" type="text" value={values.source} placeholder="diffy/hello-world or diffy" autofocus autocomplete="off" required />
56 <p class="muted small m0">Example: a URL like https://github.com/diffy/hello-world, or just diffy/hello-world. For a whole account, its name.</p>
57 </div>
58 <div class="row" style="align-items:flex-end;gap:10px">
59 <div class="field" style="min-width:200px">
60 <label for="owner">Import into</label>
61 <KSelect id="owner" name="owner" block label="Import into" value={values.owner && owners.includes(values.owner) ? values.owner : (owners[0] ?? "")} options={owners.map((o) => ({ value: o, label: o }))} />
62 </div>
63 <div class="field grow">
64 <label for="name">
65 New repository name <span class="faint">(one repository only; defaults to the GitHub name)</span>
66 </label>
67 <input id="name" name="name" type="text" value={values.name} autocomplete="off" placeholder="hello-world" />
68 </div>
69 </div>
70 <div class="field">
71 <label>Visibility</label>
72 <KRadio
73 name="visibility"
74 label="Visibility"
75 value={values.visibility || "keep"}
76 orientation="horizontal"
77 items={[
78 { value: "keep", label: "Keep" },
79 { value: "private", label: "Private" },
80 { value: "public", label: "Public" },
81 ]}
82 />
83 </div>
84 <div class="field">
85 <label for="github_token">
86 GitHub token <span class="faint">(required for private repositories, LFS, variables and protection)</span>
87 </label>
88 <input id="github_token" name="github_token" type="password" autocomplete="off" placeholder="ghp_… or a fine-grained token" value={values.github_token} />
89 <p class="muted small m0">A read token (classic `repo`, or a fine-grained token with Contents, Issues, Pull requests, Metadata). Used only for this import, encrypted at rest, and deleted when the import finishes.</p>
90 </div>
91 <details>
92 <summary class="small" style="cursor:pointer;color:var(--accent)">
93 What to copy, and GitHub Enterprise
94 </summary>
95 <div class="kstack mt" style="margin-top:8px">
96 {OPTIONS.map(([k, label, desc]) => (
97 <KCheckbox name={`opt_${k}`} checked={values[`opt_${k}`] !== "0"} label={<span><b>{label}</b> <span class="muted small">{desc}</span></span>} />
98 ))}
99 </div>
100 <div class="field mt" style="margin-top:12px">
101 <label>Secrets found in old history (push protection)</label>
102 <KRadio
103 name="secrets"
104 label="Secrets in history"
105 value={values.secrets || "bypass"}
106 items={[
107 { value: "bypass", label: "Import with an audited bypass", description: "The history is already on GitHub. A human admin's bypass is recorded, and the summary lists what to rotate." },
108 { value: "stop", label: "Stop the import", description: "Fail if push protection finds a secret, so you can scrub the history first." },
109 ]}
110 />
111 </div>
112 <div class="field mt" style="margin-top:12px">
113 <label for="github_api_url">
114 GitHub Enterprise API URL <span class="faint">(optional)</span>
115 </label>
116 <input id="github_api_url" name="github_api_url" type="text" autocomplete="off" placeholder="https://ghe.example.com/api/v3" value={values.github_api_url} />
117 </div>
118 </details>
119 <div class="field">
120 <label for="concurrency">
121 Repositories at a time <span class="faint">(organization or user imports)</span>
122 </label>
123 <input id="concurrency" name="concurrency" type="number" min="1" max="8" value={values.concurrency || "3"} style="max-width:100px" />
124 </div>
125 <button class="btn primary">Start import</button>
126 </div>
127 </Form>
128 </div>
129);
130
131const SelectForm: FC<{ t: string; row: ImportRow; candidates: Candidate[]; filter: string; selected: Set<string> }> = ({ t, row, candidates, filter, selected }) => {
132 const shown = filter ? candidates.filter((c) => c.name.toLowerCase().includes(filter.toLowerCase())) : candidates;
133 return (
134 <div style="max-width:760px;margin:0 auto">
135 <h1>Choose repositories</h1>
136 <p class="muted">
137 {candidates.length} repositories in <b>{row.source}</b>. Tick the ones to import.
138 </p>
139 <Form action={`/imports/${row.id}/select`} t={t} class="box">
140 <div class="body">
141 <div class="row" style="gap:8px">
142 <input type="text" name="filter" value={filter} placeholder="Filter by name…" autocomplete="off" class="grow" />
143 <button class="btn" name="do" value="filter">
144 Filter
145 </button>
146 <button class="btn" name="do" value="all">
147 Select all{filter ? " shown" : ""}
148 </button>
149 <button class="btn" name="do" value="none">
150 Clear
151 </button>
152 </div>
153 <div class="kstack mt" style="margin-top:10px;max-height:420px;overflow:auto">
154 {shown.length ? (
155 shown.map((cand) => (
156 <KCheckbox
157 name="repositories"
158 value={cand.name}
159 checked={selected.has(cand.name)}
160 label={
161 <span>
162 <b>{cand.name}</b> {cand.private ? <span class="badge">private</span> : null} {cand.fork ? <span class="badge">fork</span> : null} {cand.archived ? <span class="badge amber">archived</span> : null}
163 {cand.description ? <span class="muted small"> — {cand.description}</span> : null}
164 </span>
165 }
166 />
167 ))
168 ) : (
169 <p class="muted small">No repositories match "{filter}".</p>
170 )}
171 </div>
172 {/* Keep selections that are filtered out of view. */}
173 {filter ? candidates.filter((c) => selected.has(c.name) && !shown.includes(c)).map((c) => <input type="hidden" name="repositories" value={c.name} />) : null}
174 <p class="muted small">{selected.size} selected.</p>
175 <button class="btn primary" name="do" value="start">
176 Import {selected.size || ""} {selected.size === 1 ? "repository" : "repositories"}
177 </button>
178 </div>
179 </Form>
180 </div>
181 );
182};
183
184const Steps: FC<{ row: ImportRow }> = ({ row }) => {
185 const state = JSON.parse(row.steps || "{}") as Record<string, StepState>;
186 const list = (row.kind === "bulk"
187 ? [["organization", "Organization variables and secret names"], ["repositories", "Repositories"], ["finalize", "Finish"]]
188 : [["repository", "Create the repository"], ["git", "Copy branches, tags and history"], ["labels", "Labels and milestones"], ["issues", "Issues and pull requests"], ["comments", "Comments"], ["reviews", "Reviews"], ["review_comments", "Review comments"], ["protection", "Branch protection"], ["variables", "Variables and secret names"], ["releases", "Releases"], ["finalize", "Finish"]]) as Array<[string, string]>;
189 return (
190 <ul class="list">
191 {list.map(([name, title]) => {
192 const s = state[name] ?? { status: "pending" };
193 const st = s.status === "done" ? { conclusion: "success" } : s.status === "failed" ? { conclusion: "failure" } : s.status === "skipped" ? { conclusion: "skipped" } : s.status === "running" ? { status: "in_progress" } : { status: "queued" };
194 return (
195 <li>
196 <Status {...st} />
197 <div class="grow">
198 <b>{title}</b>
199 {s.message ? <div class="meta">{s.message}</div> : null}
200 </div>
201 {typeof s.count === "number" && s.count > 0 ? <span class="muted small">{s.count}</span> : null}
202 </li>
203 );
204 })}
205 </ul>
206 );
207};
208
209const Summary: FC<{ row: ImportRow; secretsHref?: string }> = ({ row, secretsHref }) => {
210 const s = JSON.parse(row.summary || "{}") as ImportSummary;
211 const warnings = JSON.parse(row.warnings || "[]") as string[];
212 return (
213 <>
214 {s.counts ? (
215 <div class="box mt">
216 <div class="body">
217 <h3 class="m0">Imported</h3>
218 <p class="muted small">
219 {s.counts.issues} issues, {s.counts.pulls} pull requests, {s.counts.comments} comments, {s.counts.reviews} reviews, {s.counts.labels} labels, {s.counts.milestones} milestones.
220 {s.git ? ` ${s.git.branches} branches, ${s.git.tags} tags, ${s.git.pullRefs} pull request heads${s.git.lfsObjects ? `, ${s.git.lfsObjects} LFS objects (${fmtBytes(s.git.lfsBytes)})` : ""}.` : ""}
221 </p>
222 {s.pullRefs?.missing?.length ? <p class="muted small">{s.pullRefs.missing.length} pull requests had no head commit on GitHub (deleted forks).</p> : null}
223 </div>
224 </div>
225 ) : null}
226 {s.children ? (
227 <div class="box mt">
228 <div class="body">
229 <h3 class="m0">Repositories</h3>
230 <p class="muted small">
231 {s.children.succeeded} of {s.children.total} imported{s.children.failed ? `, ${s.children.failed} failed` : ""}{s.children.cancelled ? `, ${s.children.cancelled} cancelled` : ""}.
232 </p>
233 </div>
234 </div>
235 ) : null}
236 {s.secretNames?.length || s.orgSecretNames?.length ? (
237 <div class="box mt">
238 <div class="body">
239 <h3 class="m0">Secret names</h3>
240 <p class="muted small">GitHub never returns secret values, so only the names came across. An admin sets the values.</p>
241 {s.secretNames?.length ? <p class="small m0">Repository: {s.secretNames.map((n) => <code style="margin-right:6px">{n}</code>)}</p> : null}
242 {s.orgSecretNames?.length ? <p class="small">Organization: {s.orgSecretNames.map((n) => <code style="margin-right:6px">{n}</code>)}</p> : null}
243 {secretsHref ? <a class="btn sm" href={secretsHref}>Set secret values</a> : null}
244 </div>
245 </div>
246 ) : null}
247 {s.findings?.length ? (
248 <div class="box mt" style="border-color:var(--amber)">
249 <div class="body">
250 <h3 class="m0">Possible secrets in history (rotate these)</h3>
251 <p class="muted small">Push protection found these in old commits; they were imported with an audited bypass because the history is already public on GitHub.</p>
252 <ul class="small">
253 {s.findings.slice(0, 50).map((f) => (
254 <li>
255 {f.kind} {f.path ? <span class="muted">in {f.path}</span> : null} <code>{f.blob.slice(0, 10)}</code>
256 </li>
257 ))}
258 </ul>
259 </div>
260 </div>
261 ) : null}
262 {warnings.length ? (
263 <div class="box mt">
264 <div class="body">
265 <h3 class="m0">Warnings</h3>
266 <ul class="small">
267 {warnings.map((w) => (
268 <li>{w}</li>
269 ))}
270 </ul>
271 </div>
272 </div>
273 ) : null}
274 </>
275 );
276};
277
278async function loadRow(c: AppContext, svc: ImportService, me: Account): Promise<ImportRow> {
279 const row = await svc.row(prm(c, "id"));
280 if (!row || !(await svc.visibleTo(row, me))) throw new HttpError(404, "import not found");
281 return row;
282}
283
284export function registerImports(web: WebApp): void {
285 // ---------------------------------------------------------------- start
286
287 web.get("/new/import", async (c) => {
288 const me = requireAccount(c);
289 const owners = [me.login, ...(await c.get("dir").orgsFor(me.id)).filter((o) => o.role !== "member").map((o) => o.login)];
290 const values = { owner: c.req.query("owner") ?? me.login, mode: c.req.query("mode") === "owner" ? "owner" : "repo", source: c.req.query("source") ?? "" };
291 return render(c, { title: "Import from GitHub" }, <StartForm t={csrfToken(c)} owners={owners} values={values} />);
292 });
293
294 post(web, "/new/import", async (c, f) => {
295 const me = requireAccount(c);
296 const svc = new ImportService(c.env);
297 const mode = f.s("mode") === "owner" ? "owner" : "repo";
298 const options = Object.fromEntries(OPTIONS.map(([k]) => [k, f.b(`opt_${k}`)])) as Record<string, boolean>;
299 const source = f.s("source").trim();
300 const body = {
301 ...(mode === "owner" ? { source_owner: source } : { source }),
302 owner: f.s("owner"),
303 name: f.s("name").trim() || undefined,
304 visibility: f.s("visibility") || "keep",
305 github_token: f.s("github_token").trim() || undefined,
306 github_api_url: f.s("github_api_url").trim() || undefined,
307 concurrency: Number(f.s("concurrency")) || 3,
308 options: { issues: options.issues, labels: options.labels, branch_protection: options.protection, variables: options.variables, releases: options.releases, lfs: options.lfs, secrets: f.s("secrets") === "stop" ? "stop" : "bypass" },
309 };
310 try {
311 const scopes = (c.get("principal") as { scopes?: Array<"read" | "write" | "admin"> }).scopes ?? ["admin"];
312 const row = await svc.create(me, scopes, body, { draft: mode === "owner" });
313 return c.redirect(row.status === "draft" ? `/imports/${row.id}/select` : `/imports/${row.id}`, 303);
314 } catch (e) {
315 if (!(e instanceof HttpError) || e.status >= 500) throw e;
316 const owners = [me.login, ...(await c.get("dir").orgsFor(me.id)).filter((o) => o.role !== "member").map((o) => o.login)];
317 const values = { ...body, source, mode, ...Object.fromEntries(OPTIONS.map(([k]) => [`opt_${k}`, options[k] ? "1" : "0"])), secrets: body.options.secrets } as unknown as Record<string, string>;
318 return render(c, { title: "Import from GitHub", status: e.status }, <StartForm t={csrfToken(c)} owners={owners} values={values} error={e.detail} />);
319 }
320 });
321
322 // ---------------------------------------------------------------- bulk: choose repositories
323
324 web.get("/imports/:id/select", async (c) => {
325 const me = requireAccount(c);
326 const svc = new ImportService(c.env);
327 const row = await loadRow(c, svc, me);
328 if (row.kind !== "bulk" || row.status !== "draft") return c.redirect(`/imports/${row.id}`, 302);
329 const req = JSON.parse(row.request || "{}") as StoredRequest;
330 const candidates = req.candidates ?? [];
331 return render(c, { title: "Choose repositories" }, <SelectForm t={csrfToken(c)} row={row} candidates={candidates} filter="" selected={new Set(candidates.filter((c) => !c.fork && !c.archived).map((c) => c.name))} />);
332 });
333
334 post(web, "/imports/:id/select", async (c, f) => {
335 const me = requireAccount(c);
336 const svc = new ImportService(c.env);
337 const row = await loadRow(c, svc, me);
338 if (row.kind !== "bulk" || row.status !== "draft") return c.redirect(`/imports/${row.id}`, 303);
339 const candidates = (JSON.parse(row.request || "{}") as StoredRequest).candidates ?? [];
340 const selected = new Set(f.all("repositories"));
341 const action = f.s("do");
342 if (action === "start") {
343 try {
344 await svc.select(row.id, me, [...selected]);
345 return c.redirect(`/imports/${row.id}`, 303);
346 } catch (e) {
347 if (!(e instanceof HttpError) || e.status >= 500) throw e;
348 flash(c, "error", e.detail);
349 }
350 }
351 const filter = f.s("filter").trim();
352 if (action === "all") for (const cand of candidates) if (!filter || cand.name.toLowerCase().includes(filter.toLowerCase())) selected.add(cand.name);
353 if (action === "none") selected.clear();
354 return render(c, { title: "Choose repositories" }, <SelectForm t={csrfToken(c)} row={row} candidates={candidates} filter={filter} selected={selected} />);
355 });
356
357 // ---------------------------------------------------------------- list and detail
358
359 web.get("/imports", async (c) => {
360 const me = requireAccount(c);
361 const svc = new ImportService(c.env);
362 const rows = await svc.list(me.id);
363 return render(
364 c,
365 { title: "Imports" },
366 <div style="max-width:860px;margin:0 auto">
367 <div class="row between mb">
368 <h1 class="m0">Imports</h1>
369 <a class="btn primary sm" href="/new/import">
370 Import from GitHub
371 </a>
372 </div>
373 {rows.length ? (
374 <ul class="list box">
375 {rows.map((r) => (
376 <li>
377 <div class="grow">
378 <a class="title" href={`/imports/${r.id}`}>
379 {r.source}
380 </a>{" "}
381 {statusBadge(r.status)} <span class="muted small">{r.kind === "bulk" ? "organization/user" : "repository"}</span>
382 <div class="meta">Started <Time at={r.created_at} /></div>
383 </div>
384 </li>
385 ))}
386 </ul>
387 ) : (
388 <div class="empty">No imports yet. <a href="/new/import">Import a repository from GitHub</a>.</div>
389 )}
390 </div>,
391 );
392 });
393
394 web.get("/imports/:id", async (c) => {
395 const me = requireAccount(c);
396 const svc = new ImportService(c.env);
397 const row = await loadRow(c, svc, me);
398 if (row.status === "draft" && row.kind === "bulk") return c.redirect(`/imports/${row.id}/select`, 302);
399 const s = JSON.parse(row.summary || "{}") as ImportSummary;
400 const children = row.kind === "bulk" ? await svc.children(row.id) : [];
401 const owner = (await c.get("dir").account(row.target_owner_id))?.login ?? "";
402 const target = row.repo_id ? `${owner}/${row.target_name}` : null;
403 const running = row.status === "running" || row.status === "queued";
404 const canSecrets = !!row.repo_id && (s.secretNames?.length || s.orgSecretNames?.length);
405 const t = csrfToken(c);
406 return render(
407 c,
408 { title: `Import of ${row.source}`, refresh: running ? 4 : undefined },
409 <div style="max-width:860px;margin:0 auto">
410 <div class="row between mb">
411 <h1 class="m0" style="word-break:break-all">
412 {row.source}
413 </h1>
414 {statusBadge(row.status)}
415 </div>
416 <p class="muted">
417 {row.kind === "bulk" ? "Organization or user import" : "Repository import"} into <b>{owner}</b>
418 {target ? <> → <a href={`/${target}`}>{target}</a></> : null}. Started <Time at={row.created_at} />.
419 {row.source.includes("/") ? <> From <a href={`https://${webHostOf(row.api_url)}/${row.source}`} rel="noreferrer noopener">GitHub</a>.</> : null}
420 </p>
421 {row.error ? <div class="flash error">{row.error}</div> : null}
422 {running ? <p class="muted small">This page refreshes automatically.</p> : null}
423 <div class="box">
424 <div class="body">
425 <Steps row={row} />
426 </div>
427 </div>
428 {children.length ? (
429 <div class="box mt">
430 <div class="body">
431 <h3 class="m0">Repositories</h3>
432 <ul class="list">
433 {children.map((ch) => (
434 <li>
435 <div class="grow">
436 <a class="title" href={`/imports/${ch.id}`}>
437 {ch.source}
438 </a>{" "}
439 {statusBadge(ch.status)}
440 {ch.error ? <div class="meta">{ch.error}</div> : null}
441 </div>
442 {ch.repo_id ? <a class="btn sm" href={`/${owner}/${ch.target_name}`}>Open</a> : null}
443 </li>
444 ))}
445 </ul>
446 </div>
447 </div>
448 ) : null}
449 <Summary row={row} secretsHref={canSecrets ? `/imports/${row.id}/secrets` : undefined} />
450 <div class="row mt" style="gap:8px">
451 {running ? (
452 <Form action={`/imports/${row.id}/cancel`} t={t} confirm="Cancel this import?">
453 <button class="btn">Cancel import</button>
454 </Form>
455 ) : null}
456 {(row.status === "failed" || row.status === "cancelled") && !row.parent_id ? (
457 <Form action={`/imports/${row.id}/retry`} t={t} class="box">
458 <div class="body">
459 <h3 class="m0">Retry this import</h3>
460 <p class="muted small m0">The GitHub token from the first run was discarded. For a private source (or LFS, variables or protection), give a token again; leave it blank for a public repository.</p>
461 <div class="field">
462 <label for="retry_token">GitHub token <span class="faint">(optional)</span></label>
463 <input id="retry_token" name="github_token" type="password" autocomplete="off" placeholder="ghp_… or a fine-grained token" />
464 </div>
465 <button class="btn primary">Retry</button>
466 </div>
467 </Form>
468 ) : null}
469 </div>
470 </div>,
471 );
472 });
473
474 post(web, "/imports/:id/cancel", async (c) => {
475 const me = requireAccount(c);
476 const svc = new ImportService(c.env);
477 const row = await loadRow(c, svc, me);
478 return act(c, `/imports/${row.id}`, () => svc.cancel(row.id, me).then(() => undefined), "Import cancelled.");
479 });
480
481 post(web, "/imports/:id/retry", async (c, f) => {
482 const me = requireAccount(c);
483 const svc = new ImportService(c.env);
484 const row = await loadRow(c, svc, me);
485 const token = f.s("github_token").trim() || null;
486 return act(c, `/imports/${row.id}`, () => svc.retry(row.id, me, token).then(() => undefined), "Import restarted.");
487 });
488
489 // ---------------------------------------------------------------- set secret values afterwards
490
491 web.get("/imports/:id/secrets", async (c) => {
492 const me = requireAccount(c);
493 const svc = new ImportService(c.env);
494 const row = await loadRow(c, svc, me);
495 if (!row.repo_id) throw new HttpError(404, "no repository yet");
496 const repo = (await c.get("dir").repoById(row.repo_id))!;
497 const { caller } = await loadRepo(c, repo.owner_login, repo.name, "admin");
498 if (caller.kind === "agent") throw new HttpError(403, "agents cannot set secrets");
499 const names = (JSON.parse(row.summary || "{}") as ImportSummary).secretNames ?? [];
500 const existing = new Set((await loadRepo(c, repo.owner_login, repo.name, "admin").then((l) => l.stub.secretNames())).map((s) => s.name));
501 return render(
502 c,
503 { title: "Set secret values" },
504 <div style="max-width:720px;margin:0 auto">
505 <h1>Secret values</h1>
506 <p class="muted">
507 GitHub never reveals secret values, so only the names were imported for <a href={repoUrl(repo)}>{repo.owner_login}/{repo.name}</a>. Set the values here; they are encrypted at rest and never shown again.
508 </p>
509 {names.length ? (
510 names.map((name) => (
511 <Form action={`/${repo.owner_login}/${repo.name}/settings/secrets`} t={csrfToken(c)} class="box mb">
512 <div class="body row" style="align-items:flex-end;gap:10px">
513 <div class="field grow">
514 <label>
515 {name} {existing.has(name) ? <span class="badge green">set</span> : <span class="badge amber">empty</span>}
516 </label>
517 <input type="hidden" name="name" value={name} />
518 <input name="value" type="password" autocomplete="off" placeholder="Value" required />
519 </div>
520 <button class="btn primary">Save</button>
521 </div>
522 </Form>
523 ))
524 ) : (
525 <div class="empty">No secret names were imported.</div>
526 )}
527 <p class="muted small">
528 You can manage all secrets under the repository's <a href={`/${repo.owner_login}/${repo.name}/settings/secrets`}>settings</a>.
529 </p>
530 </div>,
531 );
532 });
533
534 // ---------------------------------------------------------------- linked GitHub accounts
535
536 const renderLinked = async (c: AppContext, challenge: { nonce: string; host: string; apiUrl: string } | null) => {
537 const me = requireAccount(c);
538 const svc = new ImportService(c.env);
539 const links = await svc.links(me.id);
540 const t = csrfToken(c);
541 return render(
542 c,
543 { title: "Linked accounts" },
544 <div class="grid left">
545 <SettingsNav me={me} tab="linked" />
546 <div style="max-width:640px">
547 <h1>Linked GitHub accounts</h1>
548 <p class="muted">When you import from GitHub, work by a linked GitHub account is attributed to your Diffinery account. Prove you control the account by putting a one-time code in a public gist; no token is needed.</p>
549 {links.length ? (
550 <ul class="list box mb">
551 {links.map((l) => (
552 <li>
553 <div class="grow">
554 <b>{l.login}</b> <span class="muted small">on {l.host}</span>
555 <div class="meta">Linked <Time at={l.createdAt} /></div>
556 </div>
557 <Form action="/settings/linked-accounts/delete" t={t} confirm={`Unlink ${l.login}?`}>
558 <input type="hidden" name="id" value={l.id} />
559 <button class="btn sm">Unlink</button>
560 </Form>
561 </li>
562 ))}
563 </ul>
564 ) : null}
565 {svc.oauthEnabled ? (
566 <Form action="/settings/linked-accounts/github/oauth" t={t} class="box mb">
567 <div class="body">
568 <h3 class="m0">Link with GitHub</h3>
569 <p class="muted small">Sign in to GitHub and authorise read-only access to your profile.</p>
570 <button class="btn primary">Continue to GitHub</button>
571 </div>
572 </Form>
573 ) : null}
574 {challenge ? (
575 <div class="box mb">
576 <div class="body">
577 <h3 class="m0">Step 2 · verify your gist</h3>
578 <p class="muted small">Create a <b>public</b> gist on {challenge.host} whose description or a file contains this code, then enter your GitHub username and verify:</p>
579 <pre class="code" style="user-select:all">{challenge.nonce}</pre>
580 <Form action="/settings/linked-accounts/github" t={t}>
581 <input type="hidden" name="api_url" value={challenge.apiUrl} />
582 <div class="field">
583 <label for="llogin">Your GitHub username on {challenge.host}</label>
584 <input id="llogin" name="login" type="text" autocomplete="off" placeholder="diffy" required />
585 </div>
586 <button class="btn primary">Verify and link</button>
587 </Form>
588 </div>
589 </div>
590 ) : null}
591 <Form action="/settings/linked-accounts/github/challenge" t={t} class="box">
592 <div class="body">
593 <h3 class="m0">Step 1 · get a code</h3>
594 <div class="field">
595 <label for="lapi">
596 GitHub Enterprise API URL <span class="faint">(leave blank for github.com)</span>
597 </label>
598 <input id="lapi" name="api_url" type="text" autocomplete="off" placeholder="https://ghe.example.com/api/v3" value={challenge?.apiUrl ?? ""} />
599 </div>
600 <button class="btn">Get my code</button>
601 </div>
602 </Form>
603 </div>
604 </div>,
605 );
606 };
607
608 web.get("/settings/linked-accounts", async (c) => renderLinked(c, null));
609
610 post(web, "/settings/linked-accounts/github/challenge", async (c, f) => {
611 const me = requireAccount(c);
612 const svc = new ImportService(c.env);
613 const ch = await svc.linkChallenge(me, f.s("api_url").trim() || null);
614 return renderLinked(c, { nonce: ch.nonce, host: ch.host, apiUrl: f.s("api_url").trim() });
615 });
616
617 post(web, "/settings/linked-accounts/github", async (c, f) => {
618 const me = requireAccount(c);
619 const svc = new ImportService(c.env);
620 return act(c, "/settings/linked-accounts", async () => {
621 await svc.linkByGist(me, f.s("login").trim(), f.s("api_url").trim() || null);
622 }, "GitHub account linked.");
623 });
624
625 post(web, "/settings/linked-accounts/github/oauth", async (c) => {
626 const me = requireAccount(c);
627 const svc = new ImportService(c.env);
628 return c.redirect(await svc.oauthStart(me));
629 });
630
631 web.get("/settings/linked/github/callback", async (c) => {
632 const me = requireAccount(c);
633 const svc = new ImportService(c.env);
634 return act(c, "/settings/linked-accounts", async () => {
635 await svc.oauthCallback(me, c.req.query("code") ?? "", c.req.query("state") ?? "");
636 }, "GitHub account linked.");
637 });
638
639 post(web, "/settings/linked-accounts/delete", async (c, f) => {
640 const me = requireAccount(c);
641 const svc = new ImportService(c.env);
642 return act(c, "/settings/linked-accounts", () => svc.unlink(me, f.s("id")), "Account unlinked.");
643 });
644}