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.

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