| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 5 | import type { Account } from "../lib/directory.js"; |
| 6 | import { loadRepo } from "../http/common.js"; |
| 7 | import { ImportService, type Candidate, type ImportRow, type ImportSummary, type StepState, type StoredRequest } from "../imports/service.js"; |
| 8 | import { webHostOf } from "../imports/options.js"; |
| 9 | import { HttpError } from "../lib/util.js"; |
| 10 | import { SettingsNav } from "./account.js"; |
| 11 | import { csrfToken } from "./csrf.js"; |
| 12 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 13 | import { 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 | |
| 22 | const 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 | |
| 31 | const 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 | |
| 34 | const 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 | |
| 131 | const 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 | |
| 184 | const 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 | |
| 209 | const 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 | |
| 278 | async 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 | |
| 284 | export 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 | } |