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.

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