| 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 { loadRepo } from "../http/common.js"; |
| 6 | import { HttpError } from "../lib/util.js"; |
| 7 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| 8 | import { ICONS } from "./assets.js"; |
| 9 | import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js"; |
| 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; |
| 12 | |
| 13 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 14 | export type SettingsTab = "general" | "access" | "branches" | "actions" | "secrets" | "webhooks" | "agent-review"; |
| 15 | type Tab = SettingsTab; |
| 16 | |
| 17 | const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; |
| 18 | |
| 19 | const load = (c: AppContext, opts: { whileArchived?: boolean } = {}) => loadRepo(c, prm(c, "owner"), prm(c, "repo"), "admin", opts); |
| 20 | |
| 21 | function audit(c: AppContext, l: Loaded, action: string, detail?: Record<string, unknown>): void { |
| 22 | const p = c.get("principal"); |
| 23 | const actor = p.type === "account" ? p.account : null; |
| 24 | c.executionCtx.waitUntil( |
| 25 | c |
| 26 | .get("dir") |
| 27 | .audit({ actorId: actor?.id, actorLogin: actor?.login, onBehalfOf: l.caller.onBehalfOf, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail }) |
| 28 | .catch((e) => console.error("audit failed", e)), |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | export function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number) { |
| 33 | const base = `${repoUrl(l.repo)}/settings`; |
| 34 | const nav: Array<[Tab, string, string]> = [ |
| 35 | ["general", "General", base], |
| 36 | ["access", "Collaborators", `${base}/access`], |
| 37 | ["branches", "Branch protection", `${base}/branches`], |
| 38 | ["actions", "Actions", `${base}/actions`], |
| 39 | ["secrets", "Secrets and variables", `${base}/secrets`], |
| 40 | ["webhooks", "Webhooks", `${base}/webhooks`], |
| 41 | ["agent-review", "Agent review", `${base}/agent-review`], |
| 42 | ]; |
| 43 | return render( |
| 44 | c, |
| 45 | { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status }, |
| 46 | <div class="grid left"> |
| 47 | <aside class="side"> |
| 48 | <section> |
| 49 | <div class="nav"> |
| 50 | {nav.map(([k, label, href]) => ( |
| 51 | <a href={href} class={tab === k ? "on" : ""}> |
| 52 | {label} |
| 53 | </a> |
| 54 | ))} |
| 55 | </div> |
| 56 | </section> |
| 57 | </aside> |
| 58 | <div>{body}</div> |
| 59 | </div>, |
| 60 | ); |
| 61 | } |
| 62 | |
| 63 | const Once: FC<{ title: string; value: string; note: string }> = ({ title, value, note }) => ( |
| 64 | <div class="box" style="border-color:var(--amber)"> |
| 65 | <div class="body"> |
| 66 | <h3>{title}</h3> |
| 67 | <p class="muted">{note}</p> |
| 68 | <div class="row"> |
| 69 | <div class="secret grow">{value}</div> |
| 70 | <CopyButton text={value} size="base" /> |
| 71 | </div> |
| 72 | </div> |
| 73 | </div> |
| 74 | ); |
| 75 | |
| 76 | // ---------------------------------------------------------------- protection form |
| 77 | |
| 78 | const RULE_FLAGS: Array<[keyof ProtectionRules, string, string]> = [ |
| 79 | ["requirePullRequest", "Require a pull request before merging", "Direct pushes are rejected; changes land through reviewed pull requests."], |
| 80 | ["dismissStaleReviews", "Dismiss stale approvals when new commits are pushed", ""], |
| 81 | ["requireLinearHistory", "Require linear history", "Merge commits are rejected; use squash or rebase merges."], |
| 82 | ["allowForcePush", "Allow force pushes", "Off by default for protected branches."], |
| 83 | ["allowDeletion", "Allow deletion", ""], |
| 84 | ["enforceAdmins", "Do not allow administrators to bypass these rules", ""], |
| 85 | ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."], |
| 86 | ["agentsMayMerge", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."], |
| 87 | [ |
| 88 | "allowAgentAutoMerge", |
| 89 | "Allow agents to land pull requests through auto-merge", |
| 90 | "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.", |
| 91 | ], |
| 92 | [ |
| 93 | "agentAutoMergeWithoutApproval", |
| 94 | "Let agents' auto-merges land without an approving review", |
| 95 | "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.", |
| 96 | ], |
| 97 | ]; |
| 98 | |
| 99 | const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => ( |
| 100 | <Form action={`${base}/settings/branches`} t={csrfToken(c)} class="body"> |
| 101 | <input type="hidden" name="original" value={pattern ?? ""} /> |
| 102 | <div class="row" style="align-items:flex-start"> |
| 103 | <div class="field grow"> |
| 104 | <label>Branch name pattern</label> |
| 105 | <input name="pattern" type="text" required value={pattern ?? ""} placeholder="main, release/*, **" class="mono" /> |
| 106 | <div class="hint"> |
| 107 | <code>*</code> matches within a path segment, <code>**</code> across segments. |
| 108 | </div> |
| 109 | </div> |
| 110 | <div class="field" style="width:180px"> |
| 111 | <label>Required approvals</label> |
| 112 | <input name="requiredApprovals" type="number" min={0} max={10} value={String(rules.requiredApprovals ?? 0)} /> |
| 113 | </div> |
| 114 | </div> |
| 115 | <div class="field"> |
| 116 | <label>Required status checks</label> |
| 117 | <input name="requiredChecks" type="text" value={(rules.requiredChecks ?? []).join(", ")} placeholder="ci / test, lint" /> |
| 118 | <div class="hint">Comma-separated check names, exactly as they appear on pull requests.</div> |
| 119 | </div> |
| 120 | <div class="field"> |
| 121 | <div class="kstack" style="gap:8px"> |
| 122 | {RULE_FLAGS.map(([key, label, hint]) => ( |
| 123 | <KCheckbox |
| 124 | name={key} |
| 125 | checked={!!rules[key]} |
| 126 | label={ |
| 127 | <> |
| 128 | {label} |
| 129 | {hint ? <span class="muted small"> — {hint}</span> : null} |
| 130 | </> |
| 131 | } |
| 132 | /> |
| 133 | ))} |
| 134 | </div> |
| 135 | </div> |
| 136 | <div class="field"> |
| 137 | <KCheckbox |
| 138 | name="mergeQueue" |
| 139 | checked={!!rules.mergeQueue} |
| 140 | label={ |
| 141 | <> |
| 142 | Require a merge queue |
| 143 | <span class="muted small"> — 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.</span> |
| 144 | </> |
| 145 | } |
| 146 | /> |
| 147 | <div class="row" style="align-items:flex-start;margin-top:8px"> |
| 148 | <div class="field grow"> |
| 149 | <KSelect |
| 150 | name="mqMethod" |
| 151 | label="Merge method" |
| 152 | block |
| 153 | value={rules.mergeQueue?.method ?? "merge"} |
| 154 | options={[ |
| 155 | { value: "merge", label: "Create a merge commit" }, |
| 156 | { value: "squash", label: "Squash and merge" }, |
| 157 | { value: "rebase", label: "Rebase and merge" }, |
| 158 | ]} |
| 159 | /> |
| 160 | </div> |
| 161 | <div class="field" style="width:180px"> |
| 162 | <label>Build concurrency</label> |
| 163 | <input name="mqConcurrency" type="number" min={1} max={20} value={String(rules.mergeQueue?.buildConcurrency ?? 5)} /> |
| 164 | </div> |
| 165 | <div class="field" style="width:180px"> |
| 166 | <label>Check timeout (minutes)</label> |
| 167 | <input name="mqTimeout" type="number" min={1} max={360} value={String(rules.mergeQueue?.checkTimeoutMinutes ?? 60)} /> |
| 168 | </div> |
| 169 | </div> |
| 170 | </div> |
| 171 | <button class="btn primary">{pattern ? "Save rule" : "Create rule"}</button> |
| 172 | </Form> |
| 173 | ); |
| 174 | |
| 175 | function rulesFrom(f: FormData): ProtectionRules { |
| 176 | const rules: ProtectionRules = {}; |
| 177 | for (const [key] of RULE_FLAGS) if (f.b(key)) (rules as Record<string, unknown>)[key] = true; |
| 178 | const approvals = Math.max(0, Math.min(10, parseInt(f.s("requiredApprovals"), 10) || 0)); |
| 179 | if (approvals) rules.requiredApprovals = approvals; |
| 180 | const checks = f |
| 181 | .s("requiredChecks") |
| 182 | .split(/[,\n]/) |
| 183 | .map((s) => s.trim()) |
| 184 | .filter(Boolean); |
| 185 | if (checks.length) rules.requiredChecks = checks; |
| 186 | if (f.b("mergeQueue")) { |
| 187 | const method = f.s("mqMethod"); |
| 188 | rules.mergeQueue = { |
| 189 | method: method === "squash" || method === "rebase" ? method : "merge", |
| 190 | buildConcurrency: parseInt(f.s("mqConcurrency"), 10) || 5, |
| 191 | checkTimeoutMinutes: parseInt(f.s("mqTimeout"), 10) || 60, |
| 192 | }; |
| 193 | } |
| 194 | return rules; |
| 195 | } |
| 196 | |
| 197 | // ---------------------------------------------------------------- pages |
| 198 | |
| 199 | /** Where the signed-in person may transfer a repository: their own account and the organizations they own or administer. */ |
| 200 | async function transferTargets(c: AppContext, l: Loaded): Promise<KOption[]> { |
| 201 | const p = c.get("principal"); |
| 202 | if (p.type !== "account" || p.account.kind !== "user" || l.caller.kind !== "user") return []; |
| 203 | const me = p.account; |
| 204 | // A personal repository is transferred by its owner only. |
| 205 | if (l.repo.owner_kind !== "org" && l.repo.owner_id !== me.id && !me.site_admin) return []; |
| 206 | const orgs = (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); |
| 207 | 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()); |
| 208 | } |
| 209 | |
| 210 | async function generalPage(c: AppContext, l: Loaded) { |
| 211 | const { repo } = l; |
| 212 | const base = repoUrl(repo); |
| 213 | const t = csrfToken(c); |
| 214 | const [names, targets] = await Promise.all([refNames(l.stub), transferTargets(c, l)]); |
| 215 | const full = `${repo.owner_login}/${repo.name}`; |
| 216 | return page( |
| 217 | c, |
| 218 | l, |
| 219 | "general", |
| 220 | <> |
| 221 | <h1>General</h1> |
| 222 | <Form action={`${base}/settings/general`} t={t} class="box"> |
| 223 | <div class="body"> |
| 224 | <div class="field"> |
| 225 | <label>Repository name</label> |
| 226 | <input name="name" type="text" value={repo.name} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" /> |
| 227 | <div class="hint">Renaming changes the clone URL; update your remotes afterwards.</div> |
| 228 | </div> |
| 229 | <div class="field"> |
| 230 | <label>Description</label> |
| 231 | <input name="description" type="text" value={repo.description} maxlength={350} /> |
| 232 | </div> |
| 233 | <div class="field" style="max-width:320px"> |
| 234 | <label>Default branch</label> |
| 235 | {names.branches.length ? ( |
| 236 | <KSelect |
| 237 | name="default_branch" |
| 238 | block |
| 239 | label="Default branch" |
| 240 | value={names.branches.includes(repo.default_branch) ? repo.default_branch : undefined} |
| 241 | options={names.branches.map((b) => ({ value: b, label: b }))} |
| 242 | /> |
| 243 | ) : ( |
| 244 | <input name="default_branch" type="text" value={repo.default_branch} /> |
| 245 | )} |
| 246 | </div> |
| 247 | <div class="field"> |
| 248 | <label>Visibility</label> |
| 249 | <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "public"] as const).map((v) => ({ value: v, label: v === "public" ? "Public" : "Private" }))} /> |
| 250 | <div class="hint">Private repositories are visible to you, collaborators and organization members; public ones to anyone.</div> |
| 251 | </div> |
| 252 | <button class="btn primary">Save changes</button> |
| 253 | </div> |
| 254 | </Form> |
| 255 | <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> |
| 256 | <div class="head" style="background:var(--red-soft)"> |
| 257 | <h2 class="bad">Danger zone</h2> |
| 258 | </div> |
| 259 | <div class="body"> |
| 260 | <Form action={`${base}/settings/archive`} t={t} class="row between mb" confirm={repo.archived ? "Unarchive this repository?" : "Archive this repository? It becomes read-only."}> |
| 261 | <input type="hidden" name="archived" value={repo.archived ? "0" : "1"} /> |
| 262 | <div> |
| 263 | <b>{repo.archived ? "Unarchive this repository" : "Archive this repository"}</b> |
| 264 | <div class="muted small">{repo.archived ? "Make the repository writable again." : "Mark it read-only: no pushes, issues, pull requests or runs."}</div> |
| 265 | </div> |
| 266 | <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button> |
| 267 | </Form> |
| 268 | <hr /> |
| 269 | <Form action={`${base}/settings/transfer`} t={t} match={full}> |
| 270 | <b>Transfer ownership</b> |
| 271 | <p class="muted small"> |
| 272 | 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. |
| 273 | </p> |
| 274 | {targets.length ? ( |
| 275 | <> |
| 276 | <div class="formrow"> |
| 277 | <div class="field"> |
| 278 | <label for="new_owner">New owner</label> |
| 279 | <KSelect id="new_owner" name="new_owner" label="New owner" placeholder="Choose an owner" options={targets} /> |
| 280 | </div> |
| 281 | <div class="field grow"> |
| 282 | <label for="new_name"> |
| 283 | New name <span class="faint">(optional)</span> |
| 284 | </label> |
| 285 | <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" /> |
| 286 | </div> |
| 287 | </div> |
| 288 | <p class="muted small"> |
| 289 | Type <b class="mono">{full}</b> to confirm. |
| 290 | </p> |
| 291 | <div class="row"> |
| 292 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> |
| 293 | <button class="btn danger">Transfer</button> |
| 294 | </div> |
| 295 | </> |
| 296 | ) : ( |
| 297 | <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> |
| 298 | )} |
| 299 | </Form> |
| 300 | <hr /> |
| 301 | <Form action={`${base}/settings/delete`} t={t} match={full}> |
| 302 | <b>Delete this repository</b> |
| 303 | <p class="muted small"> |
| 304 | This permanently deletes the repository, its history, issues, pull requests, runs and secrets. Type <b class="mono">{full}</b> to confirm. |
| 305 | </p> |
| 306 | <div class="row"> |
| 307 | <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" /> |
| 308 | <button class="btn danger">Delete this repository</button> |
| 309 | </div> |
| 310 | </Form> |
| 311 | </div> |
| 312 | </div> |
| 313 | </>, |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | async function accessPage(c: AppContext, l: Loaded, invite?: { url: string; email: string }) { |
| 318 | const { repo } = l; |
| 319 | const base = repoUrl(repo); |
| 320 | const t = csrfToken(c); |
| 321 | const [collaborators, invites] = await Promise.all([c.get("dir").collaborators(repo.id), c.get("dir").invites({ repoId: repo.id, pending: true })]); |
| 322 | return page( |
| 323 | c, |
| 324 | l, |
| 325 | "access", |
| 326 | <> |
| 327 | <h1>Collaborators</h1> |
| 328 | <p class="muted"> |
| 329 | {repo.owner_kind === "org" ? ( |
| 330 | <> |
| 331 | 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. |
| 332 | </> |
| 333 | ) : ( |
| 334 | <> |
| 335 | <b>{repo.owner_login}</b> owns this repository. Add collaborators to share it with friends or agents. |
| 336 | </> |
| 337 | )} |
| 338 | </p> |
| 339 | {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} |
| 340 | <Form action={`${base}/settings/access/people`} t={t} class="box"> |
| 341 | <div class="head"> |
| 342 | <h3>{raw(ICONS.userPlus)} Add people</h3> |
| 343 | </div> |
| 344 | <div class="body"> |
| 345 | <div class="formrow"> |
| 346 | <div class="field grow"> |
| 347 | <label for="who">Username or email</label> |
| 348 | <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="diffy, review-bot or name@example.com" /> |
| 349 | </div> |
| 350 | <div class="field"> |
| 351 | <label for="role">Role</label> |
| 352 | <KSelect id="role" name="role" label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> |
| 353 | </div> |
| 354 | <button class="btn primary">Add</button> |
| 355 | </div> |
| 356 | <div class="hint">A username (a person or an agent) gets access right away and a heads-up. An email address gets an invitation: they get access when they accept it, creating an account first if they don't have one.</div> |
| 357 | </div> |
| 358 | </Form> |
| 359 | <div class="box"> |
| 360 | {collaborators.length ? ( |
| 361 | <table class="tbl"> |
| 362 | <thead> |
| 363 | <tr> |
| 364 | <th>Account</th> |
| 365 | <th>Role</th> |
| 366 | <th /> |
| 367 | </tr> |
| 368 | </thead> |
| 369 | <tbody> |
| 370 | {collaborators.map((x) => ( |
| 371 | <tr> |
| 372 | <td> |
| 373 | <Actor login={x.account.login} kind={x.account.kind} /> |
| 374 | </td> |
| 375 | <td> |
| 376 | <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> |
| 377 | <input type="hidden" name="login" value={x.account.login} /> |
| 378 | <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> |
| 379 | <button class="btn sm">Save</button> |
| 380 | </Form> |
| 381 | </td> |
| 382 | <td class="right"> |
| 383 | <Form action={`${base}/settings/access`} t={t} class="inline" confirm={`Remove ${x.account.login}'s access?`}> |
| 384 | <input type="hidden" name="login" value={x.account.login} /> |
| 385 | <input type="hidden" name="role" value="remove" /> |
| 386 | <button class="btn sm danger">Remove</button> |
| 387 | </Form> |
| 388 | </td> |
| 389 | </tr> |
| 390 | ))} |
| 391 | </tbody> |
| 392 | </table> |
| 393 | ) : ( |
| 394 | <div class="empty">No direct collaborators.</div> |
| 395 | )} |
| 396 | </div> |
| 397 | <PendingInvites invites={invites} revoke={(id) => `${base}/settings/access/invites/${id}/revoke`} t={t} /> |
| 398 | <div class="box"> |
| 399 | <div class="body small muted"> |
| 400 | <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. |
| 401 | </div> |
| 402 | </div> |
| 403 | </>, |
| 404 | ); |
| 405 | } |
| 406 | |
| 407 | async function branchesPage(c: AppContext, l: Loaded) { |
| 408 | const base = repoUrl(l.repo); |
| 409 | const t = csrfToken(c); |
| 410 | const rules = await l.stub.listProtections(); |
| 411 | return page( |
| 412 | c, |
| 413 | l, |
| 414 | "branches", |
| 415 | <> |
| 416 | <h1>Branch protection</h1> |
| 417 | <p class="muted"> |
| 418 | 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. |
| 419 | </p> |
| 420 | {rules.map((r) => ( |
| 421 | <details class="box"> |
| 422 | <summary class="head" style="cursor:pointer"> |
| 423 | {raw(ICONS.shield)} <b class="mono">{r.pattern}</b> |
| 424 | <span class="muted small grow"> |
| 425 | {[ |
| 426 | r.rules.requirePullRequest ? "PR required" : "", |
| 427 | r.rules.requiredApprovals ? `${r.rules.requiredApprovals} approval${r.rules.requiredApprovals === 1 ? "" : "s"}` : "", |
| 428 | r.rules.requiredChecks?.length ? `${r.rules.requiredChecks.length} required check${r.rules.requiredChecks.length === 1 ? "" : "s"}` : "", |
| 429 | r.rules.requireLinearHistory ? "linear history" : "", |
| 430 | r.rules.mergeQueue ? "merge queue" : "", |
| 431 | r.rules.enforceAdmins ? "admins included" : "", |
| 432 | ] |
| 433 | .filter(Boolean) |
| 434 | .join(" · ") || "basic protection"} |
| 435 | </span> |
| 436 | <Form action={`${base}/settings/branches/delete`} t={t} class="inline" confirm={`Delete the rule for ${r.pattern}?`}> |
| 437 | <input type="hidden" name="pattern" value={r.pattern} /> |
| 438 | <button class="btn sm danger">Delete</button> |
| 439 | </Form> |
| 440 | </summary> |
| 441 | <RuleForm c={c} base={base} pattern={r.pattern} rules={r.rules} /> |
| 442 | </details> |
| 443 | ))} |
| 444 | {rules.length === 0 ? <div class="blank mb">No branch protection rules yet.</div> : null} |
| 445 | <div class="box"> |
| 446 | <div class="head"> |
| 447 | <h3>Add a rule</h3> |
| 448 | </div> |
| 449 | <RuleForm c={c} base={base} rules={{ requirePullRequest: true, requiredApprovals: 1 }} /> |
| 450 | </div> |
| 451 | </>, |
| 452 | ); |
| 453 | } |
| 454 | |
| 455 | /** GitHub's Settings → Actions → General → Workflow permissions: what a job's GITHUB_TOKEN gets by default. */ |
| 456 | async function actionsPage(c: AppContext, l: Loaded) { |
| 457 | const base = repoUrl(l.repo); |
| 458 | const settings = await l.stub.workflowSettings(); |
| 459 | return page( |
| 460 | c, |
| 461 | l, |
| 462 | "actions", |
| 463 | <> |
| 464 | <h1>Actions</h1> |
| 465 | <Form action={`${base}/settings/actions`} t={csrfToken(c)} class="box"> |
| 466 | <div class="head"> |
| 467 | <h2>Workflow permissions</h2> |
| 468 | </div> |
| 469 | <div class="body"> |
| 470 | <p class="muted small m0"> |
| 471 | 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. |
| 472 | </p> |
| 473 | <div class="field"> |
| 474 | <KRadio |
| 475 | name="default_permissions" |
| 476 | value={settings.defaultPermissions} |
| 477 | items={[ |
| 478 | { 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." }, |
| 479 | { value: "write", label: "Read and write permissions", description: "Jobs can read and write in every scope unless the workflow asks for less." }, |
| 480 | ]} |
| 481 | /> |
| 482 | </div> |
| 483 | <div class="field"> |
| 484 | <KCheckbox |
| 485 | name="can_approve" |
| 486 | checked={settings.canApprovePullRequests} |
| 487 | label={ |
| 488 | <> |
| 489 | Allow GitHub Actions to create and approve pull requests |
| 490 | <span class="muted small"> — approvals from workflows never count toward required reviews.</span> |
| 491 | </> |
| 492 | } |
| 493 | /> |
| 494 | </div> |
| 495 | <div class="field"> |
| 496 | <KCheckbox |
| 497 | name="agent_secrets" |
| 498 | checked={settings.agentRunSecrets} |
| 499 | label={ |
| 500 | <> |
| 501 | Give runs of agent code the repository's and organization's secrets |
| 502 | <span class="muted small"> — 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.</span> |
| 503 | </> |
| 504 | } |
| 505 | /> |
| 506 | </div> |
| 507 | <button class="btn primary">Save</button> |
| 508 | </div> |
| 509 | </Form> |
| 510 | </>, |
| 511 | ); |
| 512 | } |
| 513 | |
| 514 | async function secretsPage(c: AppContext, l: Loaded) { |
| 515 | const base = repoUrl(l.repo); |
| 516 | const t = csrfToken(c); |
| 517 | const [secrets, vars] = await Promise.all([l.stub.secretNames(), l.stub.variables(false)]); |
| 518 | return page( |
| 519 | c, |
| 520 | l, |
| 521 | "secrets", |
| 522 | <> |
| 523 | <h1>Secrets and variables</h1> |
| 524 | <p class="muted"> |
| 525 | 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. |
| 526 | </p> |
| 527 | <div class="box"> |
| 528 | <div class="head"> |
| 529 | <h2>{raw(ICONS.key)} Repository secrets</h2> |
| 530 | </div> |
| 531 | {secrets.length ? ( |
| 532 | <ul class="list"> |
| 533 | {secrets.map((s) => ( |
| 534 | <li style="align-items:center"> |
| 535 | <span class="mono grow"> |
| 536 | <b>{s.name}</b> |
| 537 | </span> |
| 538 | <span class="muted small">updated {ago(s.updatedAt)}</span> |
| 539 | <Form action={`${base}/settings/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}> |
| 540 | <input type="hidden" name="name" value={s.name} /> |
| 541 | <button class="btn sm danger">Delete</button> |
| 542 | </Form> |
| 543 | </li> |
| 544 | ))} |
| 545 | </ul> |
| 546 | ) : ( |
| 547 | <div class="empty">No secrets yet.</div> |
| 548 | )} |
| 549 | <Form action={`${base}/settings/secrets`} t={t} class="body" style="border-top:1px solid var(--border2)"> |
| 550 | <div class="field" style="max-width:360px"> |
| 551 | <label>Name</label> |
| 552 | <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="API_TOKEN" autocomplete="off" /> |
| 553 | </div> |
| 554 | <div class="field"> |
| 555 | <label>Value</label> |
| 556 | <textarea name="value" required class="mono" style="min-height:80px" autocomplete="off" spellcheck={false}></textarea> |
| 557 | <div class="hint">Setting an existing name replaces its value.</div> |
| 558 | </div> |
| 559 | <button class="btn primary">Save secret</button> |
| 560 | </Form> |
| 561 | </div> |
| 562 | <div class="box"> |
| 563 | <div class="head"> |
| 564 | <h2>Variables</h2> |
| 565 | </div> |
| 566 | {Object.keys(vars).length ? ( |
| 567 | <table class="tbl"> |
| 568 | <tbody> |
| 569 | {Object.entries(vars) |
| 570 | .sort(([a], [b]) => a.localeCompare(b)) |
| 571 | .map(([k, v]) => ( |
| 572 | <tr> |
| 573 | <td class="mono"> |
| 574 | <b>{k}</b> |
| 575 | </td> |
| 576 | <td class="mono small" style="word-break:break-all"> |
| 577 | {v} |
| 578 | </td> |
| 579 | <td class="right"> |
| 580 | <Form action={`${base}/settings/variables/delete`} t={t} class="inline" confirm={`Delete variable ${k}?`}> |
| 581 | <input type="hidden" name="name" value={k} /> |
| 582 | <button class="btn sm danger">Delete</button> |
| 583 | </Form> |
| 584 | </td> |
| 585 | </tr> |
| 586 | ))} |
| 587 | </tbody> |
| 588 | </table> |
| 589 | ) : ( |
| 590 | <div class="empty">No variables yet.</div> |
| 591 | )} |
| 592 | <Form action={`${base}/settings/variables`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)"> |
| 593 | <div class="field m0" style="min-width:200px"> |
| 594 | <label>Name</label> |
| 595 | <input name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" /> |
| 596 | </div> |
| 597 | <div class="field grow m0"> |
| 598 | <label>Value</label> |
| 599 | <input name="value" type="text" class="mono" /> |
| 600 | </div> |
| 601 | <button class="btn primary">Save variable</button> |
| 602 | </Form> |
| 603 | </div> |
| 604 | </>, |
| 605 | ); |
| 606 | } |
| 607 | |
| 608 | async function webhooksPage(c: AppContext, l: Loaded, created?: { secret: string; url: string }) { |
| 609 | const base = repoUrl(l.repo); |
| 610 | const t = csrfToken(c); |
| 611 | const hooks = await l.stub.webhooks(); |
| 612 | return page( |
| 613 | c, |
| 614 | l, |
| 615 | "webhooks", |
| 616 | <> |
| 617 | <h1>Webhooks</h1> |
| 618 | <p class="muted"> |
| 619 | Diffinery POSTs JSON events to these URLs, signed with an HMAC-SHA256 of the body using the webhook's secret. |
| 620 | </p> |
| 621 | {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} |
| 622 | <div class="box"> |
| 623 | {hooks.length ? ( |
| 624 | <ul class="list"> |
| 625 | {hooks.map((h) => ( |
| 626 | <li style="align-items:center"> |
| 627 | <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> |
| 628 | <div class="grow"> |
| 629 | <b class="mono" style="word-break:break-all"> |
| 630 | {h.url} |
| 631 | </b> |
| 632 | <div class="meta"> |
| 633 | {h.events.join(", ")} · {h.active ? "active" : "inactive"} |
| 634 | {h.lastDeliveryAt ? ( |
| 635 | <> |
| 636 | {" "} |
| 637 | · last delivery {h.lastStatus ?? "?"} <Time at={h.lastDeliveryAt} /> |
| 638 | </> |
| 639 | ) : ( |
| 640 | " · no deliveries yet" |
| 641 | )} |
| 642 | </div> |
| 643 | </div> |
| 644 | <Form action={`${base}/settings/webhooks/delete`} t={t} class="inline" confirm="Delete this webhook?"> |
| 645 | <input type="hidden" name="id" value={h.id} /> |
| 646 | <button class="btn sm danger">Delete</button> |
| 647 | </Form> |
| 648 | </li> |
| 649 | ))} |
| 650 | </ul> |
| 651 | ) : ( |
| 652 | <div class="empty">No webhooks yet.</div> |
| 653 | )} |
| 654 | </div> |
| 655 | <Form action={`${base}/settings/webhooks`} t={t} class="box"> |
| 656 | <div class="head"> |
| 657 | <h3>Add a webhook</h3> |
| 658 | </div> |
| 659 | <div class="body"> |
| 660 | <div class="field"> |
| 661 | <label>Payload URL</label> |
| 662 | <input name="url" type="url" required placeholder="https://example.com/hooks/diffinery" pattern="https://.*" /> |
| 663 | </div> |
| 664 | <div class="field"> |
| 665 | <label>Events</label> |
| 666 | <div class="row" style="gap:8px 18px"> |
| 667 | <KCheckbox name="events" value="*" checked label="Everything" /> |
| 668 | {WEBHOOK_EVENTS.map((e) => ( |
| 669 | <KCheckbox name="events" value={e} label={<span class="mono small">{e}</span>} /> |
| 670 | ))} |
| 671 | </div> |
| 672 | </div> |
| 673 | <div class="field" style="max-width:420px"> |
| 674 | <label> |
| 675 | Secret <span class="faint">(optional — generated when empty)</span> |
| 676 | </label> |
| 677 | <input name="secret" type="text" autocomplete="off" class="mono" /> |
| 678 | </div> |
| 679 | <button class="btn primary">Add webhook</button> |
| 680 | </div> |
| 681 | </Form> |
| 682 | </>, |
| 683 | ); |
| 684 | } |
| 685 | |
| 686 | // ---------------------------------------------------------------- routes |
| 687 | |
| 688 | export function registerRepoSettings(web: WebApp): void { |
| 689 | web.get("/:owner/:repo/settings", async (c) => generalPage(c, await load(c))); |
| 690 | web.get("/:owner/:repo/settings/access", async (c) => accessPage(c, await load(c))); |
| 691 | web.get("/:owner/:repo/settings/branches", async (c) => branchesPage(c, await load(c))); |
| 692 | web.get("/:owner/:repo/settings/actions", async (c) => actionsPage(c, await load(c))); |
| 693 | web.get("/:owner/:repo/settings/secrets", async (c) => secretsPage(c, await load(c))); |
| 694 | web.get("/:owner/:repo/settings/webhooks", async (c) => webhooksPage(c, await load(c))); |
| 695 | |
| 696 | post(web, "/:owner/:repo/settings/actions", async (c, f) => { |
| 697 | const l = await load(c); |
| 698 | return act( |
| 699 | c, |
| 700 | `${repoUrl(l.repo)}/settings/actions`, |
| 701 | async () => { |
| 702 | const defaultPermissions = f.s("default_permissions") === "write" ? "write" : "read"; |
| 703 | await l.stub.setWorkflowSettings({ defaultPermissions, canApprovePullRequests: f.s("can_approve") === "1", agentRunSecrets: f.s("agent_secrets") === "1" }, l.caller); |
| 704 | }, |
| 705 | "Actions settings saved.", |
| 706 | ); |
| 707 | }); |
| 708 | |
| 709 | post(web, "/:owner/:repo/settings/general", async (c, f) => { |
| 710 | const l = await load(c); |
| 711 | const dir = c.get("dir"); |
| 712 | return act( |
| 713 | c, |
| 714 | `${repoUrl(l.repo)}/settings`, |
| 715 | async () => { |
| 716 | const name = f.s("name").trim(); |
| 717 | const visibility = (["private", "public"] as const).find((v) => v === f.s("visibility")); |
| 718 | const defaultBranch = f.s("default_branch").trim(); |
| 719 | 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"); |
| 720 | const updated = await dir.updateRepo(l.repo, { |
| 721 | name: name && name !== l.repo.name ? name : undefined, |
| 722 | description: f.s("description").trim(), |
| 723 | visibility: visibility && visibility !== l.repo.visibility ? visibility : undefined, |
| 724 | defaultBranch: defaultBranch && defaultBranch !== l.repo.default_branch ? defaultBranch : undefined, |
| 725 | }); |
| 726 | audit(c, l, "repo.update", { name: updated.name, visibility: updated.visibility, defaultBranch: updated.default_branch }); |
| 727 | return `${repoUrl(updated)}/settings`; |
| 728 | }, |
| 729 | "Settings saved.", |
| 730 | ); |
| 731 | }); |
| 732 | |
| 733 | post(web, "/:owner/:repo/settings/archive", async (c, f) => { |
| 734 | const l = await load(c, { whileArchived: true }); |
| 735 | const archived = f.s("archived") === "1"; |
| 736 | return act( |
| 737 | c, |
| 738 | `${repoUrl(l.repo)}/settings`, |
| 739 | async () => { |
| 740 | await c.get("dir").updateRepo(l.repo, { archived }); |
| 741 | audit(c, l, archived ? "repo.archive" : "repo.unarchive"); |
| 742 | }, |
| 743 | archived ? "Repository archived." : "Repository unarchived.", |
| 744 | ); |
| 745 | }); |
| 746 | |
| 747 | post(web, "/:owner/:repo/settings/delete", async (c, f) => { |
| 748 | const l = await load(c, { whileArchived: true }); |
| 749 | requireAccount(c); |
| 750 | const full = `${l.repo.owner_login}/${l.repo.name}`; |
| 751 | return act( |
| 752 | c, |
| 753 | `${repoUrl(l.repo)}/settings`, |
| 754 | async () => { |
| 755 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`); |
| 756 | await c.get("dir").deleteRepo(l.repo); |
| 757 | audit(c, l, "repo.delete"); |
| 758 | return "/"; |
| 759 | }, |
| 760 | `Deleted ${full}.`, |
| 761 | ); |
| 762 | }); |
| 763 | |
| 764 | post(web, "/:owner/:repo/settings/transfer", async (c, f) => { |
| 765 | const l = await load(c, { whileArchived: true }); |
| 766 | const me = requireAccount(c); |
| 767 | const full = `${l.repo.owner_login}/${l.repo.name}`; |
| 768 | return act( |
| 769 | c, |
| 770 | `${repoUrl(l.repo)}/settings`, |
| 771 | async () => { |
| 772 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm the transfer`); |
| 773 | const dir = c.get("dir"); |
| 774 | const target = await dir.accountByLogin(f.s("new_owner").trim()); |
| 775 | if (!target) throw new HttpError(422, "choose the new owner"); |
| 776 | const moved = await dir.transferRepo(l.repo, me, target, f.s("new_name").trim() || undefined); |
| 777 | audit(c, { ...l, repo: moved }, "repo.transfer", { from: full, to: `${moved.owner_login}/${moved.name}` }); |
| 778 | flash(c, "notice", `Transferred ${full} to ${moved.owner_login}/${moved.name}.`); |
| 779 | return `${repoUrl(moved)}/settings`; |
| 780 | }, |
| 781 | ); |
| 782 | }); |
| 783 | |
| 784 | post(web, "/:owner/:repo/settings/access", async (c, f) => { |
| 785 | const l = await load(c); |
| 786 | const dir = c.get("dir"); |
| 787 | return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 788 | const who = await dir.accountByLogin(f.s("login").trim()); |
| 789 | if (!who || who.kind === "org") throw new HttpError(404, "no person or agent with that username"); |
| 790 | const roleIn = f.s("role"); |
| 791 | const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null); |
| 792 | if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role"); |
| 793 | await dir.setCollaborator(l.repo.id, who.id, role); |
| 794 | audit(c, l, role ? "repo.collaborator.set" : "repo.collaborator.remove", { login: who.login, role }); |
| 795 | }); |
| 796 | }); |
| 797 | |
| 798 | post(web, "/:owner/:repo/settings/access/invites/:id/revoke", async (c) => { |
| 799 | const l = await load(c); |
| 800 | return act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 801 | 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"); |
| 802 | audit(c, l, "repo.invite.revoke", { id: prm(c, "id") }); |
| 803 | }, "Invitation revoked."); |
| 804 | }); |
| 805 | |
| 806 | post(web, "/:owner/:repo/settings/access/people", async (c, f) => { |
| 807 | const l = await load(c); |
| 808 | const me = requireAccount(c); |
| 809 | const role = ROLES.find((r) => r === f.s("role")) ?? "write"; |
| 810 | let invited: { email: string; url: string } | undefined; |
| 811 | const res = await act(c, `${repoUrl(l.repo)}/settings/access`, async () => { |
| 812 | const r = await c.get("dir").addRepoPerson(l.repo, me, f.s("who"), role); |
| 813 | if ("added" in r) { |
| 814 | flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`); |
| 815 | audit(c, l, "repo.collaborator.set", { login: r.added.login, role }); |
| 816 | } else { |
| 817 | invited = r.invited; |
| 818 | audit(c, l, "repo.invite", { email: r.invited.email, role }); |
| 819 | } |
| 820 | }); |
| 821 | return invited ? accessPage(c, l, invited) : res; |
| 822 | }); |
| 823 | |
| 824 | post(web, "/:owner/:repo/settings/branches", async (c, f) => { |
| 825 | const l = await load(c); |
| 826 | return act( |
| 827 | c, |
| 828 | `${repoUrl(l.repo)}/settings/branches`, |
| 829 | async () => { |
| 830 | const pattern = f.s("pattern").trim(); |
| 831 | if (!pattern || /\s/.test(pattern)) throw new HttpError(422, "enter a branch name pattern"); |
| 832 | const original = f.s("original").trim(); |
| 833 | const rules = rulesFrom(f); |
| 834 | // The form doesn't edit which app must report each required check; keep those bindings (set through the API). |
| 835 | const before = (await l.stub.listProtections()).find((p) => p.pattern === (original || pattern))?.rules.requiredCheckSources; |
| 836 | if (before && rules.requiredChecks?.length) { |
| 837 | const kept = Object.fromEntries(Object.entries(before).filter(([name]) => rules.requiredChecks!.includes(name))); |
| 838 | if (Object.keys(kept).length) rules.requiredCheckSources = kept; |
| 839 | } |
| 840 | if (original && original !== pattern) await l.stub.setProtection(original, null, l.caller); |
| 841 | await l.stub.setProtection(pattern, rules, l.caller); |
| 842 | audit(c, l, "repo.protection.set", { pattern, rules }); |
| 843 | }, |
| 844 | "Branch protection saved.", |
| 845 | ); |
| 846 | }); |
| 847 | |
| 848 | post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => { |
| 849 | const l = await load(c); |
| 850 | return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => { |
| 851 | await l.stub.setProtection(f.s("pattern"), null, l.caller); |
| 852 | audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") }); |
| 853 | }); |
| 854 | }); |
| 855 | |
| 856 | post(web, "/:owner/:repo/settings/secrets", async (c, f) => { |
| 857 | const l = await load(c); |
| 858 | return act( |
| 859 | c, |
| 860 | `${repoUrl(l.repo)}/settings/secrets`, |
| 861 | async () => { |
| 862 | const name = f.s("name").trim(); |
| 863 | try { |
| 864 | await l.stub.setSecret(name, f.s("value")); |
| 865 | } catch (e) { |
| 866 | const s = statusOf(e); |
| 867 | if (s.status < 500) throw e; |
| 868 | throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`); |
| 869 | } |
| 870 | audit(c, l, "repo.secret.set", { name: name.toUpperCase() }); |
| 871 | }, |
| 872 | "Secret saved.", |
| 873 | ); |
| 874 | }); |
| 875 | |
| 876 | post(web, "/:owner/:repo/settings/secrets/delete", async (c, f) => { |
| 877 | const l = await load(c); |
| 878 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 879 | await l.stub.deleteSecret(f.s("name")); |
| 880 | audit(c, l, "repo.secret.delete", { name: f.s("name") }); |
| 881 | }); |
| 882 | }); |
| 883 | |
| 884 | post(web, "/:owner/:repo/settings/variables", async (c, f) => { |
| 885 | const l = await load(c); |
| 886 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 887 | const name = f.s("name").trim(); |
| 888 | if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new HttpError(422, "invalid variable name"); |
| 889 | await l.stub.setVariable(name, f.s("value")); |
| 890 | audit(c, l, "repo.variable.set", { name }); |
| 891 | }); |
| 892 | }); |
| 893 | |
| 894 | post(web, "/:owner/:repo/settings/variables/delete", async (c, f) => { |
| 895 | const l = await load(c); |
| 896 | return act(c, `${repoUrl(l.repo)}/settings/secrets`, async () => { |
| 897 | await l.stub.setVariable(f.s("name"), null); |
| 898 | audit(c, l, "repo.variable.delete", { name: f.s("name") }); |
| 899 | }); |
| 900 | }); |
| 901 | |
| 902 | post(web, "/:owner/:repo/settings/webhooks", async (c, f) => { |
| 903 | const l = await load(c); |
| 904 | const url = f.s("url").trim(); |
| 905 | const events = f.all("events").filter((e) => e === "*" || WEBHOOK_EVENTS.includes(e)); |
| 906 | try { |
| 907 | const hook = await l.stub.createWebhook(url, events.includes("*") ? ["*"] : events, f.s("secret").trim() || undefined); |
| 908 | audit(c, l, "repo.webhook.create", { url, events }); |
| 909 | return webhooksPage(c, l, { secret: hook.secret, url }); |
| 910 | } catch (e) { |
| 911 | const s = statusOf(e); |
| 912 | if (s.status >= 500) throw e; |
| 913 | return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { |
| 914 | throw e; |
| 915 | }); |
| 916 | } |
| 917 | }); |
| 918 | |
| 919 | post(web, "/:owner/:repo/settings/webhooks/delete", async (c, f) => { |
| 920 | const l = await load(c); |
| 921 | return act(c, `${repoUrl(l.repo)}/settings/webhooks`, async () => { |
| 922 | await l.stub.deleteWebhook(f.s("id")); |
| 923 | audit(c, l, "repo.webhook.delete", { id: f.s("id") }); |
| 924 | }); |
| 925 | }); |
| 926 | } |