Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

3 commits

12 files+332−600/12 viewed
+9−0
7171 project you can see keeps that project's sidebar. A profile that does not
7272 exist says **No one on g1t goes by that name**, since profiles are public.
7373
74+## Download a ZIP
75+
76+On a repository's **Files** page, **Code** → **Download ZIP** downloads the
77+branch shown as one zip, its files in a folder named `<repo>-<branch>`. It
78+works for anyone who can see the repository, and for any branch, tag or
79+commit at `g1t.sh/<workspace>/<repo>/archive/<ref>.zip`. A ZIP holds the
80+files, not the history; clone for that. A repository with more than 10,000
81+files or over 24 MB is too large to download this way, so clone it instead.
82+
7483 ## Browsing without an account
7584
7685 Public projects, Explore, Search and profiles are open to everyone, in the
+8−7
3636 AlertDialogTrigger,
3737 } from "./ui/alert-dialog";
3838 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
39+import { shownStep } from "../lib/agent-step";
3940
4041 /** How often a page with something running asks again. */
4142 export const LIVE_MS = 4000;
251252 <RunStatusBadge status={run.status} />
252253 </div>
253254 {run.step && (
254− <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={run.step}>
255+ <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={shownStep(run.step)}>
255256 {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
256− {run.step}
257+ {shownStep(run.step)}
257258 </p>
258259 )}
259260 {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>}
408409 <RunStatusBadge status={current.status} />
409410 </div>
410411 {current.step && (
411− <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={current.step}>
412+ <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={shownStep(current.step)}>
412413 {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
413− {current.step}
414+ {shownStep(current.step)}
414415 </p>
415416 )}
416417 {confidence && <ConfidenceLine confidence={confidence} />}
448449 if (!run) return null;
449450 return (
450451 <span
451− title={run.step ?? undefined}
452+ title={run.step ? shownStep(run.step) : undefined}
452453 className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged"
453454 >
454455 <Loader2 size={11} className="animate-spin" />
488489 const run = data?.runs[0];
489490 if (!run?.step) return null;
490491 return (
491− <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={run.step}>
492− {RUN_KIND_LABEL[run.kind]}: {run.step}
492+ <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}>
493+ {RUN_KIND_LABEL[run.kind]}: {shownStep(run.step)}
493494 </span>
494495 );
495496 }
+29−43
1−import { Check, Hand } from "lucide-react";
1+import { Hand, Loader } from "lucide-react";
22
33 import type { Lifecycle, Stage } from "@g1t/contracts";
44
6565 };
6666
6767 /**
68− * Where a pull request made by g1t stands between "assigned" and
69− * "ready to merge", and what is happening to it right now.
68+ * Where a pull request made by g1t stands while g1t is still seeing it
69+ * through: the step it is on, as a five-part bar, and what is happening now.
70+ * Once it is ready or queued the merge box says so, and this steps aside.
7071 */
7172 export function LifecyclePanel({ lifecycle }: { lifecycle: Lifecycle }) {
7273 const { stage, detail, revisions } = lifecycle;
74+ if (stage === "ready" || stage === "queued") return null;
7375 if (stage === "needs_you") {
7476 return (
75− <div className="mt-4 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm">
76− <p className="flex items-center gap-2.5 font-medium">
77− <Hand size={16} className="shrink-0 text-warn" />
78− {TITLE[stage]}
79− </p>
80− <p className="mt-1 text-muted">{detail}</p>
77+ <div className="mt-4 flex gap-3 rounded-xl border border-warn/40 bg-warn/5 px-4 py-3 text-sm">
78+ <Hand size={16} className="mt-0.5 shrink-0 text-warn" />
79+ <div className="min-w-0">
80+ <p className="font-medium">{TITLE[stage]}</p>
81+ <p className="mt-0.5 text-muted">{detail}</p>
82+ </div>
8183 </div>
8284 );
8385 }
8486 const current = STEP_OF[stage];
85− const ready = stage === "ready";
8687 return (
87− <div
88− className={`mt-4 rounded-xl border px-4 py-3 text-sm ${
89− ready ? "border-accent/40 bg-accent/5" : "border-line bg-surface"
90− }`}
91− >
92− <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
93− <p className="font-medium">{TITLE[stage]}</p>
88+ <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3.5 text-sm">
89+ <div className="flex items-center gap-2.5">
90+ <Loader size={15} className="shrink-0 animate-spin text-accent motion-reduce:animate-none" />
91+ <p className="font-medium">
92+ {STAGE_LABEL[stage]}
93+ <span className="font-normal text-faint"> · step {current + 1} of {STEPS.length}</span>
94+ </p>
9495 {revisions > 0 && (
95− <p className="text-xs text-faint">
96− Revised {revisions === 1 ? "once" : `${revisions} times`}
97− </p>
96+ <span className="ml-auto text-xs text-faint">Revised {revisions === 1 ? "once" : `${revisions} times`}</span>
9897 )}
9998 </div>
100− <ol className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs">
99+ <ol className="mt-3 grid grid-cols-5 gap-1.5" aria-label="Steps">
101100 {STEPS.map((step, index) => {
102− const done = index < current || ready;
103− const active = index === current && !ready;
101+ const done = index < current;
102+ const active = index === current;
104103 return (
105− <li key={step} className="flex items-center gap-2">
106− {index > 0 && (
107− <span
108− aria-hidden="true"
109− className={`h-px w-4 sm:w-6 ${done || active ? "bg-accent-dim" : "bg-line"}`}
110− />
111− )}
104+ <li key={step} aria-current={active ? "step" : undefined}>
112105 <span
113− className={`flex items-center gap-1.5 ${
114− done ? "text-fg" : active ? "font-medium text-accent" : "text-faint"
106+ aria-hidden="true"
107+ className={`block h-1 rounded-full ${
108+ done ? "bg-accent" : active ? "animate-pulse bg-accent/60 motion-reduce:animate-none" : "bg-line"
115109 }`}
116− >
117− {done ? (
118− <Check size={13} className="text-accent" />
119− ) : (
120− <span
121− className={`size-1.5 rounded-full ${
122− active ? "animate-pulse bg-accent" : "bg-line-strong"
123− }`}
124− />
125− )}
110+ />
111+ <span className={`mt-1.5 block truncate text-[0.6875rem] ${active ? "font-medium text-fg" : done ? "text-muted" : "text-faint"}`}>
126112 {step}
127113 </span>
128114 </li>
129115 );
130116 })}
131117 </ol>
132− <p className="mt-3 text-muted">{detail}</p>
118+ <p className="mt-2.5 text-muted">{detail}</p>
133119 </div>
134120 );
135121 }
+23−10
1−import { BookOpen, Check, ChevronDown, Code2, File, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react";
1+import { BookOpen, Check, ChevronDown, Code2, File, FileArchive, Folder, FolderGit2, GitBranch, History, Search, SquareTerminal } from "lucide-react";
22 import { Form, Link } from "react-router";
33
44 import type { Blame, BlobView as Blob, Branch, Commit, TreeView as Tree } from "@g1t/contracts";
128128 );
129129 }
130130
131−/** The one button for getting the code: clone over HTTPS or SSH, or hand it to an agent. */
132−function CodeButton({ path }: { path: string }) {
131+/** The one button for getting the code: clone over HTTPS or SSH, hand it to an agent, or download it. */
132+function CodeButton({ path, gitRef }: { path: string; gitRef: string }) {
133133 return (
134134 <Popover>
135135 <PopoverTrigger className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent/90">
137137 Code
138138 <ChevronDown size={13} />
139139 </PopoverTrigger>
140− <PopoverContent align="end" className="w-96 p-4">
141− <h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
142− <SquareTerminal size={15} className="text-faint" />
143− Clone
144− </h2>
145− <CloneBox path={path} />
140+ <PopoverContent align="end" className="w-96 p-0">
141+ <div className="p-4">
142+ <h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
143+ <SquareTerminal size={15} className="text-faint" />
144+ Clone
145+ </h2>
146+ <CloneBox path={path} />
147+ </div>
148+ <div className="border-t border-line p-1.5">
149+ {/* A plain link: the browser downloads it, not the router. */}
150+ <a
151+ href={`/${path}/archive/${encodePath(gitRef)}.zip`}
152+ download
153+ className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg/90 transition-colors hover:bg-line hover:text-fg"
154+ >
155+ <FileArchive size={15} className="text-faint" />
156+ Download ZIP
157+ </a>
158+ </div>
146159 </PopoverContent>
147160 </Popover>
148161 );
179192 </span>
180193 )}
181194 <SearchCode repo={full} />
182− <CodeButton path={full} />
195+ <CodeButton path={full} gitRef={gitRef} />
183196 </>
184197 )}
185198 </div>
+15−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { shownStep } from "./agent-step";
5+
6+test("a step that hands back a file says what was done, not where", () => {
7+ assert.equal(shownStep("Said: Done. The review is written to `/work/review.json`."), "Wrote its review.");
8+ assert.equal(shownStep("Said: Plan saved to /work/plan.json"), "Wrote the plan.");
9+ assert.equal(shownStep("Said: I wrote /work/answer.md"), "Wrote its answer.");
10+});
11+
12+test("paths in the checkout read as the repository's own", () => {
13+ assert.equal(shownStep("Editing `/work/repo/src/math.js`"), "Editing src/math.js");
14+ assert.equal(shownStep("Running npm test"), "Running npm test");
15+});
+17−0
1+/**
2+ * An agent's latest step as the pages show it. The sandbox hands a review,
3+ * a plan or an answer back as a file under /work; an agent that says so
4+ * ("The review is written to `/work/review.json`") is saying it finished
5+ * that, not something anyone needs the path for.
6+ */
7+const HANDED_BACK: [RegExp, string][] = [
8+ [/\/work\/review\.json/, "Wrote its review."],
9+ [/\/work\/plan\.json/, "Wrote the plan."],
10+ [/\/work\/answer\.md/, "Wrote its answer."],
11+];
12+
13+export function shownStep(step: string): string {
14+ for (const [path, words] of HANDED_BACK) if (path.test(step)) return words;
15+ // Any other file of the checkout, as the repository names it.
16+ return step.replace(/`?\/work\/repo\/([^\s`]+)`?/g, "$1").replace(/`?\/work\/([^\s`]+)`?/g, "$1");
17+}
+40−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+import { inflateRawSync } from "node:zlib";
4+
5+import { crc32, zip } from "./zip";
6+
7+test("crc32 matches the standard check value", () => {
8+ assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
9+});
10+
11+test("a zip holds each file, deflated or stored, and reads back", async () => {
12+ const text = new TextEncoder().encode("hello ".repeat(200));
13+ const tiny = new TextEncoder().encode("x");
14+ const out = await zip([
15+ { path: "repo-main/README.md", data: text },
16+ { path: "repo-main/a.txt", data: tiny },
17+ { path: "repo-main/empty", data: new Uint8Array() },
18+ ]);
19+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
20+ // The end record names three entries and where the directory starts.
21+ const end = out.length - 22;
22+ assert.equal(view.getUint32(end, true), 0x06054b50);
23+ assert.equal(view.getUint16(end + 10, true), 3);
24+ // Read each local entry back.
25+ let at = 0;
26+ const read: Record<string, string> = {};
27+ for (let i = 0; i < 3; i++) {
28+ assert.equal(view.getUint32(at, true), 0x04034b50);
29+ const method = view.getUint16(at + 8, true);
30+ const size = view.getUint32(at + 18, true);
31+ const nameLength = view.getUint16(at + 26, true);
32+ const name = new TextDecoder().decode(out.subarray(at + 30, at + 30 + nameLength));
33+ const body = out.subarray(at + 30 + nameLength, at + 30 + nameLength + size);
34+ read[name] = new TextDecoder().decode(method === 8 ? inflateRawSync(body) : body);
35+ at += 30 + nameLength + size;
36+ }
37+ assert.equal(read["repo-main/README.md"], "hello ".repeat(200));
38+ assert.equal(read["repo-main/a.txt"], "x");
39+ assert.equal(read["repo-main/empty"], "");
40+});
+106−0
1+/**
2+ * A zip archive of files, made in memory: each file deflated when that makes
3+ * it smaller, stored otherwise, with UTF-8 names. Enough for downloading a
4+ * commit; no zip64, so the archive and each file stay under 4 GB (the caller
5+ * caps them far below that).
6+ */
7+
8+const CRC_TABLE = (() => {
9+ const table = new Uint32Array(256);
10+ for (let n = 0; n < 256; n++) {
11+ let c = n;
12+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13+ table[n] = c >>> 0;
14+ }
15+ return table;
16+})();
17+
18+export function crc32(data: Uint8Array): number {
19+ let crc = 0xffffffff;
20+ for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
21+ return (crc ^ 0xffffffff) >>> 0;
22+}
23+
24+async function deflate(data: Uint8Array): Promise<Uint8Array> {
25+ const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream("deflate-raw"));
26+ return new Uint8Array(await new Response(stream).arrayBuffer());
27+}
28+
29+/** The archive of `files`, in the order given. */
30+export async function zip(files: { path: string; data: Uint8Array }[]): Promise<Uint8Array<ArrayBuffer>> {
31+ const encoder = new TextEncoder();
32+ const parts: Uint8Array[] = [];
33+ const central: Uint8Array[] = [];
34+ let offset = 0;
35+ // Sixteen files deflating at a time: quicker than one by one, without
36+ // every file's stream held at once.
37+ const deflatedAll: Uint8Array[] = new Array(files.length);
38+ let next = 0;
39+ const worker = async () => {
40+ for (let index = next++; index < files.length; index = next++) {
41+ const data = files[index]!.data;
42+ deflatedAll[index] = data.length > 0 ? await deflate(data) : data;
43+ }
44+ };
45+ await Promise.all(Array.from({ length: Math.min(16, files.length) }, worker));
46+ for (const [index, file] of files.entries()) {
47+ const name = encoder.encode(file.path);
48+ const crc = crc32(file.data);
49+ const deflated = deflatedAll[index]!;
50+ const [method, body] = deflated.length < file.data.length ? [8, deflated] : [0, file.data];
51+ const header = (central: boolean) => {
52+ const out = new Uint8Array((central ? 46 : 30) + name.length);
53+ const view = new DataView(out.buffer);
54+ let at = 0;
55+ const u16 = (v: number) => {
56+ view.setUint16(at, v, true);
57+ at += 2;
58+ };
59+ const u32 = (v: number) => {
60+ view.setUint32(at, v >>> 0, true);
61+ at += 4;
62+ };
63+ u32(central ? 0x02014b50 : 0x04034b50);
64+ if (central) u16(0x031e); // made by: Unix, 3.0
65+ u16(20); // version needed
66+ u16(0x0800); // UTF-8 names
67+ u16(method);
68+ u16(0); // time
69+ u16(0x0021); // date: 1980-01-01
70+ u32(crc);
71+ u32(body.length);
72+ u32(file.data.length);
73+ u16(name.length);
74+ u16(0); // extra
75+ if (central) {
76+ u16(0); // comment
77+ u16(0); // disk
78+ u16(0); // internal attributes
79+ u32(0o100644 << 16);
80+ u32(offset);
81+ }
82+ out.set(name, at);
83+ return out;
84+ };
85+ const local = header(false);
86+ parts.push(local, body);
87+ central.push(header(true));
88+ offset += local.length + body.length;
89+ }
90+ const centralSize = central.reduce((sum, part) => sum + part.length, 0);
91+ const end = new Uint8Array(22);
92+ const view = new DataView(end.buffer);
93+ view.setUint32(0, 0x06054b50, true);
94+ view.setUint16(8, files.length, true);
95+ view.setUint16(10, files.length, true);
96+ view.setUint32(12, centralSize, true);
97+ view.setUint32(16, offset, true);
98+ const all = [...parts, ...central, end];
99+ const out = new Uint8Array(all.reduce((sum, part) => sum + part.length, 0));
100+ let at = 0;
101+ for (const part of all) {
102+ out.set(part, at);
103+ at += part.length;
104+ }
105+ return out;
106+}
+1−0
102102 route("code", "routes/repo/code.tsx"),
103103 route("tree/:ref/*", "routes/repo/tree.tsx"),
104104 route("blob/:ref/*", "routes/repo/blob.tsx"),
105+ route("archive/*", "routes/repo/archive.ts"),
105106 route("commits", "routes/repo/commits.tsx"),
106107 route("commit/:hash", "routes/repo/commit.tsx"),
107108 route("issues", "routes/repo/issues.tsx"),
+67−0
1+/**
2+ * Download ZIP: a branch, tag or commit of a repository as one zip, its
3+ * files under `<repo>-<ref>/` as `git archive` would name them. For anyone
4+ * who can see the repository. Made on request and not kept, so it is capped:
5+ * a repository past the caps is cloned instead.
6+ */
7+import type { Route } from "./+types/archive";
8+import { repos } from "../../lib/services.server";
9+import { getViewer } from "../../lib/session.server";
10+import { zip } from "../../lib/zip";
11+
12+/** The most files, and bytes in all, an archive is made of. */
13+const MAX_FILES = 10_000;
14+// The bytes, their base64 and the zip are all held at once, in a Worker of 128 MB.
15+const MAX_BYTES = 24 * 1024 * 1024;
16+/** Blobs read per call to the repository, and calls at once. */
17+const PER_READ = 100;
18+const READS_AT_ONCE = 8;
19+
20+function refused(status: number, message: string): Response {
21+ return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
22+}
23+
24+export async function loader({ params, context }: Route.LoaderArgs) {
25+ const viewer = getViewer(context);
26+ const asked = params["*"] ?? "";
27+ if (!asked.endsWith(".zip") || asked.length <= 4) return refused(404, "Ask for <branch, tag or commit>.zip.");
28+ const ref = asked.slice(0, -4);
29+ const repo = await repos.get({ namespace: params.owner, name: params.repo }, viewer).catch(() => null);
30+ if (!repo?.ok) return refused(404, "There is no such repository, or you cannot see it.");
31+ const listed = await repos.listFiles(repo.value.id, ref, MAX_FILES + 1).catch(() => null);
32+ if (!listed?.commit) return refused(404, `There is no branch, tag or commit named ${ref}.`);
33+ const clone = `git clone https://g1t.sh/${repo.value.namespace}/${repo.value.name}.git`;
34+ if (listed.truncated || listed.files.length > MAX_FILES) {
35+ return refused(413, `It has more than ${MAX_FILES.toLocaleString("en-US")} files, too many for a download. Clone it instead: ${clone}`);
36+ }
37+ const files = listed.files.filter((file): file is { path: string; hash: string } => file.hash != null);
38+ const unique = [...new Set(files.map((file) => file.hash))];
39+ const bytes = new Map<string, Uint8Array>();
40+ let total = 0;
41+ let tooLarge = false;
42+ const batches: string[][] = [];
43+ for (let at = 0; at < unique.length; at += PER_READ) batches.push(unique.slice(at, at + PER_READ));
44+ // A few reads at a time, each taking the next batch, until all are read or it is too large.
45+ const reader = async () => {
46+ for (let batch = batches.shift(); batch && !tooLarge; batch = batches.shift()) {
47+ for (const blob of await repos.rawBlobs(repo.value.id, batch, MAX_BYTES)) {
48+ total += blob.size;
49+ if (total > MAX_BYTES || (blob.data == null && blob.size > 0)) tooLarge = true;
50+ else bytes.set(blob.hash, Uint8Array.from(atob(blob.data ?? ""), (c) => c.charCodeAt(0)));
51+ }
52+ }
53+ };
54+ await Promise.all(Array.from({ length: READS_AT_ONCE }, reader));
55+ if (tooLarge) return refused(413, `It is over ${MAX_BYTES / 1024 / 1024} MB, too large for a download. Clone it instead: ${clone}`);
56+ // A branch name can hold slashes; the folder and file name cannot.
57+ const label = `${repo.value.name}-${ref.replaceAll("/", "-")}`;
58+ const archive = await zip(files.map((file) => ({ path: `${label}/${file.path}`, data: bytes.get(file.hash) ?? new Uint8Array() })));
59+ return new Response(archive, {
60+ headers: {
61+ "content-type": "application/zip",
62+ "content-disposition": `attachment; filename="${label}.zip"`,
63+ // A commit's files never change; a branch's do.
64+ "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=31536000, immutable" : "private, no-cache",
65+ },
66+ });
67+}
+2−0
285285 gitAccess: (path, viewer, service) =>
286286 call("git_access", { path, viewer, service }),
287287 branches: (path, viewer) => call("branches", { path, viewer }),
288+ listFiles: (repoId, ref, limit) => call("list_files", { repoId, ref, skipDirs: [], limit }),
289+ rawBlobs: (repoId, hashes, maxBytes) => call("raw_blobs", { repoId, hashes, maxBytes }),
288290 commitFile: (repo, actor, file) => call("commit_file", { repo, actor, ...file }),
289291 land: (sourceId, actor, branch) => call("land", { sourceId, actor, branch }),
290292 compare: (repoId, viewer, base, head) => call("compare", { repoId, viewer, base, head }),
+15−0
235235 branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
236236
237237 /**
238+ * Every file at `ref` (the default branch when null), at most `limit`
239+ * (10,000 at most). No viewer: check access first.
240+ */
241+ listFiles(repoId: string, ref: string | null, limit: number): Promise<FileList>;
242+
243+ /** Blobs' bytes as standard base64, at most 100; `data` is null for one missing or over `maxBytes`. No viewer. */
244+ rawBlobs(repoId: string, hashes: string[], maxBytes: number): Promise<RawBlob[]>;
245+
246+ /**
238247 * Writes one file on a new branch made from the default branch's head, as
239248 * one commit by `actor`, for a change g1t proposes on their behalf (a
240249 * starter workflow). Refused unless they may push, when the branch exists,
282291 /** A branch and the commit it points to. */
283292 export type Branch = { name: string; hash: string };
284293
294+/** Files at a commit, and whether there were more than were listed. */
295+export type FileList = { commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean };
296+
297+/** One blob's bytes, standard base64; null when missing or too large. */
298+export type RawBlob = { hash: string; size: number; data: string | null };
299+
285300 /**
286301 * What came of bringing a pull request up to date with the default branch
287302 * without a sandbox. `needs_agent` pushed nothing: the runner's `update`