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.

664 lines · 36.9 KBRaw History
1import { ACTIONS_APP } from "./required.js";
2import {
3 CODE_TYPES,
4 EMPTY_TREE_OID,
5 hashObject,
6 isAncestor,
7 isOid,
8 isTreeMode,
9 mergeBase,
10 mergeTrees,
11 parseTag,
12 readCommit,
13 readTree,
14 resolvePath,
15 serializeCommit,
16 serializeTag,
17 serializeTree,
18 type ObjectType,
19 type Signature,
20 type TreeEntry,
21 utf8,
22} from "@diffinery/git-core";
23import type { Env } from "../env.js";
24import { numericId, uuidNumber, uuidPattern } from "../http/gh.js";
25import { scanForSecrets, type SecretHit } from "../git/secret-scan.js";
26import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js";
27import { R2Sink } from "../lib/r2-sink.js";
28import { unsafeTreeName, unsafeTreePath } from "../lib/tree-names.js";
29import { HttpError, noreplyEmail, now } from "../lib/util.js";
30import { archiveKey } from "../lib/storage-keys.js";
31import type { Check } from "./collab.js";
32import { toInfo, type CommitInfo, type RepoDO } from "./RepoDO.js";
33import { atLeast, type Caller } from "./types.js";
34
35export type SigInput = { name?: string; email?: string; date?: string };
36export type TreeInput = { path?: string; mode?: string; type?: string; sha?: string | null; content?: string };
37export type GitTreeEntry = { path: string; mode: string; type: "blob" | "tree" | "commit"; sha: string; size?: number };
38
39const MODES = new Set(["100644", "100755", "040000", "160000", "120000"]);
40/** Uncompressed file content an archive may hold; bigger repositories should be cloned. */
41const MAX_ARCHIVE_BYTES = 24 * 1024 * 1024;
42/** Tree entries a walk may visit (repeated subtrees count every time), against tree bombs. */
43const MAX_TREE_ENTRIES = 100_000;
44const MAX_TREE_DEPTH = 128;
45/** Archive builds per repository per hour (cached archives don't count). */
46const ARCHIVE_BUILDS_PER_HOUR = 20;
47
48/** Language by file extension, for GET /languages (programming and markup languages, like linguist). */
49const LANGUAGES: Record<string, string> = {
50 ts: "TypeScript", tsx: "TypeScript", mts: "TypeScript", cts: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
51 py: "Python", rb: "Ruby", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", kts: "Kotlin", scala: "Scala", swift: "Swift", m: "Objective-C",
52 c: "C", h: "C", cc: "C++", cpp: "C++", cxx: "C++", hpp: "C++", hh: "C++", cs: "C#", fs: "F#", php: "PHP", pl: "Perl", lua: "Lua", r: "R",
53 dart: "Dart", ex: "Elixir", exs: "Elixir", erl: "Erlang", hs: "Haskell", clj: "Clojure", elm: "Elm", ml: "OCaml", zig: "Zig", nim: "Nim",
54 sh: "Shell", bash: "Shell", zsh: "Shell", ps1: "PowerShell", sql: "PLpgSQL", html: "HTML", htm: "HTML", css: "CSS", scss: "SCSS", sass: "Sass",
55 less: "Less", vue: "Vue", svelte: "Svelte", astro: "Astro", tf: "HCL", hcl: "HCL", nix: "Nix", sol: "Solidity", vb: "Visual Basic .NET",
56 groovy: "Groovy", jl: "Julia", cr: "Crystal", v: "V", d: "D", asm: "Assembly", s: "Assembly", mk: "Makefile", cmake: "CMake", proto: "Protocol Buffer",
57};
58const LANGUAGE_FILES: Record<string, string> = { Dockerfile: "Dockerfile", Makefile: "Makefile", "CMakeLists.txt": "CMake", Rakefile: "Ruby", Gemfile: "Ruby" };
59const VENDORED = /(^|\/)(node_modules|vendor|third_party|dist|build|\.yarn)\//;
60
61function parseSig(input: SigInput | undefined, fallback: Signature, field: string): Signature {
62 if (!input) return fallback;
63 if (!input.name || !input.email) throw new HttpError(422, `${field} needs a name and an email`, { errors: [{ resource: "Commit", code: "missing_field", field }] });
64 let timestamp = fallback.timestamp;
65 let timezone = "+0000";
66 if (input.date) {
67 const t = Date.parse(input.date);
68 if (Number.isNaN(t)) throw new HttpError(422, `${field}.date must be an ISO 8601 date`);
69 timestamp = Math.floor(t / 1000);
70 const m = /([+-])(\d{2}):?(\d{2})$/.exec(input.date);
71 if (m) timezone = `${m[1]}${m[2]}${m[3]}`;
72 }
73 return { name: input.name.replace(/[<>\n]/g, ""), email: input.email.replace(/[<>\n]/g, ""), timestamp, timezone };
74}
75
76const sixDigit = (mode: string) => mode.padStart(6, "0");
77const entryType = (mode: string): GitTreeEntry["type"] => (isTreeMode(mode) ? "tree" : mode === "160000" ? "commit" : "blob");
78
79/**
80 * Git data API (blobs, trees, commits, annotated tags), contents writes with
81 * optimistic concurrency, the merges API, archives, languages and a few
82 * Actions and checks extras that need direct access to the repository.
83 */
84export class GitData {
85 constructor(
86 private repo: RepoDO,
87 private env: Env,
88 ) {}
89
90 private get sql() {
91 return this.repo.sql;
92 }
93
94 private get store() {
95 return this.repo.store;
96 }
97
98 private requireWrite(caller: Caller): void {
99 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required");
100 }
101
102 private signature(caller: Caller): Signature {
103 return { name: caller.login, email: noreplyEmail(this.env.PUBLIC_URL, caller.login), timestamp: Math.floor(Date.now() / 1000), timezone: "+0000" };
104 }
105
106 private agentHeaders(caller: Caller): [string, string][] {
107 return caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
108 }
109
110 /** Same push protection as git pushes: blobs with credentials are refused. */
111 private scan(oid: string, content: Uint8Array, caller: Caller): void {
112 const marked: SecretHit[] = [];
113 const hits = scanForSecrets([{ oid, content }], marked);
114 const m = this.repo.meta;
115 if (marked.length) this.repo.emit({ type: "audit", action: "api.secret_marked", actor: caller, target: `${m.owner}/${m.name}`, detail: { kinds: marked.map((h) => h.kind), blob: oid }, at: now() });
116 if (hits.length) {
117 this.repo.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target: `${m.owner}/${m.name}`, detail: { kinds: hits.map((h) => h.kind), blob: oid }, at: now() });
118 throw new HttpError(422, `push protection: possible secret detected (${hits.map((h) => h.kind).join(", ")})`);
119 }
120 }
121
122 objectType(oid: string): ObjectType | null {
123 const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
124 return row ? (CODE_TYPES[row.type] ?? null) : null;
125 }
126
127 /** Refs under a prefix with the type of the object each points at. */
128 refsWithTypes(prefix: string): Array<{ ref: string; sha: string; type: ObjectType | "commit" }> {
129 return this.repo.refs(prefix).map((r) => ({ ref: r.name, sha: r.oid, type: this.objectType(r.oid) ?? "commit" }));
130 }
131
132 // ---------------------------------------------------------------- blobs
133
134 async createBlob(content: Uint8Array, caller: Caller, quotaChecked = false): Promise<{ sha: string; size: number }> {
135 this.requireWrite(caller);
136 if (!quotaChecked) await this.repo.checkStorage(content.length);
137 const oid = await hashObject("blob", content);
138 this.scan(oid, content, caller);
139 await this.store.writeKnown(oid, "blob", content);
140 return { sha: oid, size: content.length };
141 }
142
143 async getBlob(sha: string): Promise<{ sha: string; size: number; content: Uint8Array }> {
144 if (!isOid(sha)) throw new HttpError(404, "blob not found");
145 const o = await this.store.read(sha);
146 if (!o || o.type !== "blob") throw new HttpError(404, "blob not found");
147 return { sha, size: o.content.length, content: o.content };
148 }
149
150 // ---------------------------------------------------------------- trees
151
152 async createTree(baseTree: string | undefined, entries: TreeInput[], caller: Caller): Promise<string> {
153 this.requireWrite(caller);
154 if (!Array.isArray(entries)) throw new HttpError(422, "tree must be an array", { errors: [{ resource: "Tree", code: "invalid", field: "tree" }] });
155 const adding = entries.reduce((n, e) => n + (e?.content === undefined ? 0 : String(e.content).length), 0);
156 if (adding) await this.repo.checkStorage(adding);
157 if (baseTree && this.objectType(baseTree) !== "tree") throw new HttpError(422, "base_tree is not a valid tree oid", { errors: [{ resource: "Tree", code: "invalid", field: "base_tree" }] });
158 type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node> };
159 const load = async (oid: string | null): Promise<Node> => ({ entries: new Map(oid ? (await readTree(this.store, oid)).map((e) => [e.name, e]) : []), children: new Map() });
160 const root = await load(baseTree ?? null);
161 const dirFor = async (parts: string[]): Promise<Node> => {
162 let node = root;
163 for (const part of parts) {
164 let child = node.children.get(part);
165 if (!child) {
166 const existing = node.entries.get(part);
167 child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
168 node.children.set(part, child);
169 }
170 node = child;
171 }
172 return node;
173 };
174 for (const e of entries) {
175 const parts = String(e.path ?? "").split("/").filter(Boolean);
176 const name = parts.pop();
177 const why = unsafeTreePath(String(e.path ?? ""));
178 if (!name || why) throw new HttpError(422, `invalid tree path ${JSON.stringify(String(e.path ?? ""))}: ${why ?? "empty path"}`, { errors: [{ resource: "Tree", code: "invalid", field: "path" }] });
179 const mode = String(e.mode ?? "100644");
180 if (!MODES.has(mode)) throw new HttpError(422, `invalid mode ${mode}`, { errors: [{ resource: "Tree", code: "invalid", field: "mode" }] });
181 const dir = await dirFor(parts);
182 if (e.sha === null && e.content === undefined) {
183 dir.entries.delete(name);
184 dir.children.delete(name);
185 continue;
186 }
187 let oid: string;
188 if (e.content !== undefined) {
189 if (mode === "040000" || mode === "160000") throw new HttpError(422, "content can only be used with blob modes");
190 oid = (await this.createBlob(new TextEncoder().encode(String(e.content)), caller, true)).sha;
191 } else {
192 oid = String(e.sha ?? "");
193 if (!isOid(oid)) throw new HttpError(422, `invalid sha for ${e.path}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] });
194 const want = mode === "040000" ? "tree" : mode === "160000" ? null : "blob";
195 if (want && this.objectType(oid) !== want) throw new HttpError(422, `${oid} is not a ${want}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] });
196 }
197 dir.children.delete(name);
198 dir.entries.set(name, { mode: mode === "040000" ? "40000" : mode, name, oid });
199 }
200 const finish = async (node: Node): Promise<string | null> => {
201 for (const [name, child] of node.children) {
202 const oid = await finish(child);
203 if (oid) node.entries.set(name, { mode: "40000", name, oid });
204 else node.entries.delete(name);
205 }
206 if (node.entries.size === 0) return null;
207 return this.store.write("tree", serializeTree([...node.entries.values()]));
208 };
209 return (await finish(root)) ?? (await this.store.write("tree", new Uint8Array(0)));
210 }
211
212 /** A tree by tree SHA, commit SHA or ref; `recursive` lists every entry (truncated after 100k). */
213 async getTree(rev: string, recursive: boolean): Promise<{ sha: string; tree: GitTreeEntry[]; truncated: boolean }> {
214 let sha = isOid(rev) && this.objectType(rev) === "tree" ? rev : null;
215 if (!sha) {
216 const commit = await this.repo.resolve(rev);
217 if (!commit) throw new HttpError(404, "tree not found");
218 sha = (await readCommit(this.store, commit)).tree;
219 }
220 const out: GitTreeEntry[] = [];
221 let truncated = false;
222 const sizes = (oids: string[]) => {
223 const m = new Map<string, number>();
224 for (let i = 0; i < oids.length; i += 100) {
225 const chunk = oids.slice(i, i + 100);
226 for (const r of this.sql.exec<{ oid: string; size: number }>(`SELECT oid, size FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk).toArray()) m.set(r.oid, r.size);
227 }
228 return m;
229 };
230 const walk = async (oid: string, prefix: string) => {
231 const entries = await readTree(this.store, oid);
232 const size = sizes(entries.filter((e) => !isTreeMode(e.mode) && e.mode !== "160000").map((e) => e.oid));
233 for (const e of entries) {
234 if (out.length >= 100_000) {
235 truncated = true;
236 return;
237 }
238 const path = prefix ? `${prefix}/${e.name}` : e.name;
239 const type = entryType(e.mode);
240 out.push({ path, mode: sixDigit(e.mode), type, sha: e.oid, ...(type === "blob" ? { size: size.get(e.oid) ?? 0 } : {}) });
241 if (recursive && type === "tree") await walk(e.oid, path);
242 }
243 };
244 if (sha !== EMPTY_TREE_OID || this.store.has(sha)) await walk(sha, "");
245 return { sha, tree: out, truncated };
246 }
247
248 // ---------------------------------------------------------------- commits and tags
249
250 async createCommit(input: { message?: string; tree?: string; parents?: string[]; author?: SigInput; committer?: SigInput; signature?: string }, caller: Caller): Promise<CommitInfo> {
251 this.requireWrite(caller);
252 if (typeof input.message !== "string") throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] });
253 if (!input.tree || this.objectType(input.tree) !== "tree") throw new HttpError(422, "tree is not a valid tree oid", { errors: [{ resource: "Commit", code: "invalid", field: "tree" }] });
254 const parents = input.parents ?? [];
255 if (!Array.isArray(parents)) throw new HttpError(422, "parents must be an array");
256 for (const p of parents) if (!isOid(String(p)) || this.objectType(String(p)) !== "commit") throw new HttpError(422, `parent ${p} is not a valid commit`, { errors: [{ resource: "Commit", code: "invalid", field: "parents" }] });
257 this.scan("(commit message)", utf8(input.message), caller);
258 const me = this.signature(caller);
259 const author = parseSig(input.author, me, "author");
260 const committer = parseSig(input.committer, input.author ? author : me, "committer");
261 const sha = await this.store.write(
262 "commit",
263 serializeCommit({ tree: input.tree, parents: parents.map(String), author, committer, message: input.message, extraHeaders: this.agentHeaders(caller), gpgsig: input.signature || undefined }),
264 );
265 return this.repo.commitInfo(sha);
266 }
267
268 async gitCommit(sha: string): Promise<CommitInfo> {
269 if (!isOid(sha) || this.objectType(sha) !== "commit") throw new HttpError(404, "commit not found");
270 return toInfo(sha, await readCommit(this.store, sha));
271 }
272
273 async createTag(input: { tag?: string; message?: string; object?: string; type?: string; tagger?: SigInput }, caller: Caller) {
274 this.requireWrite(caller);
275 const tag = String(input.tag ?? "").trim();
276 if (!tag || /[\s~^:?*[\\]|\.\.|^\.|\/$|\.lock$/.test(tag)) throw new HttpError(422, "invalid tag name", { errors: [{ resource: "Tag", code: "invalid", field: "tag" }] });
277 const object = String(input.object ?? "");
278 const actual = isOid(object) ? this.objectType(object) : null;
279 if (!actual) throw new HttpError(422, "object does not exist", { errors: [{ resource: "Tag", code: "invalid", field: "object" }] });
280 const type = (input.type ?? actual) as ObjectType;
281 if (type !== actual) throw new HttpError(422, `object is a ${actual}, not a ${type}`, { errors: [{ resource: "Tag", code: "invalid", field: "type" }] });
282 const tagger = parseSig(input.tagger, this.signature(caller), "tagger");
283 const message = String(input.message ?? "");
284 this.scan("(tag message)", utf8(message), caller);
285 const sha = await this.store.write("tag", serializeTag({ object, type, tag, tagger, message: message.endsWith("\n") || !message ? message : `${message}\n` }));
286 return this.getTag(sha);
287 }
288
289 async getTag(sha: string) {
290 if (!isOid(sha) || this.objectType(sha) !== "tag") throw new HttpError(404, "tag not found");
291 const o = await this.store.read(sha);
292 const t = parseTag(o!.content);
293 return { sha, tag: t.tag, message: t.message, tagger: t.tagger ?? null, object: { sha: t.object, type: t.type } };
294 }
295
296 // ---------------------------------------------------------------- contents
297
298 /** Create or update one file; `sha` must name the file's current blob when it exists (GitHub's rule). */
299 async putContents(
300 input: { path: string; content: Uint8Array; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } },
301 caller: Caller,
302 ): Promise<{ created: boolean; commit: CommitInfo; blob: { sha: string; size: number } }> {
303 this.requireWrite(caller);
304 if (!input.message) throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] });
305 const branch = input.branch || this.repo.meta.defaultBranch;
306 const head = this.repo.getRef(`refs/heads/${branch}`);
307 const existing = head ? await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path) : null;
308 if (existing && isTreeMode(existing.mode)) throw new HttpError(422, `${input.path} is a directory`);
309 if (existing && !input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] });
310 if (existing && input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`);
311 if (!existing && input.sha) throw new HttpError(409, `${input.path} does not exist at ${branch}`);
312 const oid = await hashObject("blob", input.content);
313 const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, content: input.content }], expectedHeadSha: head ?? undefined, author: input.author, committer: input.committer }, caller);
314 return { created: !existing, commit: await this.repo.commitInfo(res.sha), blob: { sha: oid, size: input.content.length } };
315 }
316
317 async deleteContents(input: { path: string; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } }, caller: Caller): Promise<CommitInfo> {
318 this.requireWrite(caller);
319 if (!input.message) throw new HttpError(422, "message is required");
320 const branch = input.branch || this.repo.meta.defaultBranch;
321 const head = this.repo.getRef(`refs/heads/${branch}`);
322 if (!head) throw new HttpError(404, `branch ${branch} not found`);
323 const existing = await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path);
324 if (!existing || isTreeMode(existing.mode)) throw new HttpError(404, "file not found");
325 if (!input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] });
326 if (input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`);
327 const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, delete: true }], expectedHeadSha: head, author: input.author, committer: input.committer }, caller);
328 return this.repo.commitInfo(res.sha);
329 }
330
331 /** The README in a directory (GET /readme). */
332 async readme(rev: string, dir = ""): Promise<{ path: string; sha: string; size: number; content: Uint8Array; commit: string } | null> {
333 const t = await this.repo.tree(rev, dir);
334 if (!t) return null;
335 const candidates = t.items.filter((i) => i.type === "blob" && /^readme(\.[A-Za-z0-9]+)?$/i.test(i.name));
336 const pick = candidates.find((i) => /\.(md|markdown)$/i.test(i.name)) ?? candidates[0];
337 if (!pick) return null;
338 const b = await this.repo.blob(t.sha, pick.path);
339 return b ? { path: pick.path, sha: b.oid, size: b.size, content: b.content, commit: b.sha } : null;
340 }
341
342 // ---------------------------------------------------------------- merges API
343
344 /** Merge `head` into the `base` branch. Returns null when there is nothing to merge. */
345 async merge(input: { base?: string; head?: string; commit_message?: string }, caller: Caller): Promise<CommitInfo | null> {
346 this.requireWrite(caller);
347 const base = String(input.base ?? "");
348 const ref = `refs/heads/${base}`;
349 const baseSha = this.repo.getRef(ref);
350 if (!baseSha) throw new HttpError(404, "Base does not exist");
351 const headSha = await this.repo.resolve(String(input.head ?? ""));
352 if (!headSha) throw new HttpError(404, "Head does not exist");
353 if (await isAncestor(this.store, headSha, baseSha)) return null;
354 const mb = await mergeBase(this.store, baseSha, headSha);
355 const [bt, ht, mt] = await Promise.all([readCommit(this.store, baseSha), readCommit(this.store, headSha), mb ? readCommit(this.store, mb) : null]);
356 const merged = await mergeTrees(this.store, mt?.tree ?? null, bt.tree, ht.tree);
357 if (merged.conflicts.length || !merged.tree) throw new HttpError(409, "Merge conflict");
358 const sig = this.signature(caller);
359 const message = input.commit_message || `Merge ${input.head} into ${base}`;
360 const sha = await this.store.write("commit", serializeCommit({ tree: merged.tree, parents: [baseSha, headSha], author: sig, committer: sig, message: message.endsWith("\n") ? message : `${message}\n`, extraHeaders: this.agentHeaders(caller) }));
361 const check = await this.repo.checkRefUpdate(ref, baseSha, sha, caller);
362 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
363 if (!this.repo.casRef(ref, baseSha, sha)) throw new HttpError(409, "base branch moved; try again");
364 await this.repo.afterRefUpdate(ref, baseSha, sha, caller);
365 return this.repo.commitInfo(sha);
366 }
367
368 // ---------------------------------------------------------------- languages and archives
369
370 /** Bytes per language at the default branch, cached per commit. */
371 async languages(): Promise<Record<string, number>> {
372 const sha = await this.repo.resolve("HEAD");
373 if (!sha) return {};
374 const cached = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'languages'").toArray()[0];
375 if (cached) {
376 const c = JSON.parse(cached.value) as { sha: string; data: Record<string, number> };
377 if (c.sha === sha) return c.data;
378 }
379 const out: Record<string, number> = {};
380 try {
381 await this.walkTree((await readCommit(this.store, sha)).tree, "", (e, path) => {
382 if (isTreeMode(e.mode)) return !VENDORED.test(`${path}/`);
383 if (e.mode === "160000" || e.mode === "120000" || VENDORED.test(path)) return false;
384 const lang = LANGUAGE_FILES[e.name] ?? LANGUAGES[e.name.includes(".") ? e.name.split(".").pop()!.toLowerCase() : ""];
385 if (!lang) return false;
386 const size = this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0]?.size ?? 0;
387 out[lang] = (out[lang] ?? 0) + size;
388 return false;
389 });
390 } catch (e) {
391 // A huge (or hostile) tree: report what was counted before the budget ran out.
392 if (!(e instanceof HttpError && e.status === 413)) throw e;
393 }
394 const sorted = Object.fromEntries(Object.entries(out).sort((a, b) => b[1] - a[1]));
395 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('languages', ?)", JSON.stringify({ sha, data: sorted }));
396 return sorted;
397 }
398
399 archiveKey(sha: string, format: "zip" | "tar.gz"): string {
400 // Outside repos/: a cache the lifecycle rules may expire (lib/storage-keys.ts).
401 return archiveKey(this.repo.meta.repoId, sha, format);
402 }
403
404 /**
405 * Visit every entry under a tree, depth first. `visit` returns whether to
406 * descend into a subtree. Every entry visited counts against one budget
407 * (a subtree reused a thousand times costs a thousand times), so a small
408 * tree of shared subtrees can't make a walk explode.
409 */
410 private async walkTree(tree: string, prefix: string, visit: (e: TreeEntry, path: string) => boolean | Promise<boolean>, budget = { left: MAX_TREE_ENTRIES }, depth = 0): Promise<void> {
411 if (depth > MAX_TREE_DEPTH) throw new HttpError(413, "the tree is nested too deeply");
412 for (const e of await readTree(this.store, tree)) {
413 if (--budget.left < 0) throw new HttpError(413, `the tree has more than ${MAX_TREE_ENTRIES.toLocaleString("en-US")} entries; clone it with git instead`);
414 const path = prefix ? `${prefix}/${e.name}` : e.name;
415 if ((await visit(e, path)) && isTreeMode(e.mode)) await this.walkTree(e.oid, path, visit, budget, depth + 1);
416 }
417 }
418
419 /** Allow at most ARCHIVE_BUILDS_PER_HOUR fresh archive builds per repository. */
420 private countArchiveBuild(): void {
421 const hourAgo = Date.now() - 3600_000;
422 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'archive_builds'").toArray()[0];
423 const recent = (row ? (JSON.parse(row.value) as number[]) : []).filter((t) => t > hourAgo);
424 if (recent.length >= ARCHIVE_BUILDS_PER_HOUR) throw new HttpError(429, "too many archives were built for this repository in the last hour; try again later or clone it with git");
425 recent.push(Date.now());
426 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('archive_builds', ?)", JSON.stringify(recent));
427 }
428
429 /**
430 * Build a source archive of a commit into R2 (top directory `<owner>-<repo>-<sha7>/`,
431 * like GitHub). Entries stream through the compressor into R2, so memory stays
432 * around one file plus one upload part. Archives are a cache: R2 may evict them
433 * and they are rebuilt on the next download.
434 */
435 async buildArchive(sha: string, format: "zip" | "tar.gz"): Promise<{ key: string; size: number }> {
436 const commit = await readCommit(this.store, sha);
437 this.countArchiveBuild();
438 const m = this.repo.meta;
439 const prefix = `${m.owner}-${m.name}-${sha.slice(0, 7)}`;
440 const mtime = commit.committer.timestamp;
441 const key = this.archiveKey(sha, format);
442 const sink = new R2Sink(this.env.BUCKET, key, format === "zip" ? "application/zip" : "application/gzip", MAX_ARCHIVE_BYTES + 16 * 1024 * 1024, "repository is too large for an on-the-fly archive; clone it with git instead");
443 let total = 0;
444 // Each entry, in tree order; unsafe names (and anything under them) are left out.
445 const entries = async (emit: (e: ArchiveEntry) => Promise<void>) => {
446 await emit({ path: `${prefix}/`, dir: true });
447 await this.walkTree(commit.tree, prefix, async (e, path) => {
448 if (unsafeTreeName(e.name)) return false;
449 if (isTreeMode(e.mode)) {
450 await emit({ path: `${path}/`, dir: true });
451 return true;
452 }
453 if (e.mode === "160000") {
454 await emit({ path: `${path}/`, dir: true });
455 return false;
456 }
457 const o = await this.store.read(e.oid);
458 if (!o) return false;
459 total += o.content.length;
460 if (total > MAX_ARCHIVE_BYTES) throw new HttpError(413, "repository is too large for an on-the-fly archive; clone it with git instead");
461 await emit({ path, mode: e.mode, content: o.content });
462 return false;
463 });
464 };
465 let writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
466 try {
467 if (format === "zip") {
468 const zw = new ZipWriter(mtime);
469 await entries(async (e) => {
470 let parts: Uint8Array[];
471 try {
472 parts = await zw.entry(e);
473 } catch (err) {
474 if (err instanceof RangeError) throw new HttpError(413, "this repository has too many files for a zip archive (more than 65,535); download the .tar.gz archive or clone it with git");
475 throw err;
476 }
477 for (const part of parts) await sink.write(part);
478 });
479 for (const part of zw.end(sha)) await sink.write(part);
480 } else {
481 const gz = new CompressionStream("gzip");
482 const w = gz.writable.getWriter();
483 writer = w;
484 const reader = gz.readable.getReader();
485 const pump = (async () => {
486 for (;;) {
487 const { done, value } = await reader.read();
488 if (done) return;
489 await sink.write(value);
490 }
491 })();
492 // If R2 or the size limit stops the pump, fail the writes instead of waiting on backpressure.
493 const stopped = pump.catch(async (err) => {
494 await w.abort(err).catch(() => undefined);
495 throw err;
496 });
497 const put = async (parts: Uint8Array[]) => {
498 for (const part of parts) if (part.length) await w.write(part);
499 };
500 await put(tarStart(mtime, sha));
501 await entries((e) => put(tarEntry(e, mtime)));
502 await put([tarEnd()]);
503 await w.close();
504 await stopped;
505 }
506 return { key, size: await sink.close() };
507 } catch (e) {
508 await writer?.abort(e).catch(() => undefined);
509 await sink.abort();
510 throw e;
511 }
512 }
513
514 // ---------------------------------------------------------------- Actions extras
515
516 /**
517 * A run's logs as a zip (GET /actions/runs/:id/logs), laid out like GitHub's: one text file per
518 * job, and a folder per job with a file per step (`build/1_Run tests.txt`), which `gh run view
519 * --log` shows each step's lines from.
520 */
521 async runLogsZip(runId: string): Promise<Uint8Array> {
522 const run = this.repo.actions.getRun(runId);
523 const entries: ArchiveEntry[] = [];
524 const text = (lines: Array<{ t: string; text: string }>) => new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : ""));
525 let total = 0;
526 for (const [i, j] of run.jobs.entries()) {
527 const { lines } = await this.repo.actions.logs(j.id);
528 const all = text(lines);
529 total += all.length * 2;
530 if (total > 24 * 1024 * 1024) break;
531 entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: all });
532 // gh finds a step's file by the job's name without "/" and ":", cut to 90 characters.
533 const dir = j.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 90);
534 for (const s of j.steps) {
535 const mine = lines.filter((l) => l.step === s.index);
536 entries.push({ path: `${dir}/${s.index + 1}_${s.name.replace(/[\\/:*?"<>|]+/g, "").slice(0, 100)}.txt`, mode: "100644", content: text(mine) });
537 }
538 }
539 return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000));
540 }
541
542 /** The run id of attempt `n` of a run (re-runs are stored as new runs with the same number). */
543 runAttemptId(runId: string, attempt: number): string {
544 const r = this.repo.actions.getRun(runId);
545 const row = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE workflow_path = ? AND run_number = ? AND attempt = ? ORDER BY created_at DESC LIMIT 1", r.workflowPath, r.number, attempt).toArray()[0];
546 if (!row) throw new HttpError(404, "run attempt not found");
547 return row.id;
548 }
549
550 // ---------------------------------------------------------------- check runs and suites
551
552 /**
553 * A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it: a job's
554 * `check_run_url`, or its REST id (http/gh.ts uuidNumber), which `gh run view` asks for annotations by.
555 */
556 check(raw: string): Check {
557 const job = uuidPattern(raw);
558 if (job) raw = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE id GLOB ? ORDER BY rowid DESC LIMIT 1", job).toArray()[0]?.id ?? raw;
559 const row = /^\d{1,15}$/.test(raw)
560 ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0]
561 : (this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0] ??
562 this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE job_id = ? ORDER BY seq DESC LIMIT 1", raw).toArray()[0]);
563 const c = row ? this.repo.collab.checks(row.sha).find((x) => x.id === row.id) : undefined;
564 if (!c) throw new HttpError(404, "check run not found");
565 return c;
566 }
567
568 /** PATCH /check-runs/:id: status, conclusion, output (annotations accumulate, like GitHub). */
569 updateCheck(
570 id: string,
571 input: {
572 name?: string;
573 details_url?: string;
574 external_id?: string;
575 status?: string;
576 conclusion?: string;
577 started_at?: string;
578 completed_at?: string;
579 output?: { title?: string; summary?: string; text?: string; annotations?: unknown[] };
580 },
581 caller: Caller,
582 ): Check {
583 this.requireWrite(caller);
584 const c = this.check(id);
585 if (c.app === ACTIONS_APP) throw new HttpError(403, "check runs from Diffinery Actions can't be changed over the API");
586 const own = c.creatorId ? c.creatorId === caller.id : c.app.toLowerCase() === caller.login.toLowerCase();
587 if (!own) throw new HttpError(403, "only the app that created a check run can update it");
588 if (input.status && !["queued", "in_progress", "completed", "waiting", "requested", "pending"].includes(input.status)) throw new HttpError(422, "invalid status", { errors: [{ resource: "CheckRun", code: "invalid", field: "status" }] });
589 const conclusions = ["action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"];
590 if (input.conclusion && !conclusions.includes(input.conclusion)) throw new HttpError(422, "invalid conclusion", { errors: [{ resource: "CheckRun", code: "invalid", field: "conclusion" }] });
591 if (input.details_url && !/^https?:\/\//i.test(input.details_url)) throw new HttpError(422, "details_url must be an http(s) URL");
592 const status = input.conclusion ? "completed" : (input.status ?? c.status);
593 if (status === "completed" && !(input.conclusion ?? c.conclusion)) throw new HttpError(422, "conclusion is required when status is completed", { errors: [{ resource: "CheckRun", code: "missing_field", field: "conclusion" }] });
594 const output = { ...(c.output ?? {}) } as Record<string, unknown>;
595 if (input.output) {
596 for (const k of ["title", "summary", "text"] as const) if (input.output[k] !== undefined) output[k] = String(input.output[k]).slice(0, 65535);
597 if (Array.isArray(input.output.annotations)) {
598 const all = [...((output.annotations as unknown[]) ?? []), ...input.output.annotations.slice(0, 50)];
599 output.annotations = all.slice(0, 1000);
600 }
601 }
602 if (input.external_id !== undefined) output.external_id = String(input.external_id);
603 const t = now();
604 this.sql.exec(
605 "UPDATE checks SET name = ?, status = ?, conclusion = ?, details_url = ?, output = ?, started_at = ?, completed_at = ? WHERE id = ?",
606 input.name ?? c.name,
607 status,
608 status === "completed" ? (input.conclusion ?? c.conclusion) : null,
609 input.details_url ?? c.detailsUrl,
610 JSON.stringify(output),
611 input.started_at ?? c.startedAt ?? (status !== "queued" ? t : null),
612 status === "completed" ? (input.completed_at ?? c.completedAt ?? t) : null,
613 c.id,
614 );
615 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", c.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
616 if (status === "completed") this.repo.collab.checkCompleted(c.sha);
617 return this.check(c.id);
618 }
619
620 /**
621 * Check suites, synthesised: one per workflow run, or per app for external check runs. `number` is
622 * the REST API's id: the run's (http/gh.ts uuidNumber), or a hash of the app suite's id.
623 */
624 checkSuites(sha: string): Array<{ id: string; number: number; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> {
625 const groups = new Map<string, Check[]>();
626 for (const c of this.repo.collab.checks(sha)) {
627 const id = c.runId ?? `app-${c.app}-${sha}`;
628 groups.set(id, [...(groups.get(id) ?? []), c]);
629 }
630 return [...groups].map(([id, runs]) => {
631 const done = runs.every((r) => r.status === "completed");
632 const failed = runs.some((r) => ["failure", "timed_out", "cancelled", "action_required"].includes(r.conclusion ?? ""));
633 return { id, number: suiteNumber(id), app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs };
634 });
635 }
636
637 /** A check suite by its id or its REST API number (`number` in checkSuites). */
638 checkSuite(id: string): ReturnType<GitData["checkSuites"]>[number] {
639 if (/^\d{1,16}$/.test(id)) {
640 const run = uuidPattern(id);
641 const n = Number(id);
642 id =
643 (run && this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE id GLOB ? ORDER BY created_at DESC LIMIT 1", run).toArray()[0]?.id) ||
644 // App suites have no table to look their number up in; external check runs are few.
645 this.sql
646 .exec<{ app: string; sha: string }>("SELECT DISTINCT app, sha FROM checks WHERE run_id IS NULL")
647 .toArray()
648 .map((r) => `app-${r.app}-${r.sha}`)
649 .find((k) => suiteNumber(k) === n) ||
650 id;
651 }
652 const row = id.startsWith("app-")
653 ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0]
654 : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0];
655 const suite = row ? this.checkSuites(row.sha).find((s) => s.id === id) : undefined;
656 if (!suite) throw new HttpError(404, "check suite not found");
657 return suite;
658 }
659}
660
661/** A check suite's REST API id: its workflow run's, or a hash of an app suite's `app-<app>-<sha>`. */
662function suiteNumber(id: string): number {
663 return id.startsWith("app-") ? numericId(id) : uuidNumber(id);
664}