Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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