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.

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