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.

1107 lines · 47.0 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { Child, FC } from "hono/jsx";
4import type { AppContext } from "../auth/principal.js";
5import { loadRepo } from "../http/common.js";
6import { HttpError } from "../lib/util.js";
7import { atLeast, globToRegExp } from "../repo/types.js";
8import { ICONS } from "./assets.js";
9import { csrfToken, type FormData } from "./csrf.js";
10import {
11 CommitRow,
12 CopyButton,
13 DiffStats,
14 DiffView,
15 FileSummary,
16 Form,
17 Markdown,
18 Pager,
19 RefSwitcher,
20 Time,
21 act,
22 cloneUrl,
23 encPath,
24 firstLine,
25 fmtBytes,
26 pageNum,
27 post,
28 qs,
29 refNames,
30 render,
31 repoUrl,
32 restOfPath,
33 shortSha,
34 splitRefPath,
35 statusOf,
36 Status,
37 type DiffFile,
38 type WebApp,
39 prm,
40} from "./ui.js";
41
42type Loaded = Awaited<ReturnType<typeof loadRepo>>;
43
44const IMAGE_TYPES: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", bmp: "image/bmp", svg: "image/svg+xml" };
45const MAX_RENDER = 1024 * 1024;
46
47const extOf = (path: string): string => (/\.([A-Za-z0-9]+)$/.exec(path)?.[1] ?? "").toLowerCase();
48const isMarkdown = (path: string): boolean => /\.(md|markdown|mdown|mkd)$/i.test(path);
49const decoder = () => new TextDecoder();
50
51/** Normalise a user-supplied repository path; null when invalid. */
52function cleanPath(p: string): string | null {
53 const segs = p.replace(/\\/g, "/").split("/").filter((s) => s && s !== ".");
54 if (!segs.length || segs.some((s) => s === ".." || s === ".git")) return null;
55 return segs.join("/");
56}
57
58const Crumbs: FC<{ base: string; ref: string; path: string; last?: boolean }> = ({ base, ref, path, last }) => {
59 const segs = path ? path.split("/") : [];
60 return (
61 <div class="crumbs">
62 <a href={`${base}/tree/${encPath(ref)}`}>{base.split("/")[2]}</a>
63 {segs.map((s, i) => (
64 <>
65 <span class="faint">/</span>
66 {i === segs.length - 1 && last !== false ? <b>{s}</b> : <a href={`${base}/tree/${encPath(ref)}/${encPath(segs.slice(0, i + 1).join("/"))}`}>{s}</a>}
67 </>
68 ))}
69 </div>
70 );
71};
72
73const CloneBox: FC<{ url: string }> = ({ url }) => (
74 <div class="clone">
75 <input type="text" readonly value={url} aria-label="Clone URL" />
76 <CopyButton text={url} />
77 </div>
78);
79
80const QuickSetup: FC<{ c: AppContext; l: Loaded }> = ({ c, l }) => {
81 const url = cloneUrl(c, l.repo);
82 const b = l.repo.default_branch;
83 const base = repoUrl(l.repo);
84 const canWrite = atLeast(l.caller.role, "write");
85 return (
86 <div style="max-width:900px">
87 <div class="box">
88 <div class="head">
89 <h2>Quick setup</h2>
90 <span class="muted small">This repository is empty.</span>
91 </div>
92 <div class="body">
93 <CloneBox url={url} />
94 {canWrite ? (
95 <p class="mt muted">
96 Get started by <a href={`${base}/new/${encPath(b)}?filename=README.md`}>creating a README on the web</a>, or push an existing repository from the command line.
97 </p>
98 ) : null}
99 <h3 class="mt">…create a new repository on the command line</h3>
100 <pre class="cmd">{`echo "# ${l.repo.name}" >> README.md\ngit init -b ${b}\ngit add README.md\ngit commit -m "first commit"\ngit remote add origin ${url}\ngit push -u origin ${b}`}</pre>
101 <h3>…or push an existing repository</h3>
102 <pre class="cmd">{`git remote add origin ${url}\ngit push -u origin ${b}`}</pre>
103 <h3>Authentication</h3>
104 <p class="muted">
105 When Git asks for credentials, use your username and a <a href="/settings/tokens">personal access token</a> as the password. To avoid typing it every time, run <code>git config --global credential.helper store</code> (or use
106 your OS keychain helper). Git 2.26 or newer is required.
107 </p>
108 <p class="muted m0">
109 Workflows in <code>.github/workflows/*.yml</code> run automatically on push. AI agents should use their own <a href="/settings/agents">agent account and token</a>.
110 </p>
111 </div>
112 </div>
113 </div>
114 );
115};
116
117async function treePage(c: AppContext, l: Loaded, ref: string, path: string, root: boolean): Promise<Response> {
118 const { repo, caller, stub } = l;
119 const base = repoUrl(repo);
120 const [tree, names, stats, last] = await Promise.all([
121 stub.tree(ref, path),
122 refNames(stub),
123 root && !path ? stub.stats() : Promise.resolve(null),
124 !path ? stub.log(ref, { limit: 1 }).catch(() => []) : Promise.resolve([]),
125 ]);
126 const counts = stats ? { issues: stats.openIssues, pulls: stats.openPulls } : undefined;
127 if (!tree) {
128 if (!names.branches.length && !names.tags.length) {
129 return render(c, { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", counts } }, <QuickSetup c={c} l={l} />);
130 }
131 const blob = path ? await stub.blob(ref, path).catch(() => null) : null;
132 if (blob) return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 302);
133 throw new HttpError(404, "path not found");
134 }
135 const readme = tree.items.find((i) => i.type === "blob" && /^readme(\.(md|markdown|txt))?$/i.test(i.name));
136 const readmeBlob = readme && (readme.size ?? 0) < MAX_RENDER ? await stub.blob(ref, readme.path).catch(() => null) : null;
137 const head = last[0];
138 const canWrite = atLeast(caller.role, "write") && names.branches.includes(ref);
139 const main = (
140 <div>
141 <div class="row between mb">
142 <div class="row">
143 <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/tree/${encPath(r)}${path ? `/${encPath(path)}` : ""}`} />
144 {path ? <Crumbs base={base} ref={ref} path={path} /> : null}
145 {!path ? (
146 <span class="muted small">
147 <a href={`${base}/branches`}>
148 {raw(ICONS.branch)} {names.branches.length} branch{names.branches.length === 1 ? "" : "es"}
149 </a>{" "}
150 ·{" "}
151 <a href={`${base}/tags`}>
152 {raw(ICONS.tag)} {names.tags.length} tag{names.tags.length === 1 ? "" : "s"}
153 </a>
154 </span>
155 ) : null}
156 </div>
157 <div class="row">
158 {canWrite ? (
159 <a class="btn sm" href={`${base}/new/${encPath(ref)}${path ? `/${encPath(path)}` : ""}`}>
160 {raw(ICONS.plus)} Add file
161 </a>
162 ) : null}
163 <a class="btn sm" href={`${base}/commits/${encPath(ref)}${path ? qs({ path }) : ""}`}>
164 {raw(ICONS.history)} History
165 </a>
166 </div>
167 </div>
168 <div class="box">
169 {head ? (
170 <div class="head small">
171 <b>{head.author.name}</b>
172 <a href={`${base}/commit/${head.sha}`} class="grow" style="color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
173 {firstLine(head.message)}
174 </a>
175 <a class="sha" href={`${base}/commit/${head.sha}`}>
176 {shortSha(head.sha)}
177 </a>
178 <Time at={new Date(head.committer.timestamp * 1000).toISOString()} />
179 </div>
180 ) : null}
181 <table class="files">
182 {path ? (
183 <tr>
184 <td class="ic" />
185 <td colspan={2}>
186 <a href={`${base}/tree/${encPath(ref)}${path.includes("/") ? `/${encPath(path.split("/").slice(0, -1).join("/"))}` : ""}`}>..</a>
187 </td>
188 </tr>
189 ) : null}
190 {tree.items.map((it) => (
191 <tr>
192 <td class={`ic ${it.type === "tree" ? "ic-dir" : ""}`}>{raw(it.type === "tree" ? ICONS.dir : it.type === "commit" ? ICONS.submodule : ICONS.file)}</td>
193 <td>
194 {it.type === "commit" ? (
195 <span>
196 {it.name} <span class="faint mono small">@ {shortSha(it.oid)}</span>
197 </span>
198 ) : (
199 <a href={`${base}/${it.type === "tree" ? "tree" : "blob"}/${encPath(ref)}/${encPath(it.path)}`}>{it.name}</a>
200 )}
201 </td>
202 <td class="sz">{it.type === "blob" ? fmtBytes(it.size) : ""}</td>
203 </tr>
204 ))}
205 </table>
206 {tree.items.length === 0 ? <div class="empty">This directory is empty.</div> : null}
207 </div>
208 {readmeBlob && !readmeBlob.binary ? (
209 <div class="box readme">
210 <div class="head">
211 {raw(ICONS.file)} <b>{readme!.name}</b>
212 </div>
213 <div class="body">
214 {isMarkdown(readme!.name) ? (
215 <Markdown text={decoder().decode(readmeBlob.content)} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path }} />
216 ) : (
217 <pre style="white-space:pre-wrap;margin:0">{decoder().decode(readmeBlob.content)}</pre>
218 )}
219 </div>
220 </div>
221 ) : null}
222 </div>
223 );
224 if (path) return render(c, { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } }, main);
225 return render(
226 c,
227 { title: `${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref, counts } },
228 <div class="grid">
229 {main}
230 <aside class="side">
231 <section>
232 <h3>Clone</h3>
233 <CloneBox url={cloneUrl(c, repo)} />
234 <p class="small muted" style="margin-top:6px">
235 Use a <a href="/settings/tokens">token</a> as the password.
236 </p>
237 </section>
238 <section>
239 <h3>About</h3>
240 <p class={repo.description ? "" : "muted"}>{repo.description || "No description."}</p>
241 {stats ? (
242 <dl class="kv">
243 <dt>Default branch</dt>
244 <dd class="mono">{repo.default_branch}</dd>
245 <dt>Open issues</dt>
246 <dd>
247 <a href={`${base}/issues`}>{stats.openIssues}</a>
248 </dd>
249 <dt>Open pull requests</dt>
250 <dd>
251 <a href={`${base}/pulls`}>{stats.openPulls}</a>
252 </dd>
253 <dt>Refs</dt>
254 <dd>{stats.refs}</dd>
255 <dt>Objects</dt>
256 <dd>{stats.objects.toLocaleString("en-US")}</dd>
257 <dt>Size</dt>
258 <dd>{fmtBytes(stats.bytes)}</dd>
259 <dt>Last push</dt>
260 <dd>{repo.pushed_at ? <Time at={repo.pushed_at} /> : "never"}</dd>
261 </dl>
262 ) : null}
263 </section>
264 <section>
265 <h3>Shortcuts</h3>
266 <p class="small muted m0">
267 <kbd>Ctrl</kbd> <kbd>K</kbd> jump anywhere · <kbd>t</kbd> find a file · <kbd>g</kbd> <kbd>i</kbd> issues · <kbd>g</kbd> <kbd>p</kbd> pull requests · <kbd>g</kbd> <kbd>a</kbd> actions
268 </p>
269 </section>
270 </aside>
271 </div>,
272 );
273}
274
275// ---------------------------------------------------------------- editor
276
277interface EditorValues {
278 path: string;
279 content: string;
280 message: string;
281 description: string;
282 choice: "direct" | "branch";
283 newBranch: string;
284 head: string;
285}
286
287const Editor: FC<{ c: AppContext; l: Loaded; ref: string; mode: "edit" | "new"; orig: string; dir: string; v: EditorValues; error?: string; canDirect: boolean }> = ({ c, l, ref, mode, orig, dir, v, error, canDirect }) => {
288 const base = repoUrl(l.repo);
289 const t = csrfToken(c);
290 const action = mode === "edit" ? `${base}/edit/${encPath(ref)}/${encPath(orig)}` : `${base}/new/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`;
291 return (
292 <div>
293 {error ? <div class="flash error">{error}</div> : null}
294 <Form action={action} t={t}>
295 <input type="hidden" name="head" value={v.head} />
296 <div class="row mb">
297 <span class="badge">{raw(ICONS.branch)} {ref}</span>
298 <div class="grow">
299 <input type="text" name="path" value={v.path} required placeholder="path/to/file.ext" aria-label="File path" class="mono" />
300 </div>
301 {mode === "edit" ? (
302 <a class="btn sm" href={`${base}/blob/${encPath(ref)}/${encPath(orig)}`}>
303 Cancel
304 </a>
305 ) : null}
306 </div>
307 <div class="box">
308 <textarea name="content" class="code" spellcheck={false} style="border:0;border-radius:0;min-height:480px" aria-label="File contents">
309 {`\n${v.content}`}
310 </textarea>
311 </div>
312 <div class="box">
313 <div class="head">
314 <h3>Commit changes</h3>
315 </div>
316 <div class="body">
317 <div class="field">
318 <input type="text" name="message" value={v.message} required aria-label="Commit message" />
319 </div>
320 <div class="field">
321 <textarea name="description" placeholder="Add an optional extended description…" style="min-height:70px">
322 {v.description}
323 </textarea>
324 </div>
325 <div class="field">
326 {canDirect ? (
327 <label class="inline" style="margin-bottom:6px">
328 <input type="radio" name="choice" value="direct" checked={v.choice === "direct"} /> Commit directly to the <code>{ref}</code> branch
329 </label>
330 ) : (
331 <p class="muted small">
332 {raw(ICONS.shield)} <code>{ref}</code> is protected; your change goes to a new branch and a pull request.
333 </p>
334 )}
335 <label class="inline">
336 <input type="radio" name="choice" value="branch" checked={v.choice === "branch" || !canDirect} /> Create a new branch for this commit and start a pull request:
337 </label>
338 <input type="text" name="new_branch" value={v.newBranch} style="max-width:320px;margin:6px 0 0 24px" class="mono" aria-label="New branch name" />
339 </div>
340 <button class="btn primary">Commit changes</button>
341 </div>
342 </div>
343 </Form>
344 {mode === "edit" ? (
345 <Form action={action} t={t} class="box" confirm={`Delete ${orig}?`}>
346 <input type="hidden" name="_delete" value="1" />
347 <input type="hidden" name="head" value={v.head} />
348 <div class="body row between">
349 <span class="muted">Delete this file in a new commit on {ref}.</span>
350 <button class="btn sm danger">Delete file</button>
351 </div>
352 </Form>
353 ) : null}
354 </div>
355 );
356};
357
358// ---------------------------------------------------------------- compare and pull request creation
359
360export interface PullFormValues {
361 title: string;
362 body: string;
363 intent: string;
364 draft: boolean;
365}
366
367async function comparePage(c: AppContext, l: Loaded, baseRef: string, headRef: string, form?: { values: PullFormValues; error: string }): Promise<Response> {
368 const { repo, caller, stub } = l;
369 const base = repoUrl(repo);
370 const t = csrfToken(c);
371 const names = await refNames(stub);
372 const all = [...names.branches, ...names.tags.filter((x) => !names.branches.includes(x))];
373 let cmp: Awaited<ReturnType<typeof stub.compare>> | null = null;
374 let error: string | null = null;
375 try {
376 cmp = await stub.compare(baseRef, headRef);
377 } catch (e) {
378 const s = statusOf(e);
379 if (s.status >= 500) throw e;
380 error = s.message;
381 }
382 const bothBranches = names.branches.includes(baseRef) && names.branches.includes(headRef);
383 const canPr = c.get("principal").type === "account" && atLeast(caller.role, "write") && bothBranches && !!cmp && cmp.aheadBy > 0;
384 let existing: number | null = null;
385 if (canPr) {
386 const open = await stub.issues({ kind: "pull", state: "open", limit: 100 });
387 existing = open.items.find((i) => i.pull?.head === headRef && i.pull?.base === baseRef)?.number ?? null;
388 }
389 const defaultTitle = cmp && cmp.commits.length === 1 ? firstLine(cmp.commits[0]!.message) : headRef.replace(/[-_/]+/g, " ").replace(/^\w/, (m) => m.toUpperCase());
390 const v = form?.values ?? { title: defaultTitle, body: cmp && cmp.commits.length === 1 ? cmp.commits[0]!.message.split("\n").slice(1).join("\n").trim() : "", intent: "", draft: false };
391 return render(
392 c,
393 { title: `Comparing ${baseRef}...${headRef}`, repo: { repo, caller, tab: "pulls", ref: headRef }, wide: !!cmp && cmp.files.length > 0 },
394 <div>
395 <h1>Compare changes</h1>
396 <p class="muted">Choose two branches to see what changed or to start a new pull request.</p>
397 <form method="get" action={`${base}/compare`} class="box">
398 <div class="body row">
399 {raw(ICONS.branch)}
400 <label class="inline">
401 base:
402 <select name="base" style="width:auto">
403 {all.map((n) => (
404 <option value={n} selected={n === baseRef}>
405 {n}
406 </option>
407 ))}
408 </select>
409 </label>
410 <span class="faint">←</span>
411 <label class="inline">
412 compare:
413 <select name="head" style="width:auto">
414 {all.map((n) => (
415 <option value={n} selected={n === headRef}>
416 {n}
417 </option>
418 ))}
419 </select>
420 </label>
421 <button class="btn sm">Compare</button>
422 {cmp ? (
423 <span class="muted small" style="margin-left:auto">
424 {cmp.aheadBy} commit{cmp.aheadBy === 1 ? "" : "s"} ahead, {cmp.behindBy} behind {baseRef}
425 </span>
426 ) : null}
427 </div>
428 </form>
429 {error ? <div class="flash error">{error}</div> : null}
430 {cmp && cmp.aheadBy === 0 ? (
431 <div class="blank mb">
432 <h3>There isn't anything to compare.</h3>
433 <p class="m0">
434 <b>{baseRef}</b> is up to date with all commits from <b>{headRef}</b>.
435 </p>
436 </div>
437 ) : null}
438 {existing ? (
439 <div class="flash info">
440 A pull request for these branches already exists:{" "}
441 <a href={`${base}/pull/${existing}`}>
442 #{existing}
443 </a>
444 </div>
445 ) : canPr ? (
446 <Form action={`${base}/pulls`} t={t} class="box" style="max-width:980px">
447 <input type="hidden" name="base" value={baseRef} />
448 <input type="hidden" name="head" value={headRef} />
449 <div class="head">
450 <h2>Open a pull request</h2>
451 <span class="muted small">
452 {headRef} → {baseRef}
453 </span>
454 </div>
455 <div class="body">
456 {form?.error ? <div class="flash error">{form.error}</div> : null}
457 <div class="field">
458 <label for="pt">Title</label>
459 <input id="pt" name="title" type="text" required value={v.title} />
460 </div>
461 <div class="field">
462 <label for="pi">Intent (what this change is meant to do — helps reviewers and AI reviewers)</label>
463 <textarea id="pi" name="intent" style="min-height:80px" placeholder="e.g. Fix the race in token refresh without changing the public API.">
464 {v.intent}
465 </textarea>
466 </div>
467 <div class="field">
468 <label for="pb">Description</label>
469 <textarea id="pb" name="body" placeholder="Markdown is supported.">
470 {v.body}
471 </textarea>
472 </div>
473 <div class="row between">
474 <label class="inline">
475 <input type="checkbox" name="draft" value="1" checked={v.draft} /> Create as draft
476 </label>
477 <button class="btn green">Create pull request</button>
478 </div>
479 </div>
480 </Form>
481 ) : null}
482 {cmp && cmp.commits.length ? (
483 <div class="box">
484 <div class="head">
485 <h3>
486 {raw(ICONS.commit)} {cmp.commits.length} commit{cmp.commits.length === 1 ? "" : "s"}
487 </h3>
488 </div>
489 <ul class="list">
490 {cmp.commits.map((cm) => (
491 <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
492 ))}
493 </ul>
494 </div>
495 ) : null}
496 {cmp && cmp.files.length ? (
497 <>
498 <div class="row between mb">
499 <DiffStats files={cmp.totalFiles} additions={cmp.additions} deletions={cmp.deletions} />
500 {cmp.truncated ? <span class="badge amber">showing the first {cmp.files.length} files</span> : null}
501 </div>
502 <FileSummary files={cmp.files as DiffFile[]} />
503 <DiffView files={cmp.files as DiffFile[]} opts={{ viewHref: (p) => `${base}/blob/${encPath(headRef)}/${encPath(p)}` }} />
504 </>
505 ) : null}
506 </div>,
507 );
508}
509
510// ---------------------------------------------------------------- routes
511
512export function registerCode(web: WebApp): void {
513 web.get("/:owner/:repo", async (c) => {
514 const name = prm(c, "repo");
515 if (name.endsWith(".git")) return c.redirect(`/${prm(c, "owner")}/${name.slice(0, -4)}`, 301);
516 const l = await loadRepo(c, prm(c, "owner"), name, "read");
517 if (l.repo.owner_login !== prm(c, "owner") || l.repo.name !== name) return c.redirect(repoUrl(l.repo), 301);
518 return treePage(c, l, l.repo.default_branch, "", true);
519 });
520
521 web.get("/:owner/:repo/tree/*", async (c) => {
522 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
523 const rest = restOfPath(c, 3);
524 if (!rest) return c.redirect(repoUrl(l.repo), 302);
525 const { ref, path } = await splitRefPath(l.stub, rest);
526 return treePage(c, l, ref, path, !path);
527 });
528
529 web.get("/:owner/:repo/blob/*", async (c) => {
530 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
531 const { repo, caller, stub } = l;
532 const base = repoUrl(repo);
533 const { ref, path } = await splitRefPath(stub, restOfPath(c, 3));
534 if (!path) return c.redirect(`${base}/tree/${encPath(ref)}`, 302);
535 const [blob, names] = await Promise.all([stub.blob(ref, path), refNames(stub)]);
536 if (!blob) {
537 const tree = await stub.tree(ref, path);
538 if (tree) return c.redirect(`${base}/tree/${encPath(ref)}/${encPath(path)}`, 302);
539 throw new HttpError(404, "file not found");
540 }
541 const ext = extOf(path);
542 const rawUrl = `${base}/raw/${encPath(ref)}/${encPath(path)}`;
543 const plain = c.req.query("plain") === "1";
544 const canEdit = atLeast(caller.role, "write") && names.branches.includes(ref) && !blob.binary && blob.size <= MAX_RENDER && !repo.archived;
545 let content: Child;
546 let lineCount = 0;
547 if (IMAGE_TYPES[ext] && ext !== "svg") {
548 content = (
549 <div class="body" style="text-align:center;background:var(--bg2)">
550 <img src={rawUrl} alt={path} style="max-width:100%" />
551 </div>
552 );
553 } else if (blob.binary) {
554 content = (
555 <div class="empty">
556 Binary file, {fmtBytes(blob.size)} ({blob.size.toLocaleString("en-US")} bytes).{" "}
557 <a class="btn sm" href={rawUrl}>
558 {raw(ICONS.download)} Download
559 </a>
560 </div>
561 );
562 } else if (blob.size > MAX_RENDER) {
563 content = (
564 <div class="empty">
565 This file is too large to display ({fmtBytes(blob.size)}). <a href={rawUrl}>View raw</a>
566 </div>
567 );
568 } else {
569 const text = decoder().decode(blob.content);
570 const lines = text.split("\n");
571 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
572 lineCount = lines.length;
573 if (isMarkdown(path) && !plain) {
574 content = (
575 <div class="body readme" style="margin:0;padding:24px 32px">
576 <Markdown text={text} base={{ owner: repo.owner_login, repo: repo.name, ref, dir: path.split("/").slice(0, -1).join("/") }} empty="Empty file." />
577 </div>
578 );
579 } else {
580 content = (
581 <div class="code-wrap">
582 <table class="blob">
583 {lines.map((line, i) => (
584 <tr id={`L${i + 1}`}>
585 <td class="ln">
586 <a href={`#L${i + 1}`}>{i + 1}</a>
587 </td>
588 <td>{line.replace(/\r$/, "")}</td>
589 </tr>
590 ))}
591 </table>
592 </div>
593 );
594 }
595 }
596 return render(
597 c,
598 { title: `${path} at ${ref} · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
599 <div>
600 <div class="row between mb">
601 <div class="row">
602 <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/blob/${encPath(r)}/${encPath(path)}`} />
603 <Crumbs base={base} ref={ref} path={path} />
604 <CopyButton text={path} label="Copy path" />
605 </div>
606 </div>
607 <div class="box">
608 <div class="head small">
609 <span class="muted">
610 {lineCount ? `${lineCount} line${lineCount === 1 ? "" : "s"} · ` : ""}
611 {fmtBytes(blob.size)}
612 {blob.mode === "100755" ? " · executable" : ""}
613 </span>
614 <span class="grow" />
615 {isMarkdown(path) && !blob.binary ? (
616 <div class="subnav">
617 <a href={`?`} class={!plain ? "on" : ""}>
618 Preview
619 </a>
620 <a href={`?plain=1`} class={plain ? "on" : ""}>
621 Code
622 </a>
623 </div>
624 ) : null}
625 <a class="btn sm" href={rawUrl}>
626 Raw
627 </a>
628 <a class="btn sm" href={`${base}/commits/${encPath(ref)}${qs({ path })}`}>
629 {raw(ICONS.history)} History
630 </a>
631 {canEdit ? (
632 <a class="btn sm" href={`${base}/edit/${encPath(ref)}/${encPath(path)}`}>
633 {raw(ICONS.edit)} Edit
634 </a>
635 ) : null}
636 </div>
637 {content}
638 </div>
639 </div>,
640 );
641 });
642
643 web.get("/:owner/:repo/raw/*", async (c) => {
644 const { stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
645 const { ref, path } = await splitRefPath(stub, restOfPath(c, 3));
646 const blob = path ? await stub.blob(ref, path) : null;
647 if (!blob) throw new HttpError(404, "file not found");
648 const ext = extOf(path);
649 const type = IMAGE_TYPES[ext] ?? (blob.binary ? "application/octet-stream" : "text/plain; charset=utf-8");
650 const name = path.split("/").pop() ?? "file";
651 const headers: Record<string, string> = {
652 "content-type": type,
653 "x-content-type-options": "nosniff",
654 "content-security-policy": "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'",
655 "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=86400, immutable" : "private, max-age=60",
656 "content-disposition": `${type === "application/octet-stream" ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`,
657 "x-frame-options": "DENY",
658 };
659 return new Response(blob.content, { headers });
660 });
661
662 // Editor (edit an existing file / create a new one).
663 const editorGet = async (c: AppContext, mode: "edit" | "new") => {
664 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
665 const { repo, caller, stub } = l;
666 const base = repoUrl(repo);
667 const names = await refNames(stub);
668 const empty = !names.branches.length && !names.tags.length;
669 const rest = restOfPath(c, 3);
670 const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest);
671 const ref = split.ref;
672 if (!empty && !names.branches.includes(ref)) throw new HttpError(422, "files can only be edited on a branch");
673 const meName = caller.login;
674 const patchBranch = `${meName}-patch-${Math.floor(Date.now() / 1000) % 100000}`;
675 const canDirect = await canCommitDirect(l, ref);
676 if (mode === "edit") {
677 const blob = split.path ? await stub.blob(ref, split.path) : null;
678 if (!blob) throw new HttpError(404, "file not found");
679 if (blob.binary) throw new HttpError(422, "binary files cannot be edited in the browser");
680 return render(
681 c,
682 { title: `Editing ${split.path}`, repo: { repo, caller, tab: "code", ref } },
683 <Editor
684 c={c}
685 l={l}
686 ref={ref}
687 mode="edit"
688 orig={split.path}
689 dir=""
690 canDirect={canDirect}
691 v={{ path: split.path, content: decoder().decode(blob.content), message: `Update ${split.path.split("/").pop()}`, description: "", choice: canDirect ? "direct" : "branch", newBranch: patchBranch, head: blob.sha }}
692 />,
693 );
694 }
695 const headSha = empty ? "" : ((await stub.resolve(ref)) ?? "");
696 const filename = c.req.query("filename") ?? "";
697 const dir = split.path;
698 return render(
699 c,
700 { title: `New file · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
701 <>
702 <p class="muted">
703 Create a new file in <a href={`${base}/tree/${encPath(ref)}${dir ? `/${encPath(dir)}` : ""}`}>{dir || repo.name}</a>.
704 </p>
705 <Editor
706 c={c}
707 l={l}
708 ref={ref}
709 mode="new"
710 orig=""
711 dir={dir}
712 canDirect={canDirect || empty}
713 v={{ path: `${dir ? `${dir}/` : ""}${cleanPath(filename) ?? ""}`, content: "", message: filename ? `Create ${filename}` : "Create new file", description: "", choice: canDirect || empty ? "direct" : "branch", newBranch: patchBranch, head: headSha }}
714 />
715 </>,
716 );
717 };
718
719 const editorPost = async (c: AppContext, mode: "edit" | "new", f: FormData) => {
720 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
721 const { repo, caller, stub } = l;
722 const base = repoUrl(repo);
723 const names = await refNames(stub);
724 const empty = !names.branches.length && !names.tags.length;
725 const rest = restOfPath(c, 3);
726 const split = empty ? { ref: rest.split("/")[0] || repo.default_branch, path: rest.split("/").slice(1).join("/") } : await splitRefPath(stub, rest);
727 const ref = split.ref;
728 const orig = mode === "edit" ? split.path : "";
729 const head = f.s("head");
730
731 if (mode === "edit" && f.b("_delete")) {
732 return act(c, `${base}/blob/${encPath(ref)}/${encPath(orig)}`, async () => {
733 await stub.commitFiles({ branch: ref, message: `Delete ${orig}`, changes: [{ path: orig, delete: true }], expectedHeadSha: head || undefined }, caller);
734 const parent = orig.split("/").slice(0, -1).join("/");
735 return `${base}/tree/${encPath(ref)}${parent ? `/${encPath(parent)}` : ""}`;
736 });
737 }
738
739 let content = f.s("content");
740 if (content.startsWith("\r\n")) content = content.slice(2);
741 else if (content.startsWith("\n")) content = content.slice(1);
742 let mode100: "100644" | "100755" | "120000" | undefined;
743 if (mode === "edit") {
744 const old = await stub.blob(ref, orig);
745 const crlf = !!old && decoder().decode(old.content.slice(0, 65536)).includes("\r\n");
746 if (!crlf) content = content.replace(/\r\n/g, "\n");
747 if (old && (old.mode === "100755" || old.mode === "120000")) mode100 = old.mode;
748 } else content = content.replace(/\r\n/g, "\n");
749 const values: EditorValues = {
750 path: f.s("path"),
751 content,
752 message: f.s("message"),
753 description: f.s("description"),
754 choice: f.s("choice") === "direct" ? "direct" : "branch",
755 newBranch: f.s("new_branch").trim(),
756 head,
757 };
758 const canDirect = empty || (await canCommitDirect(l, ref));
759 try {
760 const path = cleanPath(values.path);
761 if (!path) throw new HttpError(422, "enter a valid file path");
762 if (!values.message.trim()) throw new HttpError(422, "a commit message is required");
763 const toBranch = values.choice === "direct" && canDirect ? ref : values.newBranch;
764 if (!toBranch) throw new HttpError(422, "enter a branch name");
765 const changes: Array<{ path: string; content?: Uint8Array; delete?: boolean; mode?: "100644" | "100755" | "120000" }> = [];
766 if (mode === "edit" && path !== orig) changes.push({ path: orig, delete: true });
767 if (mode === "new" && !empty && (await stub.blob(ref, path))) throw new HttpError(422, `${path} already exists`);
768 changes.push({ path, content: new TextEncoder().encode(content), mode: mode100 });
769 const message = values.description.trim() ? `${values.message.trim()}\n\n${values.description.trim()}` : values.message.trim();
770 const newBranch = toBranch !== ref;
771 if (newBranch && names.branches.includes(toBranch)) throw new HttpError(422, `branch ${toBranch} already exists`);
772 await stub.commitFiles({ branch: toBranch, message, changes, expectedHeadSha: !newBranch && head ? head : undefined, createBranchFrom: newBranch ? ref : undefined }, caller);
773 c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id));
774 if (newBranch) return c.redirect(`${base}/compare/${encPath(ref)}...${encPath(toBranch)}`, 303);
775 return c.redirect(`${base}/blob/${encPath(ref)}/${encPath(path)}`, 303);
776 } catch (e) {
777 const s = statusOf(e);
778 if (s.status >= 500) throw e;
779 return render(
780 c,
781 { title: mode === "edit" ? `Editing ${orig}` : "New file", repo: { repo, caller, tab: "code", ref }, status: s.status },
782 <Editor c={c} l={l} ref={ref} mode={mode} orig={orig} dir={mode === "new" ? split.path : ""} canDirect={canDirect} v={values} error={s.message} />,
783 );
784 }
785 };
786
787 web.get("/:owner/:repo/edit/*", (c) => editorGet(c, "edit"));
788 post(web, "/:owner/:repo/edit/*", (c, f) => editorPost(c, "edit", f));
789 web.get("/:owner/:repo/new/*", (c) => editorGet(c, "new"));
790 post(web, "/:owner/:repo/new/*", (c, f) => editorPost(c, "new", f));
791
792 // History
793 web.get("/:owner/:repo/commits/*", async (c) => {
794 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
795 const { repo, caller, stub } = l;
796 const base = repoUrl(repo);
797 const ref = restOfPath(c, 3) || repo.default_branch;
798 const path = c.req.query("path") ?? "";
799 const page = pageNum(c);
800 const per = 30;
801 const [commits, names] = await Promise.all([stub.log(ref, { path: path || undefined, limit: per + 1, skip: (page - 1) * per }), refNames(stub)]);
802 const hasNext = commits.length > per;
803 const shown = commits.slice(0, per);
804 const groups = new Map<string, typeof shown>();
805 for (const cm of shown) {
806 const day = new Date(cm.committer.timestamp * 1000).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
807 groups.set(day, [...(groups.get(day) ?? []), cm]);
808 }
809 return render(
810 c,
811 { title: `Commits · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code", ref } },
812 <div>
813 <div class="row between mb">
814 <div class="row">
815 <h1 class="m0">Commits</h1>
816 <RefSwitcher current={ref} branches={names.branches} tags={names.tags} to={(r) => `${base}/commits/${encPath(r)}${qs({ path })}`} />
817 {path ? (
818 <span class="muted">
819 for <span class="mono">{path}</span> · <a href={`${base}/commits/${encPath(ref)}`}>all files</a>
820 </span>
821 ) : null}
822 </div>
823 </div>
824 {shown.length === 0 ? <div class="blank">No commits found.</div> : null}
825 {[...groups.entries()].map(([day, list]) => (
826 <div class="mb">
827 <h3 class="muted small" style="font-weight:500">
828 {raw(ICONS.commit)} Commits on {day}
829 </h3>
830 <div class="box">
831 <ul class="list">
832 {list.map((cm) => (
833 <CommitRow base={base} sha={cm.sha} message={cm.message} author={cm.author} signed={cm.signed} />
834 ))}
835 </ul>
836 </div>
837 </div>
838 ))}
839 <Pager page={page} hasNext={hasNext} href={(p) => `${base}/commits/${encPath(ref)}${qs({ path, page: p > 1 ? p : undefined })}`} />
840 </div>,
841 );
842 });
843 web.get("/:owner/:repo/commits", (c) => c.redirect(`/${prm(c, "owner")}/${prm(c, "repo")}/commits/HEAD`, 302));
844
845 web.get("/:owner/:repo/commit/:sha", async (c) => {
846 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
847 const { repo, caller, stub } = l;
848 const base = repoUrl(repo);
849 const cm = await stub.commitWithDiff(prm(c, "sha"));
850 const checks = await stub.checks(cm.sha).catch(() => []);
851 const [title, ...rest] = cm.message.split("\n");
852 const body = rest.join("\n").trim();
853 const files = cm.files as DiffFile[];
854 return render(
855 c,
856 { title: `${firstLine(cm.message)} · ${shortSha(cm.sha)}`, repo: { repo, caller, tab: "code", ref: cm.sha }, wide: true },
857 <div>
858 <div class="box">
859 <div class="body">
860 <h2 style="font-size:18px">{title}</h2>
861 {body ? <pre style="white-space:pre-wrap;margin:0 0 12px;font-size:13px">{body}</pre> : null}
862 <div class="row small muted">
863 <b style="color:var(--fg)">{cm.author.name}</b> authored <Time at={new Date(cm.author.timestamp * 1000).toISOString()} />
864 {cm.committer.name !== cm.author.name ? (
865 <span>
866 · <b style="color:var(--fg)">{cm.committer.name}</b> committed <Time at={new Date(cm.committer.timestamp * 1000).toISOString()} />
867 </span>
868 ) : null}
869 {cm.signed ? <span class="badge green">signed</span> : null}
870 <span class="grow" />
871 <span>
872 {cm.parents.length} parent{cm.parents.length === 1 ? "" : "s"}{" "}
873 {cm.parents.map((p) => (
874 <a class="sha" href={`${base}/commit/${p}`} style="margin-left:4px">
875 {shortSha(p)}
876 </a>
877 ))}
878 </span>
879 <span>
880 commit <span class="sha">{cm.sha}</span>
881 </span>
882 <CopyButton text={cm.sha} />
883 <a class="btn sm" href={`${base}/tree/${cm.sha}`}>
884 Browse files
885 </a>
886 </div>
887 {checks.length ? (
888 <ul class="checks mt">
889 {checks.map((ch) => (
890 <li>
891 <Status status={ch.status} conclusion={ch.conclusion} />
892 {ch.runId ? <a href={`${base}/actions/runs/${ch.runId}${ch.jobId ? `/job/${ch.jobId}` : ""}`}>{ch.name}</a> : <span>{ch.name}</span>}
893 <span class="muted small">{ch.output?.title ?? ""}</span>
894 </li>
895 ))}
896 </ul>
897 ) : null}
898 </div>
899 </div>
900 <div class="row between mb">
901 <DiffStats files={cm.totalFiles} additions={cm.additions} deletions={cm.deletions} />
902 {cm.truncated ? <span class="badge amber">showing the first {files.length} files</span> : null}
903 </div>
904 <FileSummary files={files} />
905 <DiffView files={files} opts={{ viewHref: (p) => `${base}/blob/${cm.sha}/${encPath(p)}` }} />
906 </div>,
907 );
908 });
909
910 // Branches and tags
911 web.get("/:owner/:repo/branches", async (c) => {
912 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
913 const { repo, caller, stub } = l;
914 const base = repoUrl(repo);
915 const t = csrfToken(c);
916 const branches = await stub.branches();
917 branches.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : a.name.localeCompare(b.name)));
918 const infos = await Promise.all(branches.slice(0, 40).map((b) => stub.commitInfo(b.sha).catch(() => null)));
919 const canWrite = atLeast(caller.role, "write") && !repo.archived;
920 return render(
921 c,
922 { title: `Branches · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } },
923 <div>
924 <div class="row between mb">
925 <h1 class="m0">Branches</h1>
926 </div>
927 <div class="box">
928 {branches.length ? (
929 <ul class="list">
930 {branches.map((b, i) => {
931 const info = infos[i];
932 return (
933 <li>
934 <span class="st faint">{raw(ICONS.branch)}</span>
935 <div class="grow">
936 <a class="title mono" href={`${base}/tree/${encPath(b.name)}`}>
937 {b.name}
938 </a>{" "}
939 {b.isDefault ? <span class="badge blue">default</span> : null} {b.protected ? <span class="badge">{raw(ICONS.shield)} protected</span> : null}
940 {info ? (
941 <div class="meta">
942 {firstLine(info.message)} · {info.author.name} · <Time at={new Date(info.committer.timestamp * 1000).toISOString()} />
943 </div>
944 ) : null}
945 </div>
946 <a class="sha" href={`${base}/commit/${b.sha}`}>
947 {shortSha(b.sha)}
948 </a>
949 {!b.isDefault ? (
950 <a class="btn sm" href={`${base}/compare/${encPath(repo.default_branch)}...${encPath(b.name)}`}>
951 {raw(ICONS.pr)} Compare
952 </a>
953 ) : null}
954 {canWrite && !b.isDefault ? (
955 <Form action={`${base}/branches/delete`} t={t} class="inline" confirm={`Delete branch ${b.name}?`}>
956 <input type="hidden" name="name" value={b.name} />
957 <button class="btn sm danger" title="Delete branch">
958 {raw(ICONS.x)}
959 </button>
960 </Form>
961 ) : null}
962 </li>
963 );
964 })}
965 </ul>
966 ) : (
967 <div class="empty">No branches yet.</div>
968 )}
969 </div>
970 {canWrite && branches.length ? (
971 <Form action={`${base}/branches`} t={t} class="box">
972 <div class="head">
973 <h3>Create a branch</h3>
974 </div>
975 <div class="body row" style="align-items:flex-end">
976 <div class="field grow m0">
977 <label>Branch name</label>
978 <input name="name" type="text" required class="mono" />
979 </div>
980 <div class="field m0">
981 <label>From</label>
982 <select name="from">
983 {branches.map((b) => (
984 <option value={b.name} selected={b.isDefault}>
985 {b.name}
986 </option>
987 ))}
988 </select>
989 </div>
990 <button class="btn primary">Create branch</button>
991 </div>
992 </Form>
993 ) : null}
994 </div>,
995 );
996 });
997
998 post(web, "/:owner/:repo/branches", async (c, f) => {
999 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1000 const base = repoUrl(repo);
1001 return act(c, `${base}/branches`, async () => {
1002 const name = f.s("name").trim();
1003 const sha = await stub.resolve(f.s("from"));
1004 if (!sha) throw new HttpError(422, "unknown source branch");
1005 await stub.createRef(`refs/heads/${name}`, sha, caller);
1006 return `${base}/tree/${encPath(name)}`;
1007 });
1008 });
1009
1010 post(web, "/:owner/:repo/branches/delete", async (c, f) => {
1011 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1012 return act(c, `${repoUrl(repo)}/branches`, async () => stub.deleteRef(`refs/heads/${f.s("name")}`, caller), "Branch deleted.");
1013 });
1014
1015 web.get("/:owner/:repo/tags", async (c) => {
1016 const { repo, caller, stub } = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1017 const base = repoUrl(repo);
1018 const tags = (await stub.tags()).reverse();
1019 const infos = await Promise.all(tags.slice(0, 40).map((tg) => stub.commitInfo(tg.target).catch(() => null)));
1020 return render(
1021 c,
1022 { title: `Tags · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "code" } },
1023 <div>
1024 <h1>Tags</h1>
1025 <div class="box">
1026 {tags.length ? (
1027 <ul class="list">
1028 {tags.map((tg, i) => (
1029 <li>
1030 <span class="st faint">{raw(ICONS.tag)}</span>
1031 <div class="grow">
1032 <a class="title mono" href={`${base}/tree/${encPath(tg.name)}`}>
1033 {tg.name}
1034 </a>
1035 {infos[i] ? (
1036 <div class="meta">
1037 {firstLine(infos[i]!.message)} · <Time at={new Date(infos[i]!.committer.timestamp * 1000).toISOString()} />
1038 </div>
1039 ) : null}
1040 </div>
1041 <a class="sha" href={`${base}/commit/${tg.target}`}>
1042 {shortSha(tg.target)}
1043 </a>
1044 <a class="btn sm" href={`${base}/compare/${encPath(tg.name)}...${encPath(repo.default_branch)}`}>
1045 Compare
1046 </a>
1047 </li>
1048 ))}
1049 </ul>
1050 ) : (
1051 <div class="empty">
1052 No tags yet. Create one with <code>git tag v1.0.0 && git push --tags</code>.
1053 </div>
1054 )}
1055 </div>
1056 </div>,
1057 );
1058 });
1059
1060 // Compare
1061 web.get("/:owner/:repo/compare", async (c) => {
1062 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1063 const base = repoUrl(l.repo);
1064 const b = c.req.query("base");
1065 const h = c.req.query("head");
1066 if (b && h) return c.redirect(`${base}/compare/${encPath(b)}...${encPath(h)}`, 302);
1067 const names = await refNames(l.stub);
1068 const other = names.branches.find((x) => x !== l.repo.default_branch);
1069 return comparePage(c, l, l.repo.default_branch, h ?? other ?? l.repo.default_branch);
1070 });
1071
1072 web.get("/:owner/:repo/compare/*", async (c) => {
1073 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
1074 const spec = restOfPath(c, 3);
1075 const idx = spec.indexOf("...");
1076 const baseRef = idx >= 0 ? spec.slice(0, idx) : l.repo.default_branch;
1077 const headRef = idx >= 0 ? spec.slice(idx + 3) : spec;
1078 if (!headRef) return c.redirect(`${repoUrl(l.repo)}/compare`, 302);
1079 return comparePage(c, l, baseRef || l.repo.default_branch, headRef);
1080 });
1081
1082 post(web, "/:owner/:repo/pulls", async (c, f) => {
1083 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
1084 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
1085 const values: PullFormValues = { title: f.s("title").trim(), body: f.s("body").replace(/\r\n/g, "\n"), intent: f.s("intent").replace(/\r\n/g, "\n").trim(), draft: f.b("draft") };
1086 const baseRef = f.s("base") || l.repo.default_branch;
1087 const headRef = f.s("head");
1088 try {
1089 const pr = await l.stub.createPull({ title: values.title, body: values.body, head: headRef, base: baseRef, draft: values.draft, intent: values.intent || undefined }, l.caller);
1090 return c.redirect(`${repoUrl(l.repo)}/pull/${pr.number}`, 303);
1091 } catch (e) {
1092 const s = statusOf(e);
1093 if (s.status >= 500) throw e;
1094 return comparePage(c, l, baseRef, headRef, { values, error: s.message });
1095 }
1096 });
1097}
1098
1099/** Whether a web commit straight to `branch` would pass protection rules for this caller. */
1100async function canCommitDirect(l: Loaded, branch: string): Promise<boolean> {
1101 const rules = (await l.stub.listProtections()).find((p) => globToRegExp(p.pattern).test(branch))?.rules;
1102 if (l.caller.kind === "agent" && (branch === l.repo.default_branch || rules) && !rules?.agentsMayPush) return false;
1103 if (!rules) return true;
1104 if (l.caller.role === "admin" && !rules.enforceAdmins) return true;
1105 return !rules.requirePullRequest;
1106}
1107