| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { refreshRepoLimit } from "../agentic/org-policy.js"; |
| 3 | import { raw } from "hono/html"; |
| 4 | import type { Child, FC } from "hono/jsx"; |
| 5 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 6 | import { loadRepo, WHILE_IMPORTING } from "../http/common.js"; |
| 7 | import { normalizeHomepage, normalizeTopics, topicsOf } from "../http/gh.js"; |
| 8 | import { HttpError } from "../lib/util.js"; |
| 9 | import { MAX_REVIEWERS, MAX_WAIT_TIMER, type Environment } from "../repo/deployments.js"; |
| 10 | import { resolveReviewers } from "../http/environment-reviews.js"; |
| 11 | import { parseCheckList } from "../repo/required.js"; |
| 12 | import { atLeast, type ProtectionRules, type ProtectionRulesInput, type Role } from "../repo/types.js"; |
| 13 | import { ICONS } from "./assets.js"; |
| 14 | import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js"; |
| 15 | import { csrfToken, type FormData } from "./csrf.js"; |
| 16 | import { ActionsBlockedBanner } from "./kill-switches.js"; |
| 17 | import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; |
| 18 | |
| 19 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 20 | export type SettingsTab = "general" | "access" | "branches" | "merge-on-green" | "actions" | "environments" | "secrets" | "webhooks" | "agent-review" | "project" | "agent-setup"; |
| 21 | type Tab = SettingsTab; |
| 22 | |
| 23 | const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; |
| 24 | |
| 25 | const load = (c: AppContext, opts: { whileArchived?: boolean; whileImporting?: boolean } = {}) => loadRepo(c, prm(c, "owner"), prm(c, "repo"), "admin", opts); |
| 26 | |
| 27 | function audit(c: AppContext, l: Loaded, action: string, detail?: Record<string, unknown>): void { |
| 28 | const p = c.get("principal"); |
| 29 | const actor = p.type === "account" ? p.account : null; |
| 30 | c.executionCtx.waitUntil( |
| 31 | c |
| 32 | .get("dir") |
| 33 | .audit({ actorId: actor?.id, actorLogin: actor?.login, onBehalfOf: l.caller.onBehalfOf, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail }) |
| 34 | .catch((e) => console.error("audit failed", e)), |
| 35 | ); |
| 36 | } |
| 37 | |
| 38 | export function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number, script?: string) { |
| 39 | const base = `${repoUrl(l.repo)}/settings`; |
| 40 | const all: Array<[Tab, string, string]> = [ |
| 41 | ["general", "General", base], |
| 42 | ["access", "Collaborators", `${base}/access`], |
| 43 | ["branches", "Branch protection", `${base}/branches`], |
| 44 | ["merge-on-green", "Merge on green", `${base}/merge-on-green`], |
| 45 | ["actions", "Actions", `${base}/actions`], |
| 46 | ["environments", "Environments", `${base}/environments`], |
| 47 | ["secrets", "Secrets and variables", `${base}/secrets`], |
| 48 | ["webhooks", "Webhooks", `${base}/webhooks`], |
| 49 | ["agent-review", "Agent review", `${base}/agent-review`], |
| 50 | ["project", "Project", `${base}/project`], |
| 51 | ["agent-setup", "Agent setup", `${base}/agent-setup`], |
| 52 | ]; |
| 53 | // People with write access but not admin reach only Agent setup (web/agent-setup.tsx). |
| 54 | const nav = atLeast(l.caller.role, "admin") ? all : all.filter(([k]) => k === "agent-setup"); |
| 55 | return render( |
| 56 | c, |
| 57 | { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status, script }, |
| 58 | <div class="grid left"> |
| 59 | <aside class="side"> |
| 60 | <section> |
| 61 | <div class="nav"> |
| 62 | {nav.map(([k, label, href]) => ( |
| 63 | <a href={href} class={tab === k ? "on" : ""}> |
| 64 | {label} |
| 65 | </a> |
| 66 | ))} |
| 67 | </div> |
| 68 | </section> |
| 69 | </aside> |
| 70 | <div> |
| 71 | <ActionsBlockedBanner env={c.env} repoId={l.repo.id} /> |
| 72 | {body} |
| 73 | </div> |
| 74 | </div>, |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | const Once: FC<{ title: string; value: string; note: string }> = ({ title, value, note }) => ( |
| 79 | <div class="box" style="border-color:var(--amber)"> |
| 80 | <div class="body"> |
| 81 | <h3>{title}</h3> |
| 82 | <p class="muted">{note}</p> |
| 83 | <div class="row"> |
| 84 | <div class="secret grow">{value}</div> |
| 85 | <CopyButton text={value} size="base" /> |
| 86 | </div> |
| 87 | </div> |
| 88 | </div> |
| 89 | ); |
| 90 | |
| 91 | // ---------------------------------------------------------------- protection form |
| 92 | |
| 93 | type FlagGroup = "reviews" | "history" | "agents"; |
| 94 | |
| 95 | const RULE_FLAGS: Array<[keyof ProtectionRules & keyof ProtectionRulesInput, FlagGroup, string, string]> = [ |
| 96 | ["requirePullRequest", "reviews", "Require a pull request before merging", "Direct pushes are rejected; changes land through reviewed pull requests."], |
| 97 | ["dismissStaleReviews", "reviews", "Dismiss stale approvals when new commits are pushed", ""], |
| 98 | ["requireLinearHistory", "history", "Require linear history", "Merge commits are rejected; use squash or rebase merges."], |
| 99 | ["allowForcePush", "history", "Allow force pushes", "Off by default for protected branches."], |
| 100 | ["allowDeletion", "history", "Allow deletion", ""], |
| 101 | ["enforceAdmins", "history", "Do not allow administrators to bypass these rules", "Off: administrators can push past these rules and merge without meeting them."], |
| 102 | ["agentsMayPush", "agents", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."], |
| 103 | ["agentsMayMerge", "agents", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."], |
| 104 | [ |
| 105 | "allowAgentAutoMerge", |
| 106 | "agents", |
| 107 | "Allow agents to land pull requests through auto-merge", |
| 108 | "Agents may turn on auto-merge or add pull requests to the merge queue; Diffinery merges once every rule is met, with no bypass. Needs at least one required status check.", |
| 109 | ], |
| 110 | [ |
| 111 | "agentAutoMergeWithoutApproval", |
| 112 | "agents", |
| 113 | "Let agents' auto-merges land without an approving review", |
| 114 | "With the setting above: green required checks are enough for what Diffinery lands for agents. Requested changes still block it, and a change to a workflow file still needs a person.", |
| 115 | ], |
| 116 | ]; |
| 117 | |
| 118 | const MERGE_METHODS: KOption[] = [ |
| 119 | { value: "merge", label: "Create a merge commit" }, |
| 120 | { value: "squash", label: "Squash and merge" }, |
| 121 | { value: "rebase", label: "Rebase and merge" }, |
| 122 | ]; |
| 123 | |
| 124 | /** A titled part of a settings form; the first one in a form has no rule above it. */ |
| 125 | export const Section: FC<{ title?: string; note?: Child; children?: Child }> = ({ title, note, children }) => ( |
| 126 | <section class="fsec"> |
| 127 | {title ? <h3>{title}</h3> : null} |
| 128 | {note ? <p class="fsec-d">{note}</p> : null} |
| 129 | {children} |
| 130 | </section> |
| 131 | ); |
| 132 | |
| 133 | const Flags: FC<{ group: FlagGroup; rules: ProtectionRules }> = ({ group, rules }) => ( |
| 134 | <div class="fchecks"> |
| 135 | {RULE_FLAGS.filter(([, g]) => g === group).map(([key, , label, hint]) => ( |
| 136 | <KCheckbox name={key} checked={!!rules[key]} label={label} description={hint || undefined} /> |
| 137 | ))} |
| 138 | </div> |
| 139 | ); |
| 140 | |
| 141 | const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => { |
| 142 | // Field ids must be unique on a page that shows several rules. |
| 143 | const id = (name: string) => `${name}-${pattern ? pattern.replace(/[^A-Za-z0-9_-]/g, "_") : "new"}`; |
| 144 | return ( |
| 145 | <Form action={`${base}/settings/branches`} t={csrfToken(c)} class="body fbody"> |
| 146 | <input type="hidden" name="original" value={pattern ?? ""} /> |
| 147 | <Section> |
| 148 | <div class="field"> |
| 149 | <label for={id("pattern")}>Branch name pattern</label> |
| 150 | <input id={id("pattern")} name="pattern" type="text" required value={pattern ?? ""} placeholder="main, release/*, **" class="mono" /> |
| 151 | <div class="hint"> |
| 152 | <code>*</code> matches within a path segment, <code>**</code> across segments. |
| 153 | </div> |
| 154 | </div> |
| 155 | </Section> |
| 156 | <Section title="Pull requests and reviews"> |
| 157 | <Flags group="reviews" rules={rules} /> |
| 158 | <div class="fgrid c3"> |
| 159 | <div class="field"> |
| 160 | <label for={id("approvals")}>Required approvals</label> |
| 161 | <input id={id("approvals")} name="requiredApprovals" type="number" min={0} max={10} value={String(rules.requiredApprovals ?? 0)} /> |
| 162 | <div class="hint">From 0 to 10.</div> |
| 163 | </div> |
| 164 | </div> |
| 165 | </Section> |
| 166 | <Section title="Status checks"> |
| 167 | <div class="field"> |
| 168 | <label for={id("checks")}>Required status checks</label> |
| 169 | <textarea id={id("checks")} name="requiredChecks" class="mono" rows={3} autocomplete="off" spellcheck={false} placeholder={"CI / test\nlint"}> |
| 170 | {(rules.requiredChecks ?? []).join("\n")} |
| 171 | </textarea> |
| 172 | <div class="hint">One check name per line, exactly as it appears on pull requests. Names may contain commas.</div> |
| 173 | </div> |
| 174 | </Section> |
| 175 | <Section title="Pushes and history"> |
| 176 | <Flags group="history" rules={rules} /> |
| 177 | </Section> |
| 178 | <Section title="Agents"> |
| 179 | <Flags group="agents" rules={rules} /> |
| 180 | <div class="fgrid c3"> |
| 181 | <div class="field span2"> |
| 182 | <label for={id("agents")}>Auto-merge pull requests from these agents</label> |
| 183 | <textarea id={id("agents")} name="autoMergeAgents" class="mono" rows={2} autocomplete="off" spellcheck={false} placeholder="my-agent"> |
| 184 | {(rules.autoMergeAgents ?? []).map((a) => a.login).join("\n")} |
| 185 | </textarea> |
| 186 | <div class="hint"> |
| 187 | One agent login per line. Needs <b>Allow agents to land pull requests through auto-merge</b>. Diffinery turns auto-merge on by itself for their pull requests, so nobody has to click Merge: each lands once every rule on this page is met (an approval too, unless agents' auto-merges may land without one). A change to a workflow file still needs a person. |
| 188 | </div> |
| 189 | </div> |
| 190 | <div class="field"> |
| 191 | <label for={id("agent-method")}>Their merge method</label> |
| 192 | <KSelect id={id("agent-method")} name="autoMergeMethod" label="Their merge method" block value={rules.autoMergeMethod ?? "default"} options={[{ value: "default", label: "As the branch merges" }, ...MERGE_METHODS]} /> |
| 193 | </div> |
| 194 | </div> |
| 195 | </Section> |
| 196 | <Section title="Merge queue"> |
| 197 | <div class="fchecks"> |
| 198 | <KCheckbox |
| 199 | name="mergeQueue" |
| 200 | checked={!!rules.mergeQueue} |
| 201 | label="Require a merge queue" |
| 202 | description={ |
| 203 | <> |
| 204 | Pull requests merge in order, each tested on top of the base and the ones ahead of it. Workflows run on the queue through the <code>merge_group</code> event. |
| 205 | </> |
| 206 | } |
| 207 | /> |
| 208 | </div> |
| 209 | <div class="fgrid c3"> |
| 210 | <div class="field"> |
| 211 | <label for={id("mq-method")}>Merge method</label> |
| 212 | <KSelect id={id("mq-method")} name="mqMethod" label="Merge method" block value={rules.mergeQueue?.method ?? "merge"} options={MERGE_METHODS} /> |
| 213 | </div> |
| 214 | <div class="field"> |
| 215 | <label for={id("mq-concurrency")}>Build concurrency</label> |
| 216 | <input id={id("mq-concurrency")} name="mqConcurrency" type="number" min={1} max={20} value={String(rules.mergeQueue?.buildConcurrency ?? 5)} /> |
| 217 | <div class="hint">Builds that run at once, 1 to 20.</div> |
| 218 | </div> |
| 219 | <div class="field"> |
| 220 | <label for={id("mq-timeout")}>Check timeout (minutes)</label> |
| 221 | <input id={id("mq-timeout")} name="mqTimeout" type="number" min={1} max={360} value={String(rules.mergeQueue?.checkTimeoutMinutes ?? 60)} /> |
| 222 | <div class="hint">Longer waits for required checks fail, 1 to 360.</div> |
| 223 | </div> |
| 224 | </div> |
| 225 | </Section> |
| 226 | <div class="factions"> |
| 227 | <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button> |
| 228 | </div> |
| 229 | </Form> |
| 230 | ); |
| 231 | }; |
| 232 | |
| 233 | function rulesFrom(f: FormData): ProtectionRulesInput { |
| 234 | const rules: ProtectionRulesInput = {}; |
| 235 | for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true; |
| 236 | const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0)); |
| 237 | if (approvals) rules.requiredApprovals = approvals; |
| 238 | const checks = parseCheckList(f.s("requiredChecks")); |
| 239 | if (checks.length) rules.requiredChecks = checks; |
| 240 | const agents = f |
| 241 | .s("autoMergeAgents") |
| 242 | .split(/[\s,]+/) |
| 243 | .map((x) => x.trim().replace(/^@/, "")) |
| 244 | .filter(Boolean); |
| 245 | if (agents.length) rules.autoMergeAgents = agents; |
| 246 | const agentMethod = f.s("autoMergeMethod"); |
| 247 | if (agents.length && (agentMethod === "merge" || agentMethod === "squash" || agentMethod === "rebase")) rules.autoMergeMethod = agentMethod; |
| 248 | if (f.b("mergeQueue")) { |
| 249 | const method = f.s("mqMethod"); |
| 250 | rules.mergeQueue = { |
| 251 | method: method === "squash" || method === "rebase" ? method : "merge", |
| 252 | buildConcurrency: parseInt(f.s("mqConcurrency"), 10) || 5, |
| 253 | checkTimeoutMinutes: parseInt(f.s("mqTimeout"), 10) || 60, |
| 254 | }; |
| 255 | } |
| 256 | return rules; |
| 257 | } |
| 258 | |
| 259 | // ---------------------------------------------------------------- pages |
| 260 | |
| 261 | /** Where the signed-in person may transfer a repository: their own account and the organizations they own or administer. */ |
| 262 | async function transferTargets(c: AppContext, l: Loaded): Promise<KOption[]> { |
| 263 | const p = c.get("principal"); |
| 264 | if (p.type !== "account" || p.account.kind !== "user" || l.caller.kind !== "user") return []; |
| 265 | const me = p.account; |
| 266 | // A personal repository is transferred by its owner only. |
| 267 | if (l.repo.owner_kind !== "org" && l.repo.owner_id !== me.id && !me.site_admin) return []; |
| 268 | const orgs = (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); |
| 269 | return [{ value: me.login, label: `${me.login} (your account)` }, ...orgs.map((o) => ({ value: o.login, label: o.login }))].filter((o) => o.value.toLowerCase() !== l.repo.owner_login.toLowerCase()); |
| 270 | } |
| 271 | |
| 272 | async function generalPage(c: AppContext, l: Loaded) { |
| 273 | const { repo } = l; |
| 274 | const base = repoUrl(repo); |
| 275 | const t = csrfToken(c); |
| 276 | const [names, targets] = await Promise.all([refNames(l.stub), transferTargets(c, l)]); |
| 277 | const full = `${repo.owner_login}/${repo.name}`; |
| 278 | return page( |
| 279 | c, |
| 280 | l, |
| 281 | "general", |
| 282 | <> |
| 283 | <h1>General</h1> |
| 284 | <Form action={`${base}/settings/general`} t={t} class="box"> |
| 285 | <div class="body fbody"> |
| 286 | <div class="fgrid"> |
| 287 | <div class="field"> |
| 288 | <label for="repo-name">Repository name</label> |
| 289 | <input id="repo-name" name="name" type="text" value={repo.name} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> |
| 290 | <div class="hint">Renaming changes the clone URL; update your remotes afterwards.</div> |
| 291 | </div> |
| 292 | <div class="field"> |
| 293 | <label for="default-branch">Default branch</label> |
| 294 | {names.branches.length ? ( |
| 295 | <KSelect |
| 296 | id="default-branch" |
| 297 | name="default_branch" |
| 298 | block |
| 299 | label="Default branch" |
| 300 | value={names.branches.includes(repo.default_branch) ? repo.default_branch : undefined} |
| 301 | options={names.branches.map((b) => ({ value: b, label: b }))} |
| 302 | /> |
| 303 | ) : ( |
| 304 | <input id="default-branch" name="default_branch" type="text" value={repo.default_branch} /> |
| 305 | )} |
| 306 | <div class="hint">The branch people see first, and the one pull requests target by default.</div> |
| 307 | </div> |
| 308 | <div class="field full"> |
| 309 | <label for="repo-description">Description</label> |
| 310 | <input id="repo-description" name="description" type="text" value={repo.description} maxlength={350} /> |
| 311 | </div> |
| 312 | <div class="field full"> |
| 313 | <label for="repo-homepage">Website</label> |
| 314 | <input id="repo-homepage" name="homepage" type="url" value={repo.homepage ?? ""} maxlength={500} placeholder="https://example.com" /> |
| 315 | <div class="hint">The project's site, linked from the repository's About box.</div> |
| 316 | </div> |
| 317 | <div class="field full"> |
| 318 | <label for="repo-topics">Topics</label> |
| 319 | <input id="repo-topics" name="topics" type="text" value={topicsOf(repo).join(" ")} placeholder="cloudflare ci git" /> |
| 320 | <div class="hint">Up to 20, separated by spaces or commas: lowercase letters, digits and hyphens. They help people and search engines find the repository.</div> |
| 321 | </div> |
| 322 | <div class="field full"> |
| 323 | <label>Visibility</label> |
| 324 | <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "public"] as const).map((v) => ({ value: v, label: v === "public" ? "Public" : "Private" }))} /> |
| 325 | <div class="hint">Private repositories are visible to you, collaborators and organization members; public ones to anyone.</div> |
| 326 | </div> |
| 327 | </div> |
| 328 | <div class="factions"> |
| 329 | <button class="btn primary">Save changes</button> |
| 330 | </div> |
| 331 | </div> |
| 332 | </Form> |
| 333 | <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> |
| 334 | <div class="head" style="background:var(--red-soft)"> |
| 335 | <h2 class="bad">Danger zone</h2> |
| 336 | </div> |
| 337 | <div class="body"> |
| 338 | <Form action={`${base}/settings/archive`} t={t} class="drow mb" confirm={repo.archived ? "Unarchive this repository?" : "Archive this repository? It becomes read-only."}> |
| 339 | <input type="hidden" name="archived" value={repo.archived ? "0" : "1"} /> |
| 340 | <div> |
| 341 | <b>{repo.archived ? "Unarchive this repository" : "Archive this repository"}</b> |
| 342 | <div class="muted small">{repo.archived ? "Make the repository writable again." : "Mark it read-only: no pushes, issues, pull requests or runs."}</div> |
| 343 | </div> |
| 344 | <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button> |
| 345 | </Form> |
| 346 | <hr /> |
| 347 | <Form action={`${base}/settings/transfer`} t={t} match={full}> |
| 348 | <b>Transfer ownership</b> |
| 349 | <p class="muted small"> |
| 350 | Move this repository to your account or an organization you own or administer. Its history, issues, pull requests, runs, secrets and settings move with it, and its old URLs redirect to the new ones. People who only had access through {repo.owner_login} lose it. |
| 351 | </p> |
| 352 | {targets.length ? ( |
| 353 | <> |
| 354 | <div class="formrow"> |
| 355 | <div class="field"> |
| 356 | <label for="new_owner">New owner</label> |
| 357 | <KSelect id="new_owner" name="new_owner" label="New owner" placeholder="Choose an owner" options={targets} /> |
| 358 | </div> |
| 359 | <div class="field grow"> |
| 360 | <label for="new_name"> |
| 361 | New name <span class="faint">(optional)</span> |
| 362 | </label> |
| 363 | <input id="new_name" name="new_name" type="text" pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder={repo.name} autocomplete="off" /> |
| 364 | </div> |
| 365 | </div> |
| 366 | <p class="muted small"> |
| 367 | Type <b class="mono">{full}</b> to confirm. |
| 368 | </p> |
| 369 | <div class="row"> |
| 370 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> |
| 371 | <button class="btn danger">Transfer</button> |
| 372 | </div> |
| 373 | </> |
| 374 | ) : ( |
| 375 | <p class="muted small m0">{repo.owner_kind !== "org" && l.caller.id !== repo.owner_id ? `Only ${repo.owner_login} can transfer this repository.` : "You don't own or administer another account or organization to transfer it to."}</p> |
| 376 | )} |
| 377 | </Form> |
| 378 | <hr /> |
| 379 | <Form action={`${base}/settings/purge`} t={t} class="drow mb" confirm="Permanently delete every commit and file that no branch, tag or open pull request reaches?"> |
| 380 | <div> |
| 381 | <b>Purge unreachable objects</b> |
| 382 | <div class="muted small">After you force-push a secret away: permanently delete the commits and files no branch, tag or open pull request reaches, so nobody can fetch them by their SHA. Rotate the secret too.</div> |
| 383 | </div> |
| 384 | <button class="btn danger">Purge</button> |
| 385 | </Form> |
| 386 | <hr /> |
| 387 | <Form action={`${base}/settings/delete-closed-pulls`} t={t} match={full}> |
| 388 | <b>Delete closed pull requests</b> |
| 389 | <p class="muted small"> |
| 390 | To start over with fresh history after rewriting the branches: permanently delete every closed and merged pull request with its conversation, reviews and refs, then purge the commits and files nothing else reaches. Issues, open pull requests, branches, tags and workflow runs stay. Type <b class="mono">{full}</b> to confirm. |
| 391 | </p> |
| 392 | <div class="row"> |
| 393 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> |
| 394 | <button class="btn danger">Delete closed pull requests</button> |
| 395 | </div> |
| 396 | </Form> |
| 397 | <hr /> |
| 398 | <Form action={`${base}/settings/delete`} t={t} match={full}> |
| 399 | <b>Delete this repository</b> |
| 400 | <p class="muted small"> |
| 401 | This permanently deletes the repository, its history, issues, pull requests, runs and secrets. Type <b class="mono">{full}</b> to confirm. |
| 402 | </p> |
| 403 | <div class="row"> |
| 404 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> |
| 405 | <button class="btn danger">Delete this repository</button> |
| 406 | </div> |
| 407 | </Form> |
| 408 | </div> |
| 409 | </div> |
| 410 | </>, |
| 411 | ); |
| 412 | } |
| 413 | |
| 414 | async function accessPage(c: AppContext, l: Loaded, invite?: { url: string; email: string }) { |
| 415 | const { repo } = l; |
| 416 | const base = repoUrl(repo); |
| 417 | const t = csrfToken(c); |
| 418 | const [collaborators, invites] = await Promise.all([c.get("dir").collaborators(repo.id), c.get("dir").invites({ repoId: repo.id, pending: true })]); |
| 419 | return page( |
| 420 | c, |
| 421 | l, |
| 422 | "access", |
| 423 | <> |
| 424 | <h1>Collaborators</h1> |
| 425 | <p class="muted"> |
| 426 | {repo.owner_kind === "org" ? ( |
| 427 | <> |
| 428 | Members of <a href={`/${repo.owner_login}`}>{repo.owner_login}</a> have the organization's base permission (owners and admins: admin). Add people here for more access. |
| 429 | </> |
| 430 | ) : ( |
| 431 | <> |
| 432 | <b>{repo.owner_login}</b> owns this repository. Add collaborators to share it with friends or agents. |
| 433 | </> |
| 434 | )} |
| 435 | </p> |
| 436 | {invite ? <Once title={`Invitation sent to ${invite.email}`} value={invite.url} note="We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they get access when they accept it, creating an account and passkey first if they don't have one." /> : null} |
| 437 | <Form action={`${base}/settings/access/people`} t={t} class="box"> |
| 438 | <div class="head"> |
| 439 | <h3>{raw(ICONS.userPlus)} Add people</h3> |
| 440 | </div> |
| 441 | <div class="body"> |
| 442 | <div class="formrow"> |
| 443 | <div class="field grow"> |
| 444 | <label for="who">Username or email</label> |
| 445 | <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="diffy, review-bot or name@example.com" /> |
| 446 | </div> |
| 447 | <div class="field"> |
| 448 | <label for="role">Role</label> |
| 449 | <KSelect id="role" name="role" label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> |
| 450 | </div> |
| 451 | <button class="btn primary">Add</button> |
| 452 | </div> |
| 453 | <div class="hint">A person's username gets an invitation in their Settings → Invitations; they get access once they accept it. Agents, organization members and existing collaborators get the role at once. An email address gets an invitation link that also creates an account if they don't have one.</div> |
| 454 | </div> |
| 455 | </Form> |
| 456 | <div class="box"> |
| 457 | {collaborators.length ? ( |
| 458 | <table class="tbl"> |
| 459 | <thead> |
| 460 | <tr> |
| 461 | <th>Account</th> |
| 462 | <th>Role</th> |
| 463 | <th /> |
| 464 | </tr> |
| 465 | </thead> |
| 466 | <tbody> |
| 467 | {collaborators.map((x) => ( |
| 468 | <tr> |
| 469 | <td> |
| 470 | <Actor login={x.account.login} kind={x.account.kind} /> |
| 471 | </td> |
| 472 | <td> |
| 473 | <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> |
| 474 | <input type="hidden" name="login" value={x.account.login} /> |
| 475 | <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> |
| 476 | <button class="btn sm">Save</button> |
| 477 | </Form> |
| 478 | </td> |
| 479 | <td class="right"> |
| 480 | <Form action={`${base}/settings/access`} t={t} class="inline" confirm={`Remove ${x.account.login}'s access?`}> |
| 481 | <input type="hidden" name="login" value={x.account.login} /> |
| 482 | <input type="hidden" name="role" value="remove" /> |
| 483 | <button class="btn sm danger">Remove</button> |
| 484 | </Form> |
| 485 | </td> |
| 486 | </tr> |
| 487 | ))} |
| 488 | </tbody> |
| 489 | </table> |
| 490 | ) : ( |
| 491 | <div class="empty">No direct collaborators.</div> |
| 492 | )} |
| 493 | </div> |
| 494 | <PendingInvites invites={invites} revoke={(id) => `${base}/settings/access/invites/${id}/revoke`} t={t} /> |
| 495 | <div class="box"> |
| 496 | <div class="body small muted"> |
| 497 | <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access. |
| 498 | </div> |
| 499 | </div> |
| 500 | </>, |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | async function branchesPage(c: AppContext, l: Loaded) { |
| 505 | const base = repoUrl(l.repo); |
| 506 | const t = csrfToken(c); |
| 507 | const rules = await l.stub.listProtections(); |
| 508 | return page( |
| 509 | c, |
| 510 | l, |
| 511 | "branches", |
| 512 | <> |
| 513 | <h1>Branch protection</h1> |
| 514 | <p class="muted"> |
| 515 | Rules protect matching branches from force pushes, deletion and unreviewed changes. The default branch (<span class="mono">{l.repo.default_branch}</span>) is always protected from agents. To have pull requests merge by themselves once their checks pass, use{" "} |
| 516 | <a href={`${base}/settings/merge-on-green`}>Merge on green</a>. |
| 517 | </p> |
| 518 | {rules.map((r) => ( |
| 519 | <details class="box"> |
| 520 | <summary class="head" style="cursor:pointer"> |
| 521 | {raw(ICONS.shield)} <b class="mono">{r.pattern}</b> |
| 522 | <span class="muted small grow"> |
| 523 | {[ |
| 524 | r.rules.requirePullRequest ? "PR required" : "", |
| 525 | r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "", |
| 526 | r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "", |
| 527 | r.rules.requireLinearHistory ? "linear history" : "", |
| 528 | r.rules.mergeQueue ? "merge queue" : "", |
| 529 | r.rules.autoMergeAgents?.length ? `auto-merges ${r.rules.autoMergeAgents.map((a) => a.login).join(", ")}` : "", |
| 530 | r.rules.enforceAdmins ? "admins included" : "", |
| 531 | ] |
| 532 | .filter(Boolean) |
| 533 | .join(" · ") || "basic protection"} |
| 534 | </span> |
| 535 | <Form action={`${base}/settings/branches/delete`} t={t} class="inline" confirm={`Delete the rule for ${r.pattern}?`}> |
| 536 | <input type="hidden" name="pattern" value={r.pattern} /> |
| 537 | <button class="btn sm danger">Delete</button> |
| 538 | </Form> |
| 539 | </summary> |
| 540 | <RuleForm c={c} base={base} pattern={r.pattern} rules={r.rules} /> |
| 541 | </details> |
| 542 | ))} |
| 543 | {rules.length === 0 ? <div class="blank mb">No branch protection rules yet.</div> : null} |
| 544 | <div class="box"> |
| 545 | <div class="head"> |
| 546 | <h3>Add a rule</h3> |
| 547 | </div> |
| 548 | <RuleForm c={c} base={base} rules={{ requirePullRequest: true, requiredApprovals: 1 }} /> |
| 549 | </div> |
| 550 | </>, |
| 551 | ); |
| 552 | } |
| 553 | |
| 554 | /** GitHub's Settings → Actions → General → Workflow permissions: what a job's GITHUB_TOKEN gets by default. */ |
| 555 | async function actionsPage(c: AppContext, l: Loaded) { |
| 556 | const base = repoUrl(l.repo); |
| 557 | const settings = await l.stub.workflowSettings(); |
| 558 | return page( |
| 559 | c, |
| 560 | l, |
| 561 | "actions", |
| 562 | <> |
| 563 | <h1>Actions</h1> |
| 564 | <Form action={`${base}/settings/actions`} t={csrfToken(c)} class="box"> |
| 565 | <div class="head"> |
| 566 | <h2>Workflow permissions</h2> |
| 567 | </div> |
| 568 | <div class="body fbody"> |
| 569 | <Section |
| 570 | title="Default token permissions" |
| 571 | note={ |
| 572 | <> |
| 573 | The default permissions of the <code>GITHUB_TOKEN</code> each job gets. A workflow or job can ask for less, or more, with a <code>permissions:</code> block. |
| 574 | </> |
| 575 | } |
| 576 | > |
| 577 | <KRadio |
| 578 | name="default_permissions" |
| 579 | value={settings.defaultPermissions} |
| 580 | items={[ |
| 581 | { value: "read", label: "Read repository contents and packages permissions", description: "Jobs can read the repository and its packages, and nothing else unless the workflow asks." }, |
| 582 | { value: "write", label: "Read and write permissions", description: "Jobs can read and write in every scope unless the workflow asks for less." }, |
| 583 | ]} |
| 584 | /> |
| 585 | </Section> |
| 586 | <Section title="Pull requests and secrets"> |
| 587 | <div class="fchecks"> |
| 588 | <KCheckbox name="can_approve" checked={settings.canApprovePullRequests} label="Allow GitHub Actions to create and approve pull requests" description="Approvals from workflows never count toward required reviews." /> |
| 589 | <KCheckbox |
| 590 | name="agent_secrets" |
| 591 | checked={settings.agentRunSecrets} |
| 592 | label="Give runs of agent code the repository's and organization's secrets" |
| 593 | description="Off: a run whose code an agent pushed, or whose pull request an agent opened, gets only its job token. Environment secrets follow the environment's own rules." |
| 594 | /> |
| 595 | </div> |
| 596 | </Section> |
| 597 | <div class="factions"> |
| 598 | <button class="btn primary">Save</button> |
| 599 | </div> |
| 600 | </div> |
| 601 | </Form> |
| 602 | </>, |
| 603 | ); |
| 604 | } |
| 605 | |
| 606 | // ---------------------------------------------------------------- environments |
| 607 | |
| 608 | const envPath = (l: Loaded, name?: string) => `${repoUrl(l.repo)}/settings/environments${name ? `/${encodeURIComponent(name)}` : ""}`; |
| 609 | |
| 610 | function rulesSummary(e: Environment): string { |
| 611 | const parts: string[] = []; |
| 612 | if (e.protection.reviewers.length) parts.push(`${e.protection.reviewers.length} required reviewer${e.protection.reviewers.length === 1 ? "" : "s"}`); |
| 613 | if (e.protection.waitTimer) parts.push(`${e.protection.waitTimer} minute wait timer`); |
| 614 | if (e.policy) parts.push(e.policy.protected_branches ? "protected branches only" : `${e.policy.patterns.length} branch and tag rule${e.policy.patterns.length === 1 ? "" : "s"}`); |
| 615 | return parts.join(" · ") || "No protection rules"; |
| 616 | } |
| 617 | |
| 618 | async function environmentsPage(c: AppContext, l: Loaded) { |
| 619 | const t = csrfToken(c); |
| 620 | const envs = await l.stub.environments(); |
| 621 | return page( |
| 622 | c, |
| 623 | l, |
| 624 | "environments", |
| 625 | <> |
| 626 | <h1>Environments</h1> |
| 627 | <p class="muted"> |
| 628 | A workflow job with <code>environment: name</code> deploys to that environment and gets its secrets. Required reviewers and a wait timer hold such jobs before anything starts: nothing runs, and no secret is handed out, until a reviewer approves and the timer has passed. |
| 629 | </p> |
| 630 | <div class="box"> |
| 631 | {envs.length ? ( |
| 632 | <ul class="list"> |
| 633 | {envs.map((e) => ( |
| 634 | <li> |
| 635 | <div class="grow"> |
| 636 | <a class="title" href={envPath(l, e.name)}> |
| 637 | {e.name} |
| 638 | </a> |
| 639 | <div class="meta">{rulesSummary(e)}</div> |
| 640 | </div> |
| 641 | </li> |
| 642 | ))} |
| 643 | </ul> |
| 644 | ) : ( |
| 645 | <div class="empty">No environments yet. A workflow job that names one creates it.</div> |
| 646 | )} |
| 647 | <Form action={envPath(l)} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> |
| 648 | <div class="field m0" style="min-width:240px"> |
| 649 | <label>New environment</label> |
| 650 | <input name="name" type="text" required maxlength={255} placeholder="production" autocomplete="off" /> |
| 651 | </div> |
| 652 | <button class="btn">Add environment</button> |
| 653 | </Form> |
| 654 | </div> |
| 655 | </>, |
| 656 | ); |
| 657 | } |
| 658 | |
| 659 | type BranchPolicyChoice = "all" | "protected" | "selected"; |
| 660 | |
| 661 | const BRANCH_POLICY_ITEMS = [ |
| 662 | { value: "all", label: "No restriction", description: "Any branch or tag can deploy." }, |
| 663 | { value: "protected", label: "Protected branches only", description: "Branches a branch protection rule matches. The only setting under which runs whose code came from an agent receive this environment's secrets." }, |
| 664 | { value: "selected", label: "Selected branches and tags", description: "Only branches and tags that match the patterns you add below, after saving." }, |
| 665 | ]; |
| 666 | |
| 667 | const branchPolicyOf = (e: Environment): BranchPolicyChoice => (!e.policy ? "all" : e.policy.protected_branches ? "protected" : "selected"); |
| 668 | |
| 669 | async function environmentPage(c: AppContext, l: Loaded, name: string) { |
| 670 | const t = csrfToken(c); |
| 671 | const [e, secrets] = await Promise.all([l.stub.environment(name), l.stub.environmentSecretNames(name)]); |
| 672 | const p = e.protection; |
| 673 | return page( |
| 674 | c, |
| 675 | l, |
| 676 | "environments", |
| 677 | <> |
| 678 | <div class="muted small"> |
| 679 | <a href={envPath(l)}>Environments</a> / |
| 680 | </div> |
| 681 | <h1>{e.name}</h1> |
| 682 | <Form action={envPath(l, e.name)} t={t} class="box"> |
| 683 | <div class="head"> |
| 684 | <h2>Deployment protection rules</h2> |
| 685 | </div> |
| 686 | <div class="body fbody"> |
| 687 | <div class="fgrid c3"> |
| 688 | <div class="field span2"> |
| 689 | <label for="env-reviewers">Required reviewers</label> |
| 690 | <input id="env-reviewers" name="reviewers" type="text" value={p.reviewers.map((r) => r.login).join(", ")} placeholder="alice, bob" autocomplete="off" /> |
| 691 | <div class="hint"> |
| 692 | Up to {MAX_REVIEWERS} people with access to this repository, separated by commas. One approval lets a run's jobs deploy here. Agents can't be reviewers, and an agent's approval never counts. |
| 693 | </div> |
| 694 | </div> |
| 695 | <div class="field"> |
| 696 | <label for="env-wait">Wait timer (minutes)</label> |
| 697 | <input id="env-wait" name="wait_timer" type="number" min={0} max={MAX_WAIT_TIMER} value={String(p.waitTimer)} /> |
| 698 | <div class="hint">Jobs wait this long after they're ready to start, up to {MAX_WAIT_TIMER} (30 days). Nobody can skip it.</div> |
| 699 | </div> |
| 700 | </div> |
| 701 | <div class="fchecks"> |
| 702 | <KCheckbox name="prevent_self_review" checked={p.preventSelfReview} label="Prevent self-review" description="Whoever started a run (or re-ran it, or owns the agent that did) can't approve it." /> |
| 703 | </div> |
| 704 | <div class="field"> |
| 705 | <KRadio name="branch_policy" label="Deployment branches" value={branchPolicyOf(e)} items={BRANCH_POLICY_ITEMS} /> |
| 706 | </div> |
| 707 | <div class="factions"> |
| 708 | <button class="btn primary">Save protection rules</button> |
| 709 | </div> |
| 710 | </div> |
| 711 | </Form> |
| 712 | {e.policy?.custom_branch_policies ? ( |
| 713 | <div class="box"> |
| 714 | <div class="head"> |
| 715 | <h2>Branch and tag patterns</h2> |
| 716 | </div> |
| 717 | {e.policy.patterns.length ? ( |
| 718 | <ul class="list"> |
| 719 | {e.policy.patterns.map((x) => ( |
| 720 | <li style="align-items:center"> |
| 721 | <span class="mono grow"> |
| 722 | <b>{x.name}</b> |
| 723 | </span> |
| 724 | <span class="muted small">{x.type}</span> |
| 725 | <Form action={`${envPath(l, e.name)}/branch-policies/delete`} t={t} class="inline" confirm={`Remove the ${x.type} pattern ${x.name}?`}> |
| 726 | <input type="hidden" name="id" value={String(x.id)} /> |
| 727 | <button class="btn sm danger">Remove</button> |
| 728 | </Form> |
| 729 | </li> |
| 730 | ))} |
| 731 | </ul> |
| 732 | ) : ( |
| 733 | <div class="empty">No patterns yet, so no branch or tag can deploy here.</div> |
| 734 | )} |
| 735 | <Form action={`${envPath(l, e.name)}/branch-policies`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> |
| 736 | <div class="field m0" style="min-width:240px"> |
| 737 | <label for="env-pattern">Pattern</label> |
| 738 | <input id="env-pattern" name="name" type="text" required maxlength={255} class="mono" placeholder="release/*" autocomplete="off" /> |
| 739 | </div> |
| 740 | <div class="field m0"> |
| 741 | <KSelect name="type" label="Matches" value="branch" options={[{ value: "branch", label: "Branches" }, { value: "tag", label: "Tags" }]} /> |
| 742 | </div> |
| 743 | <button class="btn">Add pattern</button> |
| 744 | </Form> |
| 745 | </div> |
| 746 | ) : null} |
| 747 | <div class="box"> |
| 748 | <div class="head"> |
| 749 | <h2>{raw(ICONS.key)} Environment secrets</h2> |
| 750 | </div> |
| 751 | {secrets.length ? ( |
| 752 | <ul class="list"> |
| 753 | {secrets.map((s) => ( |
| 754 | <li style="align-items:center"> |
| 755 | <span class="mono grow"> |
| 756 | <b>{s.name}</b> |
| 757 | </span> |
| 758 | <span class="muted small">updated {ago(s.updatedAt)}</span> |
| 759 | <Form action={`${envPath(l, e.name)}/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> |
| 760 | <input type="hidden" name="name" value={s.name} /> |
| 761 | <button class="btn sm danger">Delete</button> |
| 762 | </Form> |
| 763 | </li> |
| 764 | ))} |
| 765 | </ul> |
| 766 | ) : ( |
| 767 | <div class="empty">No environment secrets yet.</div> |
| 768 | )} |
| 769 | <Form action={`${envPath(l, e.name)}/secrets`} t={t} class="body fbody" style="border-top:1px solid var(--border2)"> |
| 770 | <div class="fgrid"> |
| 771 | <div class="field"> |
| 772 | <label for="env-secret-name">Name</label> |
| 773 | <input id="env-secret-name" name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="DEPLOY_TOKEN" autocomplete="off" /> |
| 774 | </div> |
| 775 | <div class="field full"> |
| 776 | <label for="env-secret-value">Value</label> |
| 777 | <textarea id="env-secret-value" name="value" required class="mono" rows={4} autocomplete="off" spellcheck={false}></textarea> |
| 778 | <div class="hint"> |
| 779 | Only jobs with <code>environment: {e.name}</code> receive it, as <code>{"${{ secrets.NAME }}"}</code>. Setting an existing name replaces its value.{e.policy ? "" : " Adding the first secret limits deployment branches to protected branches only."} |
| 780 | </div> |
| 781 | </div> |
| 782 | </div> |
| 783 | <div class="factions"> |
| 784 | <button class="btn primary">Save secret</button> |
| 785 | </div> |
| 786 | </Form> |
| 787 | </div> |
| 788 | <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}> |
| 789 | <div class="body drow"> |
| 790 | <div> |
| 791 | <b>Delete this environment</b> |
| 792 | <div class="muted small">Its secrets, variables and protection rules go with it. Past deployments stay.</div> |
| 793 | </div> |
| 794 | <button class="btn danger">Delete environment</button> |
| 795 | </div> |
| 796 | </Form> |
| 797 | </>, |
| 798 | ); |
| 799 | } |
| 800 | |
| 801 | async function secretsPage(c: AppContext, l: Loaded) { |
| 802 | const base = repoUrl(l.repo); |
| 803 | const t = csrfToken(c); |
| 804 | const [secrets, vars] = await Promise.all([l.stub.secretNames(), l.stub.variables(false)]); |
| 805 | return page( |
| 806 | c, |
| 807 | l, |
| 808 | "secrets", |
| 809 | <> |
| 810 | <h1>Secrets and variables</h1> |
| 811 | <p class="muted"> |
| 812 | Use them in workflows as <code>{"${{ secrets.NAME }}"}</code> and <code>{"${{ vars.NAME }}"}</code>. Secret values are encrypted and never shown again; they are masked in logs. |
| 813 | </p> |
| 814 | <div class="box"> |
| 815 | <div class="head"> |
| 816 | <h2>{raw(ICONS.key)} Repository secrets</h2> |
| 817 | </div> |
| 818 | {secrets.length ? ( |
| 819 | <ul class="list"> |
| 820 | {secrets.map((s) => ( |
| 821 | <li style="align-items:center"> |
| 822 | <span class="mono grow"> |
| 823 | <b>{s.name}</b> |
| 824 | </span> |
| 825 | <span class="muted small">updated {ago(s.updatedAt)}</span> |
| 826 | <Form action={`${base}/settings/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> |
| 827 | <input type="hidden" name="name" value={s.name} /> |
| 828 | <button class="btn sm danger">Delete</button> |
| 829 | </Form> |
| 830 | </li> |
| 831 | ))} |
| 832 | </ul> |
| 833 | ) : ( |
| 834 | <div class="empty">No secrets yet.</div> |
| 835 | )} |
| 836 | <Form action={`${base}/settings/secrets`} t={t} class="body fbody" style="border-top:1px solid var(--border2)"> |
| 837 | <div class="fgrid"> |
| 838 | <div class="field"> |
| 839 | <label for="secret-name">Name</label> |
| 840 | <input id="secret-name" name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="API_TOKEN" autocomplete="off" /> |
| 841 | </div> |
| 842 | <div class="field full"> |
| 843 | <label for="secret-value">Value</label> |
| 844 | <textarea id="secret-value" name="value" required class="mono" rows={4} autocomplete="off" spellcheck={false}></textarea> |
| 845 | <div class="hint">Setting an existing name replaces its value.</div> |
| 846 | </div> |
| 847 | </div> |
| 848 | <div class="factions"> |
| 849 | <button class="btn primary">Save secret</button> |
| 850 | </div> |
| 851 | </Form> |
| 852 | </div> |
| 853 | <div class="box"> |
| 854 | <div class="head"> |
| 855 | <h2>Variables</h2> |
| 856 | </div> |
| 857 | {Object.keys(vars).length ? ( |
| 858 | <table class="tbl"> |
| 859 | <tbody> |
| 860 | {Object.entries(vars) |
| 861 | .sort(([a], [b]) => a.localeCompare(b)) |
| 862 | .map(([k, v]) => ( |
| 863 | <tr> |
| 864 | <td class="mono"> |
| 865 | <b>{k}</b> |
| 866 | </td> |
| 867 | <td class="mono small" style="word-break:break-all"> |
| 868 | {v} |
| 869 | </td> |
| 870 | <td class="right"> |
| 871 | <Form action={`${base}/settings/variables/delete`} t={t} class="inline" confirm={`Delete variable ${k}?`}> |
| 872 | <input type="hidden" name="name" value={k} /> |
| 873 | <button class="btn sm danger">Delete</button> |
| 874 | </Form> |
| 875 | </td> |
| 876 | </tr> |
| 877 | ))} |
| 878 | </tbody> |
| 879 | </table> |
| 880 | ) : ( |
| 881 | <div class="empty">No variables yet.</div> |
| 882 | )} |
| 883 | <Form action={`${base}/settings/variables`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> |
| 884 | <div class="field m0" style="min-width:200px"> |
| 885 | <label>Name</label> |
| 886 | <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" /> |
| 887 | </div> |
| 888 | <div class="field grow m0"> |
| 889 | <label>Value</label> |
| 890 | <input name="value" type="text" class="mono" /> |
| 891 | </div> |
| 892 | <button class="btn primary">Save variable</button> |
| 893 | </Form> |
| 894 | </div> |
| 895 | </>, |
| 896 | ); |
| 897 | } |
| 898 | |
| 899 | async function webhooksPage(c: AppContext, l: Loaded, created?: { secret: string; url: string }) { |
| 900 | const base = repoUrl(l.repo); |
| 901 | const t = csrfToken(c); |
| 902 | const hooks = await l.stub.webhooks(); |
| 903 | return page( |
| 904 | c, |
| 905 | l, |
| 906 | "webhooks", |
| 907 | <> |
| 908 | <h1>Webhooks</h1> |
| 909 | <p class="muted"> |
| 910 | Diffinery POSTs JSON events to these URLs, signed with an HMAC-SHA256 of the body using the webhook's secret. |
| 911 | </p> |
| 912 | {created ? <Once title={`Webhook created for ${created.url}`} value={created.secret} note="This is the signing secret. Copy it now; it will not be shown again." /> : null} |
| 913 | <div class="box"> |
| 914 | {hooks.length ? ( |
| 915 | <ul class="list"> |
| 916 | {hooks.map((h) => ( |
| 917 | <li style="align-items:center"> |
| 918 | <span class={`st ${h.lastStatus === null ? "faint" : h.lastStatus >= 200 && h.lastStatus < 300 ? "ok" : "bad"}`}>{raw(h.lastStatus === null ? ICONS.queued : h.lastStatus >= 200 && h.lastStatus < 300 ? ICONS.success : ICONS.failure)}</span> |
| 919 | <div class="grow"> |
| 920 | <b class="mono" style="word-break:break-all"> |
| 921 | {h.url} |
| 922 | </b> |
| 923 | <div class="meta"> |
| 924 | {h.events.join(", ")} · {h.active ? "active" : "inactive"} |
| 925 | {h.lastDeliveryAt ? ( |
| 926 | <> |
| 927 | {" "} |
| 928 | · last delivery {h.lastStatus ?? "?"} <Time at={h.lastDeliveryAt} /> |
| 929 | </> |
| 930 | ) : ( |
| 931 | " · no deliveries yet" |
| 932 | )} |
| 933 | </div> |
| 934 | </div> |
| 935 | <Form action={`${base}/settings/webhooks/delete`} t={t} class="inline" confirm="Delete this webhook?"> |
| 936 | <input type="hidden" name="id" value={h.id} /> |
| 937 | <button class="btn sm danger">Delete</button> |
| 938 | </Form> |
| 939 | </li> |
| 940 | ))} |
| 941 | </ul> |
| 942 | ) : ( |
| 943 | <div class="empty">No webhooks yet.</div> |
| 944 | )} |
| 945 | </div> |
| 946 | <Form action={`${base}/settings/webhooks`} t={t} class="box"> |
| 947 | <div class="head"> |
| 948 | <h3>Add a webhook</h3> |
| 949 | </div> |
| 950 | <div class="body fbody"> |
| 951 | <div class="field"> |
| 952 | <label for="hook-url">Payload URL</label> |
| 953 | <input id="hook-url" name="url" type="url" required placeholder="https://example.com/hooks/diffinery" pattern="https://.*" /> |
| 954 | </div> |
| 955 | <div class="field"> |
| 956 | <label>Events</label> |
| 957 | <div class="fchecks cols"> |
| 958 | <KCheckbox name="events" value="*" checked label="Everything" /> |
| 959 | {WEBHOOK_EVENTS.map((e) => ( |
| 960 | <KCheckbox name="events" value={e} label={<span class="mono small">{e}</span>} /> |
| 961 | ))} |
| 962 | </div> |
| 963 | </div> |
| 964 | <div class="fgrid"> |
| 965 | <div class="field"> |
| 966 | <label for="hook-secret"> |
| 967 | Secret <span class="faint">(optional)</span> |
| 968 | </label> |
| 969 | <input id="hook-secret" name="secret" type="text" autocomplete="off" class="mono" /> |
| 970 | <div class="hint">Left empty, Diffinery generates one and shows it once.</div> |
| 971 | </div> |
| 972 | </div> |
| 973 | <div class="factions"> |
| 974 | <button class="btn primary">Add webhook</button> |
| 975 | </div> |
| 976 | </div> |
| 977 | </Form> |
| 978 | </>, |
| 979 | ); |
| 980 | } |
| 981 | |
| 982 | // ---------------------------------------------------------------- routes |
| 983 | |
| 984 | export function registerRepoSettings(web: WebApp): void { |
| 985 | web.get("/:owner/:repo/settings", async (c) => generalPage(c, await load(c))); |
| 986 | web.get("/:owner/:repo/settings/access", async (c) => accessPage(c, await load(c))); |
| 987 | web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c))); |
| 988 | web.get("/:owner/:repo/settings/actions", async (c) => actionsPage(c, await load(c))); |
| 989 | web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c))); |
| 990 | web.get("/:owner/:repo/settings/environments", async (c) => environmentsPage(c, await load(c))); |
| 991 | web.get("/:owner/:repo/settings/environments/:env", async (c) => environmentPage(c, await load(c), prm(c, "env"))); |
| 992 | |
| 993 | post(web, "/:owner/:repo/settings/environments", async (c, f) => { |
| 994 | const l = await load(c, WHILE_IMPORTING); |
| 995 | const name = f.s("name").trim(); |
| 996 | return act(c, envPath(l), async () => { |
| 997 | const e = await l.stub.upsertEnvironment(name, {}, l.caller); |
| 998 | audit(c, l, "environment.update", { environment: e.name }); |
| 999 | return envPath(l, e.name); |
| 1000 | }); |
| 1001 | }); |
| 1002 | |
| 1003 | post(web, "/:owner/:repo/settings/environments/:env", async (c, f) => { |
| 1004 | const l = await load(c, WHILE_IMPORTING); |
| 1005 | const name = prm(c, "env"); |
| 1006 | return act( |
| 1007 | c, |
| 1008 | envPath(l, name), |
| 1009 | async () => { |
| 1010 | const logins = f |
| 1011 | .s("reviewers") |
| 1012 | .split(/[\s,]+/) |
| 1013 | .map((x) => x.replace(/^@/, "")) |
| 1014 | .filter(Boolean); |
| 1015 | const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login }))); |
| 1016 | const raw = f.s("wait_timer").trim(); |
| 1017 | const waitTimer = raw === "" ? 0 : Number(raw); |
| 1018 | const choice = f.keys().includes("branch_policy") ? f.s("branch_policy") : undefined; |
| 1019 | if (choice !== undefined && !["all", "protected", "selected"].includes(choice)) throw new HttpError(422, "choose which branches can deploy"); |
| 1020 | const deployment_branch_policy = choice === undefined ? undefined : choice === "all" ? null : { protected_branches: choice === "protected", custom_branch_policies: choice === "selected" }; |
| 1021 | const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1", deployment_branch_policy }, l.caller); |
| 1022 | audit(c, l, "environment.update", { |
| 1023 | environment: e.name, |
| 1024 | reviewers: e.protection.reviewers.map((r) => r.login), |
| 1025 | wait_timer: e.protection.waitTimer, |
| 1026 | prevent_self_review: e.protection.preventSelfReview, |
| 1027 | deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, |
| 1028 | }); |
| 1029 | }, |
| 1030 | "Protection rules saved.", |
| 1031 | ); |
| 1032 | }); |
| 1033 | |
| 1034 | post(web, "/:owner/:repo/settings/environments/:env/branch-policies", async (c, f) => { |
| 1035 | const l = await load(c, WHILE_IMPORTING); |
| 1036 | const name = prm(c, "env"); |
| 1037 | return act( |
| 1038 | c, |
| 1039 | envPath(l, name), |
| 1040 | async () => { |
| 1041 | const p = await l.stub.addEnvironmentBranchPattern(name, { name: f.s("name"), type: f.s("type") }, l.caller); |
| 1042 | audit(c, l, "environment.branch_policy.create", { environment: name, pattern: p.name, type: p.type }); |
| 1043 | }, |
| 1044 | "Pattern added.", |
| 1045 | ); |
| 1046 | }); |
| 1047 | |
| 1048 | post(web, "/:owner/:repo/settings/environments/:env/branch-policies/delete", async (c, f) => { |
| 1049 | const l = await load(c, WHILE_IMPORTING); |
| 1050 | const name = prm(c, "env"); |
| 1051 | return act(c, envPath(l, name), async () => { |
| 1052 | const id = Number(f.s("id")); |
| 1053 | if (!Number.isInteger(id)) throw new HttpError(422, "invalid pattern"); |
| 1054 | await l.stub.removeEnvironmentBranchPattern(name, id, l.caller); |
| 1055 | audit(c, l, "environment.branch_policy.delete", { environment: name, id }); |
| 1056 | }); |
| 1057 | }); |
| 1058 | |
| 1059 | post(web, "/:owner/:repo/settings/environments/:env/secrets", async (c, f) => { |
| 1060 | const l = await load(c, WHILE_IMPORTING); |
| 1061 | const env = prm(c, "env"); |
| 1062 | return act( |
| 1063 | c, |
| 1064 | envPath(l, env), |
| 1065 | async () => { |
| 1066 | const name = f.s("name").trim(); |
| 1067 | try { |
| 1068 | await l.stub.setEnvironmentSecret(env, name, f.s("value"), l.caller); |
| 1069 | } catch (e) { |
| 1070 | const s = statusOf(e); |
| 1071 | if (s.status < 500) throw e; |
| 1072 | throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); |
| 1073 | } |
| 1074 | audit(c, l, "environment.secret.set", { environment: env, name: name.toUpperCase() }); |
| 1075 | }, |
| 1076 | "Secret saved.", |
| 1077 | ); |
| 1078 | }); |
| 1079 | |
| 1080 | post(web, "/:owner/:repo/settings/environments/:env/secrets/delete", async (c, f) => { |
| 1081 | const l = await load(c, WHILE_IMPORTING); |
| 1082 | const env = prm(c, "env"); |
| 1083 | return act(c, envPath(l, env), async () => { |
| 1084 | await l.stub.deleteEnvironmentSecret(env, f.s("name"), l.caller); |
| 1085 | audit(c, l, "environment.secret.delete", { environment: env, name: f.s("name").toUpperCase() }); |
| 1086 | }); |
| 1087 | }); |
| 1088 | |
| 1089 | post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => { |
| 1090 | const l = await load(c, WHILE_IMPORTING); |
| 1091 | const name = prm(c, "env"); |
| 1092 | return act( |
| 1093 | c, |
| 1094 | envPath(l), |
| 1095 | async () => { |
| 1096 | await l.stub.deleteEnvironment(name, l.caller); |
| 1097 | audit(c, l, "environment.delete", { environment: name }); |
| 1098 | }, |
| 1099 | `Deleted ${name}.`, |
| 1100 | ); |
| 1101 | }); |
| 1102 | web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c))); |
| 1103 | |
| 1104 | post(web, "/:owner/:repo/settings/actions", async (c, f) => { |
| 1105 | const l = await load(c); |
| 1106 | return act( |
| 1107 | c, |
| 1108 | `${repoUrl(l.repo)}/settings/actions`, |
| 1109 | async () => { |
| 1110 | const defaultPermissions = f.s("default_permissions") === "write" ? "write" : "read"; |
| 1111 | await l.stub.setWorkflowSettings({ defaultPermissions, canApprovePullRequests: f.s("can_approve") === "1", agentRunSecrets: f.s("agent_secrets") === "1" }, l.caller); |
| 1112 | }, |
| 1113 | "Actions settings saved.", |
| 1114 | ); |
| 1115 | }); |
| 1116 | |
| 1117 | post(web, "/:owner/:repo/settings/general", async (c, f) => { |
| 1118 | const l = await load(c); |
| 1119 | const dir = c.get("dir"); |
| 1120 | return act( |
| 1121 | c, |
| 1122 | `${repoUrl(l.repo)}/settings`, |
| 1123 | async () => { |
| 1124 | const name = f.s("name").trim(); |
| 1125 | const visibility = (["private", "public"] as const).find((v) => v === f.s("visibility")); |
| 1126 | const defaultBranch = f.s("default_branch").trim(); |
| 1127 | if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists"); |
| 1128 | // Forms without these fields (older pages, scripts) leave them alone. |
| 1129 | const has = (k: string) => f.keys().includes(k); |
| 1130 | const topics = has("topics") ? normalizeTopics(f.s("topics").split(/[\s,]+/)) : undefined; |
| 1131 | const topicsChanged = topics !== undefined && topics.join(" ") !== topicsOf(l.repo).join(" "); |
| 1132 | const homepage = has("homepage") ? normalizeHomepage(f.s("homepage")) : undefined; |
| 1133 | const updated = await dir.updateRepo(l.repo, { |
| 1134 | name: name && name !== l.repo.name ? name : undefined, |
| 1135 | description: f.s("description").trim(), |
| 1136 | homepage: homepage !== (l.repo.homepage ?? "") ? homepage : undefined, |
| 1137 | topics: topicsChanged ? topics : undefined, |
| 1138 | visibility: visibility && visibility !== l.repo.visibility ? visibility : undefined, |
| 1139 | defaultBranch: defaultBranch && defaultBranch !== l.repo.default_branch ? defaultBranch : undefined, |
| 1140 | }); |
| 1141 | audit(c, l, "repo.update", { name: updated.name, visibility: updated.visibility, defaultBranch: updated.default_branch, homepage: updated.homepage ?? "" }); |
| 1142 | if (topicsChanged) audit(c, l, "repo.topics", { names: topics }); |
| 1143 | return `${repoUrl(updated)}/settings`; |
| 1144 | }, |
| 1145 | "Settings saved.", |
| 1146 | ); |
| 1147 | }); |
| 1148 | |
| 1149 | post(web, "/:owner/:repo/settings/archive", async (c, f) => { |
| 1150 | const l = await load(c, { whileArchived: true }); |
| 1151 | const archived = f.s("archived") === "1"; |
| 1152 | return act( |
| 1153 | c, |
| 1154 | `${repoUrl(l.repo)}/settings`, |
| 1155 | async () => { |
| 1156 | await c.get("dir").updateRepo(l.repo, { archived }); |
| 1157 | audit(c, l, archived ? "repo.archive" : "repo.unarchive"); |
| 1158 | }, |
| 1159 | archived ? "Repository archived." : "Repository unarchived.", |
| 1160 | ); |
| 1161 | }); |
| 1162 | |
| 1163 | post(web, "/:owner/:repo/settings/delete", async (c, f) => { |
| 1164 | const l = await load(c, { whileArchived: true }); |
| 1165 | requireAccount(c); |
| 1166 | const full = `${l.repo.owner_login}/${l.repo.name}`; |
| 1167 | return act( |
| 1168 | c, |
| 1169 | `${repoUrl(l.repo)}/settings`, |
| 1170 | async () => { |
| 1171 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`); |
| 1172 | requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); |
| 1173 | await c.get("dir").deleteRepo(l.repo); |
| 1174 | audit(c, l, "repo.delete"); |
| 1175 | return "/"; |
| 1176 | }, |
| 1177 | `Deleted ${full}.`, |
| 1178 | ); |
| 1179 | }); |
| 1180 | |
| 1181 | post(web, "/:owner/:repo/settings/purge", async (c) => { |
| 1182 | const l = await load(c); |
| 1183 | requireAccount(c); |
| 1184 | return act(c, `${repoUrl(l.repo)}/settings`, async () => { |
| 1185 | requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); |
| 1186 | const r = await l.stub.purgeUnreachable(l.caller); |
| 1187 | audit(c, l, "repo.purge_unreachable", { objects: r.objects, bytes: r.bytes }); |
| 1188 | flash(c, "notice", r.objects ? `Purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.` : "Nothing to purge: every object is reachable."); |
| 1189 | }); |
| 1190 | }); |
| 1191 | |
| 1192 | post(web, "/:owner/:repo/settings/delete-closed-pulls", async (c, f) => { |
| 1193 | const l = await load(c); |
| 1194 | requireAccount(c); |
| 1195 | const full = `${l.repo.owner_login}/${l.repo.name}`; |
| 1196 | return act(c, `${repoUrl(l.repo)}/settings`, async () => { |
| 1197 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm`); |
| 1198 | requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); |
| 1199 | const r = await l.stub.deleteClosedPullRequests(l.caller); |
| 1200 | audit(c, l, "repo.delete_closed_pulls", { pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes }); |
| 1201 | flash(c, "notice", r.pullRequests ? `Deleted ${r.pullRequests} closed pull request${r.pullRequests === 1 ? "" : "s"} and purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.` : `No closed pull requests to delete; purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.`); |
| 1202 | }); |
| 1203 | }); |
| 1204 | |
| 1205 | post(web, "/:owner/:repo/settings/transfer", async (c, f) => { |
| 1206 | const l = await load(c, { whileArchived: true }); |
| 1207 | const me = requireAccount(c); |
| 1208 | const full = `${l.repo.owner_login}/${l.repo.name}`; |
| 1209 | return act( |
| 1210 | c, |
| 1211 | `${repoUrl(l.repo)}/settings`, |
| 1212 | async () => { |
| 1213 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm the transfer`); |
| 1214 | requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); |
| 1215 | const dir = c.get("dir"); |
| 1216 | const target = await dir.accountByLogin(f.s("new_owner").trim()); |
| 1217 | if (!target) throw new HttpError(422, "choose the new owner"); |
| 1218 | const moved = await dir.transferRepo(l.repo, me, target, f.s("new_name").trim() || undefined); |
| 1219 | c.executionCtx.waitUntil(refreshRepoLimit(c.env, moved)); |
| 1220 | audit(c, { ...l, repo: moved }, "repo.transfer", { from: full, to: `${moved.owner_login}/${moved.name}` }); |
| 1221 | flash(c, "notice", `Transferred ${full} to ${moved.owner_login}/${moved.name}.`); |
| 1222 | return `${repoUrl(moved)}/settings`; |
| 1223 | }, |
| 1224 | ); |
| 1225 | }); |
| 1226 | |
| 1227 | post(web, "/:owner/:repo/settings/access", async (c, f) => { |
| 1228 | const l = await load(c, WHILE_IMPORTING); |
| 1229 | const dir = c.get("dir"); |
| 1230 | return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 1231 | const who = await dir.accountByLogin(f.s("login").trim()); |
| 1232 | if (!who || who.kind === "org") throw new HttpError(404, "no person or agent with that username"); |
| 1233 | const roleIn = f.s("role"); |
| 1234 | const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null); |
| 1235 | if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role"); |
| 1236 | if (!role) { |
| 1237 | await dir.setCollaborator(l.repo.id, who.id, null); |
| 1238 | audit(c, l, "repo.collaborator.remove", { login: who.login }); |
| 1239 | return; |
| 1240 | } |
| 1241 | // Someone without access yet gets an invitation to accept (see Directory.grantRepoAccess). |
| 1242 | const r = await dir.grantRepoAccess(l.repo, requireAccount(c), who, role); |
| 1243 | if ("invitedUser" in r) { |
| 1244 | flash(c, "notice", r.renewed ? `${who.login} already had a pending invitation; it now offers ${role} access.` : `Invited ${who.login} with ${role} access. They get access once they accept.`); |
| 1245 | audit(c, l, "repo.invite", { login: who.login, role }); |
| 1246 | } else audit(c, l, "repo.collaborator.set", { login: who.login, role }); |
| 1247 | }); |
| 1248 | }); |
| 1249 | |
| 1250 | post(web, "/:owner/:repo/settings/access/invites/:id/revoke", async (c) => { |
| 1251 | const l = await load(c, WHILE_IMPORTING); |
| 1252 | return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 1253 | if (!(await c.get("dir").revokeInvite(prm(c, "id"), { repoId: l.repo.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed"); |
| 1254 | audit(c, l, "repo.invite.revoke", { id: prm(c, "id") }); |
| 1255 | }, "Invitation revoked."); |
| 1256 | }); |
| 1257 | |
| 1258 | post(web, "/:owner/:repo/settings/access/people", async (c, f) => { |
| 1259 | const l = await load(c, WHILE_IMPORTING); |
| 1260 | const me = requireAccount(c); |
| 1261 | const role = ROLES.find((r) => r === f.s("role")) ?? "write"; |
| 1262 | let invited: { email: string; url: string } | undefined; |
| 1263 | const res = await act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 1264 | const r = await c.get("dir").addRepoPerson(l.repo, me, f.s("who"), role); |
| 1265 | if ("added" in r) { |
| 1266 | flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`); |
| 1267 | audit(c, l, "repo.collaborator.set", { login: r.added.login, role }); |
| 1268 | } else if ("invitedUser" in r) { |
| 1269 | flash(c, "notice", r.renewed ? `${r.invitedUser.login} already had a pending invitation; it now offers ${role} access.` : `Invited ${r.invitedUser.login} with ${role} access. They get access once they accept.`); |
| 1270 | audit(c, l, "repo.invite", { login: r.invitedUser.login, role }); |
| 1271 | } else { |
| 1272 | invited = r.invited; |
| 1273 | audit(c, l, "repo.invite", { email: r.invited.email, role }); |
| 1274 | } |
| 1275 | }); |
| 1276 | return invited ? accessPage(c, l, invited) : res; |
| 1277 | }); |
| 1278 | |
| 1279 | post(web, "/:owner/:repo/settings/branches", async (c, f) => { |
| 1280 | const l = await load(c); |
| 1281 | return act( |
| 1282 | c, |
| 1283 | `${repoUrl(l.repo)}/settings/branches`, |
| 1284 | async () => { |
| 1285 | const pattern = f.s("pattern").trim(); |
| 1286 | if (!pattern || /\s/.test(pattern)) throw new HttpError(422, "enter a branch name pattern"); |
| 1287 | const original = f.s("original").trim(); |
| 1288 | const rules = rulesFrom(f); |
| 1289 | // The form doesn't edit which app must report each required check; keep those bindings (set through the API). |
| 1290 | const before = (await l.stub.listProtections()).find((p) => p.pattern === (original || pattern))?.rules.requiredCheckSources; |
| 1291 | if (before && rules.requiredChecks?.length) { |
| 1292 | const kept = Object.fromEntries(Object.entries(before).filter(([name]) => rules.requiredChecks!.includes(name))); |
| 1293 | if (Object.keys(kept).length) rules.requiredCheckSources = kept; |
| 1294 | } |
| 1295 | if (original && original !== pattern) await l.stub.setProtection(original, null, l.caller); |
| 1296 | await l.stub.setProtection(pattern, rules, l.caller); |
| 1297 | audit(c, l, "repo.protection.set", { pattern, rules }); |
| 1298 | }, |
| 1299 | "Branch protection saved.", |
| 1300 | ); |
| 1301 | }); |
| 1302 | |
| 1303 | post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => { |
| 1304 | const l = await load(c); |
| 1305 | return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => { |
| 1306 | await l.stub.setProtection(f.s("pattern"), null, l.caller); |
| 1307 | audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") }); |
| 1308 | }); |
| 1309 | }); |
| 1310 | |
| 1311 | post(web, "/:owner/:repo/settings/secrets", async (c, f) => { |
| 1312 | const l = await load(c, WHILE_IMPORTING); |
| 1313 | return act( |
| 1314 | c, |
| 1315 | `${repoUrl(l.repo)}/settings/secrets`, |
| 1316 | async () => { |
| 1317 | const name = f.s("name").trim(); |
| 1318 | try { |
| 1319 | await l.stub.setSecret(name, f.s("value")); |
| 1320 | } catch (e) { |
| 1321 | const s = statusOf(e); |
| 1322 | if (s.status < 500) throw e; |
| 1323 | throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); |
| 1324 | } |
| 1325 | audit(c, l, "repo.secret.set", { name: name.toUpperCase() }); |
| 1326 | }, |
| 1327 | "Secret saved.", |
| 1328 | ); |
| 1329 | }); |
| 1330 | |
| 1331 | post(web, "/:owner/:repo/settings/secrets/delete", async (c, f) => { |
| 1332 | const l = await load(c, WHILE_IMPORTING); |
| 1333 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 1334 | await l.stub.deleteSecret(f.s("name")); |
| 1335 | audit(c, l, "repo.secret.delete", { name: f.s("name") }); |
| 1336 | }); |
| 1337 | }); |
| 1338 | |
| 1339 | post(web, "/:owner/:repo/settings/variables", async (c, f) => { |
| 1340 | const l = await load(c, WHILE_IMPORTING); |
| 1341 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 1342 | const name = f.s("name").trim(); |
| 1343 | if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new HttpError(422, "invalid variable name"); |
| 1344 | await l.stub.setVariable(name, f.s("value")); |
| 1345 | audit(c, l, "repo.variable.set", { name }); |
| 1346 | }); |
| 1347 | }); |
| 1348 | |
| 1349 | post(web, "/:owner/:repo/settings/variables/delete", async (c, f) => { |
| 1350 | const l = await load(c, WHILE_IMPORTING); |
| 1351 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 1352 | await l.stub.setVariable(f.s("name"), null); |
| 1353 | audit(c, l, "repo.variable.delete", { name: f.s("name") }); |
| 1354 | }); |
| 1355 | }); |
| 1356 | |
| 1357 | post(web, "/:owner/:repo/settings/webhooks", async (c, f) => { |
| 1358 | const l = await load(c); |
| 1359 | const url = f.s("url").trim(); |
| 1360 | const events = f.all("events").filter((e) => e === "*" || WEBHOOK_EVENTS.includes(e)); |
| 1361 | try { |
| 1362 | const hook = await l.stub.createWebhook(url, events.includes("*") ? ["*"] : events, f.s("secret").trim() || undefined); |
| 1363 | audit(c, l, "repo.webhook.create", { url, events }); |
| 1364 | return webhooksPage(c, l, { secret: hook.secret, url }); |
| 1365 | } catch (e) { |
| 1366 | const s = statusOf(e); |
| 1367 | if (s.status >= 500) throw e; |
| 1368 | return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { |
| 1369 | throw e; |
| 1370 | }); |
| 1371 | } |
| 1372 | }); |
| 1373 | |
| 1374 | post(web, "/:owner/:repo/settings/webhooks/delete", async (c, f) => { |
| 1375 | const l = await load(c); |
| 1376 | return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { |
| 1377 | await l.stub.deleteWebhook(f.s("id")); |
| 1378 | audit(c, l, "repo.webhook.delete", { id: f.s("id") }); |
| 1379 | }); |
| 1380 | }); |
| 1381 | } |