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.

1513 lines · 66.2 KBRaw History
1import { DurableObject } from "cloudflare:workers";
2import { normalizeCheckSources } from "./required.js";
3import { unsafeTreePath } from "../lib/tree-names.js";
4import {
5 EMPTY_TREE_OID,
6 ZERO_OID,
7 applyChanges,
8 diffTrees,
9 fileDiff,
10 formatUnified,
11 fromUtf8,
12 isAncestor,
13 isBinary,
14 isOid,
15 isTreeMode,
16 mergeBase,
17 parsePack,
18 peelToCommit,
19 readCommit,
20 readTree,
21 resolvePath,
22 serializeCommit,
23 walkCommits,
24 type Commit,
25 type FileChange,
26 type Signature,
27} from "@diffinery/git-core";
28import type { DiffineryEvent } from "@diffinery/shared";
29import type { Env } from "../env.js";
30import {
31 advertiseReceivePack,
32 advertiseUploadPackV2,
33 errorPacket,
34 handleUploadPackV2,
35 parseReceivePack,
36 reportStatus,
37 type RefEntry,
38 type RefResult,
39} from "../git/protocol.js";
40import { scanForSecrets, secretBypassReason } from "../git/secret-scan.js";
41import { HttpError, isValidRefName, now } from "../lib/util.js";
42import { deletePrefixes, transientPrefixes } from "../lib/storage-keys.js";
43import { Actions } from "./actions.js";
44import { AutoMerge, isPerson } from "./auto-merge.js";
45import { Collab } from "./collab.js";
46import { Deployments } from "./deployments.js";
47import { triggerWorkflows } from "./extra-triggers.js";
48import { GitData } from "./gitdata.js";
49import { IMPORT_REF_PREFIX, RepoImports } from "./imports.js";
50import { RepoObjectStore } from "./objects.js";
51import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js";
52import { Releases } from "./releases.js";
53import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js";
54import { Runtime } from "./runtime.js";
55import { TokenPolicy } from "./token-policy.js";
56import { searchIssues } from "./search.js";
57import { Social } from "./social.js";
58import { Statuses } from "./statuses.js";
59import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
60
61const MAX_PUSH_BYTES = 100 * 1024 * 1024;
62
63export type TreeItem = {
64 name: string;
65 path: string;
66 type: "tree" | "blob" | "commit";
67 mode: string;
68 oid: string;
69 size?: number;
70};
71
72export type CommitInfo = {
73 sha: string;
74 tree: string;
75 parents: string[];
76 author: Signature;
77 committer: Signature;
78 message: string;
79 signed: boolean;
80};
81
82/**
83 * One Durable Object per repository. It is the single writer for the repo's
84 * refs, Git objects, issues, pull requests, reviews, checks and workflow runs,
85 * which gives strongly consistent ref updates and merges without locks.
86 */
87export class RepoDO extends DurableObject<Env> {
88 readonly sql: SqlStorage;
89 store!: RepoObjectStore;
90 readonly collab: Collab;
91 readonly actions: Actions;
92 readonly queue: MergeQueue;
93 readonly autoMerge: AutoMerge;
94 readonly runtime: Runtime;
95 readonly policy: TokenPolicy;
96 private metaCache: RepoMeta | null = null;
97
98 constructor(ctx: DurableObjectState, env: Env) {
99 super(ctx, env);
100 this.sql = ctx.storage.sql;
101 this.sql.exec(REPO_SCHEMA);
102 migrateRepoSchema(this.sql);
103 const meta = this.readMeta();
104 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId);
105 this.collab = new Collab(this);
106 this.actions = new Actions(this, env);
107 this.queue = new MergeQueue(this);
108 this.autoMerge = new AutoMerge(this, env);
109 this.runtime = new Runtime(this, env);
110 this.policy = new TokenPolicy(this);
111 }
112
113 // ---------------------------------------------------------------- meta
114
115 private readMeta(): RepoMeta | null {
116 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0];
117 this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null;
118 return this.metaCache;
119 }
120
121 get meta(): RepoMeta {
122 const m = this.metaCache ?? this.readMeta();
123 if (!m) throw new HttpError(404, "repository not initialised");
124 return m;
125 }
126
127 async init(meta: RepoMeta): Promise<void> {
128 // A repository created now (not one whose meta is updated) starts with read-only workflow tokens.
129 if (!this.readMeta()) this.policy.initNewRepo();
130 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta));
131 this.metaCache = meta;
132 this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId);
133 }
134
135 async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> {
136 const next = { ...this.meta, ...patch };
137 await this.init(next);
138 return next;
139 }
140
141 /** Wipe all storage (repository deletion). */
142 async destroy(): Promise<void> {
143 // Caches, artifacts, logs and archives live outside repos/<id>/; with the rows gone, nothing would remove them.
144 const repoId = this.readMeta()?.repoId;
145 if (repoId) await deletePrefixes(this.env.BUCKET, transientPrefixes(repoId)).catch((e) => console.error("could not delete the repository's Actions objects", e));
146 await this.ctx.storage.deleteAll();
147 this.metaCache = null;
148 }
149
150 async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> {
151 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
152 return {
153 objects: this.store.count(),
154 bytes: this.store.sizeBytes(),
155 refs: q("SELECT count(*) AS n FROM refs"),
156 openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"),
157 openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"),
158 };
159 }
160
161 /** Keep the object alive for work that finishes after the current request. */
162 background(p: Promise<unknown>): void {
163 this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
164 }
165
166 /** Set (or with null, clear) the object's single alarm; the merge queue uses it for check timeouts. */
167 armAlarm(at: number | null): void {
168 this.ctx.waitUntil(at === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(at));
169 }
170
171 override async alarm(): Promise<void> {
172 await this.queue.process();
173 }
174
175 /** Queue an event (webhooks, notifications) and show it live to open pages; `live: false` skips the pages (e.g. draft releases, which readers can't see). */
176 emit(event: DiffineryEvent, opts?: { live?: boolean }): void {
177 this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e)));
178 if (opts?.live !== false) this.broadcast("repo", { kind: "event", event });
179 }
180
181 // ---------------------------------------------------------------- refs
182
183 refs(prefix = "refs/"): RefEntry[] {
184 return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}￿`).toArray();
185 }
186
187 getRef(name: string): string | null {
188 return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null;
189 }
190
191 /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */
192 casRef(name: string, expected: string | null, next: string | null): boolean {
193 const current = this.getRef(name);
194 if (expected !== null && (current ?? ZERO_OID) !== expected) return false;
195 if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name);
196 else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next);
197 return true;
198 }
199
200 get headRef(): string {
201 return `refs/heads/${this.meta.defaultBranch}`;
202 }
203
204 /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */
205 async resolve(rev: string | undefined | null): Promise<string | null> {
206 const r = rev && rev.length ? rev : "HEAD";
207 let oid: string | null = null;
208 if (r === "HEAD") oid = this.getRef(this.headRef);
209 else if (isOid(r)) oid = this.store.has(r) ? r : null;
210 else if (r.startsWith("refs/")) oid = this.getRef(r);
211 else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`);
212 if (!oid && /^[0-9a-f]{7,39}$/.test(r)) {
213 const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray();
214 if (rows.length === 1) oid = rows[0]!.oid;
215 }
216 if (!oid) return null;
217 return peelToCommit(this.store, oid);
218 }
219
220 async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> {
221 return this.refs("refs/heads/").map((r) => {
222 const name = r.name.slice(11);
223 return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch };
224 });
225 }
226
227 async tags(): Promise<Array<{ name: string; sha: string; target: string }>> {
228 const out = [];
229 for (const r of this.refs("refs/tags/")) out.push({ name: r.name.slice(10), sha: r.oid, target: await peelToCommit(this.store, r.oid).catch(() => r.oid) });
230 return out;
231 }
232
233 // ---------------------------------------------------------------- git smart HTTP
234
235 async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> {
236 if (service === "git-upload-pack") return advertiseUploadPackV2();
237 // Like GitHub, pull request refs are hidden from pushes (so `git push --mirror` leaves them alone).
238 return advertiseReceivePack(this.refs().filter((r) => !r.name.startsWith("refs/pull/")));
239 }
240
241 async gitUploadPack(body: Uint8Array): Promise<Uint8Array> {
242 try {
243 return await handleUploadPackV2(body, { store: this.store, refs: this.refs(), headTarget: this.headRef });
244 } catch (e) {
245 return errorPacket((e as Error).message);
246 }
247 }
248
249 /** `moved`: the push came in under an old name (a transfer or rename), so say where the repository lives now, like GitHub. */
250 async gitReceivePack(body: Uint8Array, caller: Caller, o: { moved?: boolean } = {}): Promise<Uint8Array> {
251 if (body.length > MAX_PUSH_BYTES) {
252 const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null };
253 return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []);
254 }
255 const req = parseReceivePack(body);
256 // `git push -o diffinery-import` from a GitHub import's own token: no workflows, webhooks,
257 // pull request syncs or notifications (see apps/diffinery/src/imports).
258 const importMode = !!caller.importId && req.pushOptions.some((o) => o.trim() === "diffinery-import");
259 // H2: while an import holds the repository, only that import may push. Everything else waits
260 // until the import finalises, so protection, guardrails, CI and webhooks are never bypassed.
261 const lockHolder = this.importLockHolder();
262 if (lockHolder && (!importMode || caller.importId !== lockHolder)) {
263 const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" }));
264 return reportStatus(req, "repository is locked by an import in progress", denied);
265 }
266 const messages: string[] = o.moved ? ["This repository moved. Please use the new location:", ` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}.git`] : [];
267 let unpackError: string | null = null;
268 // Like git's quarantine: nothing from the pack is stored until push protection passes,
269 // so a blocked push leaves no objects behind for a retry to build on.
270 let objects: Awaited<ReturnType<typeof parsePack>> = [];
271 if (req.pack && req.pack.length > 0) {
272 try {
273 objects = await parsePack(req.pack, (oid) => this.store.read(oid));
274 } catch (e) {
275 unpackError = (e as Error).message;
276 }
277 }
278 const newBlobs = objects.filter((o) => o.type === "blob" && o.content.length < 1024 * 1024).map((o) => ({ oid: o.oid, content: o.content }));
279 const results: RefResult[] = [];
280 if (unpackError) {
281 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
282 return reportStatus(req, unpackError, results);
283 }
284
285 const leaks = scanForSecrets(newBlobs);
286 if (leaks.length) {
287 const kinds = leaks.map((l) => l.kind);
288 const target = `${this.meta.owner}/${this.meta.name}`;
289 // Like GitHub's push protection bypass: a human repository admin may push anyway by
290 // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited.
291 const reason = secretBypassReason(req.pushOptions);
292 if (reason && caller.role === "admin" && caller.kind === "user") {
293 messages.push(`Push protection bypassed by ${caller.login}: ${reason}`);
294 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
295 this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason, refs: req.commands.map((c) => c.ref), ...(importMode ? { import: caller.importId, blobs: leaks.slice(0, 50).map((l) => l.oid) } : {}) }, at: now() });
296 if (importMode) this.importsModule.recordFindings(leaks);
297 } else {
298 messages.push("Push blocked: possible secrets detected.");
299 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
300 messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
301 if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection.");
302 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
303 this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds, refs: req.commands.map((c) => c.ref) }, at: now() });
304 return reportStatus(req, null, results, messages);
305 }
306 }
307
308 try {
309 for (const o of objects) await this.store.writeKnown(o.oid, o.type, o.content);
310 } catch (e) {
311 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
312 return reportStatus(req, (e as Error).message, results);
313 }
314
315 // Validate every command before applying any, so `atomic` is honoured.
316 const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = [];
317 for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller, importMode)) });
318 const atomic = req.capabilities.has("atomic");
319 const anyFailed = checks.some((c) => !c.ok);
320 let imported = 0;
321 for (const c of checks) {
322 if (!c.ok) {
323 results.push({ ref: c.ref, ok: false, reason: c.reason });
324 continue;
325 }
326 if (atomic && anyFailed) {
327 results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" });
328 continue;
329 }
330 const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid);
331 results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" });
332 if (applied && importMode) {
333 imported++;
334 // Record provenance so a later push may move this ref only if the import still owns it.
335 if (c.cmd.newOid === ZERO_OID) this.sql.exec("DELETE FROM import_refs WHERE ref = ?", c.cmd.ref);
336 else this.importsModule.recordImportRef(c.cmd.ref, c.cmd.newOid, caller.importId!);
337 } else if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages);
338 }
339 // One audit entry per import push instead of push events.
340 if (imported) this.emit({ type: "audit", action: "import.push", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { import: caller.importId, refs: imported }, at: now() });
341 return reportStatus(req, null, results, messages);
342 }
343
344 /** As on GitHub: a rule naming the branch exactly wins; otherwise the oldest matching pattern. */
345 protectionFor(branch: string): ProtectionRules | null {
346 const rows = this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY rowid").toArray();
347 const row = rows.find((r) => r.pattern === branch) ?? rows.find((r) => globToRegExp(r.pattern).test(branch));
348 return row ? (JSON.parse(row.rules) as ProtectionRules) : null;
349 }
350
351 /**
352 * Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails.
353 * `importMode`: a GitHub import filling the repository it created, which is the only writer
354 * until it finishes, so branch rules (copied from GitHub at its end) don't apply to it.
355 */
356 async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, importMode = false): Promise<RefResult> {
357 if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" };
358 if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" };
359 const deleting = newOid === ZERO_OID;
360 if (ref.startsWith("refs/pull/")) return { ref, ok: false, reason: "refs/pull/ is managed by Diffinery for pull requests" };
361 if (ref.startsWith(`refs/heads/${QUEUE_BRANCH_PREFIX}`)) return { ref, ok: false, reason: "merge queue branches are read-only" };
362 if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" };
363 if (ref.startsWith(IMPORT_REF_PREFIX) && !importMode) return { ref, ok: false, reason: `${IMPORT_REF_PREFIX} is reserved for GitHub imports` };
364 if (importMode) {
365 if (!/^refs\/(heads|tags|diffinery-import)\//.test(ref)) return { ref, ok: false, reason: "imports write branches and tags only" };
366 // H2: create a ref, or move one this import previously wrote — never a ref adopted since.
367 const current = this.getRef(ref);
368 if (current && !deleting) {
369 const owned = this.importsModule.importRefOid(ref, caller.importId!);
370 if (owned !== current) return { ref, ok: false, reason: "import may not overwrite a ref it did not write" };
371 }
372 return { ref, ok: true };
373 }
374 // A workflow's GITHUB_TOKEN never adds or changes workflow files (GitHub's `workflows` permission).
375 const workflowEdit = await this.policy.refuseWorkflowEdit(caller, oldOid, newOid);
376 if (workflowEdit) return { ref, ok: false, reason: workflowEdit };
377 if (!ref.startsWith("refs/heads/")) {
378 if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" };
379 return { ref, ok: true };
380 }
381 const branch = ref.slice(11);
382 const rules = this.protectionFor(branch);
383 const isDefault = branch === this.meta.defaultBranch;
384 if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) {
385 return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" };
386 }
387 if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" };
388 if (!rules) return { ref, ok: true };
389 const bypass = caller.role === "admin" && !rules.enforceAdmins;
390 if (bypass) return { ref, ok: true };
391 if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" };
392 if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" };
393 if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) {
394 return { ref, ok: false, reason: "protected branch: force push not allowed" };
395 }
396 if (rules.requireLinearHistory) {
397 const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 });
398 if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" };
399 }
400 return { ref, ok: true };
401 }
402
403 /** Side effects after a ref moved: events, PR sync, workflow triggers. */
404 async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> {
405 const at = now();
406 const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid));
407 const ev: DiffineryEvent = {
408 type: "push",
409 repoId: this.meta.repoId,
410 owner: this.meta.owner,
411 repo: this.meta.name,
412 ref,
413 before: oldOid,
414 after: newOid,
415 actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf },
416 forced,
417 created: oldOid === ZERO_OID,
418 deleted: newOid === ZERO_OID,
419 at,
420 };
421 this.emit(ev);
422 this.policy.recordPush(ref, newOid, caller);
423 this.deploys.noteRefUpdate(ref, newOid, caller);
424 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) {
425 const branch = ref.slice(11);
426 const synced = await this.collab.syncPullsForBranch(branch, newOid, caller);
427 for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`);
428 if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) {
429 messages.push(`Create a pull request for '${branch}':`);
430 messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`);
431 }
432 }
433 if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
434 // A base branch that moved (outside the queue, or by it) needs its merge groups checked,
435 // and pull requests into it waiting to auto-merge may now be up to date.
436 if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11));
437 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) this.autoMerge.schedule({ base: ref.slice(11) });
438 }
439
440 // ---------------------------------------------------------------- browsing
441
442 async commitInfo(sha: string): Promise<CommitInfo> {
443 const c = await readCommit(this.store, sha);
444 return toInfo(sha, c);
445 }
446
447 async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> {
448 const sha = await this.resolve(rev);
449 if (!sha) return null;
450 const c = await readCommit(this.store, sha);
451 const entry = await resolvePath(this.store, c.tree, path);
452 if (!entry || !isTreeMode(entry.mode)) return null;
453 const items: TreeItem[] = [];
454 for (const e of await readTree(this.store, entry.oid)) {
455 const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob";
456 const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined;
457 items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size });
458 }
459 items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1));
460 return { sha, items };
461 }
462
463 async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> {
464 const sha = await this.resolve(rev);
465 if (!sha) return null;
466 const c = await readCommit(this.store, sha);
467 const entry = await resolvePath(this.store, c.tree, path);
468 if (!entry || isTreeMode(entry.mode)) return null;
469 const o = await this.store.read(entry.oid);
470 if (!o) return null;
471 return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content };
472 }
473
474 async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> {
475 const sha = await this.resolve(rev);
476 if (!sha) return [];
477 const limit = Math.min(opts.limit ?? 30, 100);
478 const skip = opts.skip ?? 0;
479 if (!opts.path) {
480 const commits = await walkCommits(this.store, [sha], { limit: limit + skip });
481 return commits.slice(skip).map((c) => toInfo(c.oid, c.commit));
482 }
483 // Path history: commits whose tree entry at `path` differs from their first parent's.
484 const out: CommitInfo[] = [];
485 let skipped = 0;
486 for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) {
487 const mine = await resolvePath(this.store, c.commit.tree, opts.path);
488 const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null;
489 const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null;
490 if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue;
491 if (skipped++ < skip) continue;
492 out.push(toInfo(c.oid, c.commit));
493 if (out.length >= limit) break;
494 }
495 return out;
496 }
497
498 /** Diff between two commits (or a commit and its first parent). */
499 async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) {
500 const head = await readCommit(this.store, headSha);
501 const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null;
502 const changes = await diffTrees(this.store, baseTree, head.tree);
503 const max = opts.maxFiles ?? 300;
504 const files = [];
505 for (const ch of changes.slice(0, max)) {
506 const d = await fileDiff(this.store, ch);
507 files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) });
508 }
509 return {
510 files,
511 totalFiles: changes.length,
512 additions: files.reduce((s, f) => s + f.additions, 0),
513 deletions: files.reduce((s, f) => s + f.deletions, 0),
514 truncated: changes.length > max,
515 };
516 }
517
518 async commitWithDiff(rev: string) {
519 const sha = await this.resolve(rev);
520 if (!sha) throw new HttpError(404, "commit not found");
521 const info = await this.commitInfo(sha);
522 return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) };
523 }
524
525 async compare(base: string, head: string) {
526 const b = await this.resolve(base);
527 const h = await this.resolve(head);
528 if (!b || !h) throw new HttpError(404, "unknown base or head");
529 const mb = await mergeBase(this.store, b, h);
530 const commits = (await walkCommits(this.store, [h], { stop: new Set(mb ? (await walkCommits(this.store, [mb], { limit: 100000 })).map((c) => c.oid) : []), limit: 250 })).map((c) => toInfo(c.oid, c.commit));
531 const ahead = commits.length;
532 const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0;
533 return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) };
534 }
535
536 /**
537 * Push protection for commits made without a Git client: the same scan as `git push`, so the
538 * web editor, the contents and commits APIs and MCP cannot be used to slip a credential in.
539 */
540 private protectCommit(changes: readonly FileChange[], allowSecrets: string | undefined, caller: Caller): void {
541 const blobs = changes.filter((ch) => !ch.delete && ch.content && ch.content.length < 1024 * 1024).map((ch) => ({ oid: ch.path, content: ch.content! }));
542 const leaks = scanForSecrets(blobs);
543 if (!leaks.length) return;
544 const kinds = [...new Set(leaks.map((l) => l.kind))];
545 const paths = [...new Set(leaks.map((l) => l.oid))].slice(0, 10);
546 const target = `${this.meta.owner}/${this.meta.name}`;
547 const reason = allowSecrets?.trim().slice(0, 200);
548 if (reason && caller.role === "admin" && caller.kind === "user") {
549 this.emit({ type: "audit", action: "api.secret_bypassed", actor: caller, target, detail: { kinds, paths, reason }, at: now() });
550 return;
551 }
552 this.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target, detail: { kinds, paths }, at: now() });
553 const hint = reason ? " Only repository admins (people, not agents) can bypass push protection." : "";
554 throw new HttpError(422, `push protection: possible secret detected (${kinds.join(", ")}) in ${paths.join(", ")}. Remove it, or rotate it and ask an admin.${hint}`);
555 }
556
557 /**
558 * Create a commit from file changes without a Git client (used by the web
559 * editor, the REST contents API and AI agents through MCP).
560 */
561 async commitFiles(input: {
562 branch: string;
563 message: string;
564 changes: FileChange[];
565 expectedHeadSha?: string;
566 createBranchFrom?: string;
567 author?: { name: string; email: string };
568 committer?: { name: string; email: string };
569 /** A human admin's reason for committing a detected secret anyway (like `git push -o allow-secrets=`). */
570 allowSecrets?: string;
571 }, caller: Caller): Promise<{ sha: string; branch: string }> {
572 const ref = `refs/heads/${input.branch}`;
573 let parent = this.getRef(ref);
574 if (!parent) {
575 if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom);
576 else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`);
577 }
578 if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent });
579 for (const ch of input.changes) {
580 const why = ch.delete ? null : unsafeTreePath(ch.path);
581 if (why) throw new HttpError(422, `invalid path ${JSON.stringify(ch.path)}: ${why}`);
582 }
583 this.protectCommit(input.changes, input.allowSecrets, caller);
584 const baseTree = parent ? (await readCommit(this.store, parent)).tree : null;
585 const tree = await applyChanges(this.store, baseTree, input.changes);
586 const ts = Math.floor(Date.now() / 1000);
587 const committer = input.committer
588 ? { ...input.committer, timestamp: ts, timezone: "+0000" }
589 : { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
590 const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer;
591 const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
592 const sha = await this.store.write(
593 "commit",
594 serializeCommit({ tree: tree === EMPTY_TREE_OID ? tree : tree, parents: parent ? [parent] : [], author, committer, message: input.message.endsWith("\n") ? input.message : `${input.message}\n`, extraHeaders: extra }),
595 );
596 const old = parent && this.getRef(ref) ? parent : ZERO_OID;
597 const check = await this.checkRefUpdate(ref, old, sha, caller);
598 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
599 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry");
600 await this.afterRefUpdate(ref, old, sha, caller);
601 return { sha, branch: input.branch };
602 }
603
604 async createRef(ref: string, sha: string, caller: Caller): Promise<void> {
605 if (!this.store.has(sha)) throw new HttpError(422, "object does not exist");
606 const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller);
607 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
608 if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists");
609 await this.afterRefUpdate(ref, ZERO_OID, sha, caller);
610 }
611
612 async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> {
613 const old = this.getRef(ref);
614 if (!old) throw new HttpError(404, "reference not found");
615 if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward");
616 const check = await this.checkRefUpdate(ref, old, sha, caller);
617 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
618 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved");
619 await this.afterRefUpdate(ref, old, sha, caller);
620 }
621
622 async deleteRef(ref: string, caller: Caller): Promise<void> {
623 const old = this.getRef(ref);
624 if (!old) throw new HttpError(404, "reference not found");
625 const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller);
626 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
627 this.casRef(ref, old, null);
628 await this.afterRefUpdate(ref, old, ZERO_OID, caller);
629 }
630
631 // ---------------------------------------------------------------- protections
632
633 listProtections(): Array<{ pattern: string; rules: ProtectionRules }> {
634 return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) }));
635 }
636
637 /**
638 * Create, replace or (with null) delete a rule. Only people who administer the repository change
639 * branch protection: never agents, never a workflow's token. Turning `allowAgentAutoMerge` or
640 * `agentAutoMergeWithoutApproval` on or off is audited on its own, since they let agents land changes.
641 */
642 setProtection(pattern: string, rules: ProtectionRules | null, caller: Caller): void {
643 if (!isPerson(caller) || caller.role !== "admin") throw new HttpError(403, caller.kind === "agent" ? "agents cannot change branch protection" : "only people who administer the repository can change branch protection");
644 if (rules?.mergeQueue) {
645 const asked = typeof rules.mergeQueue === "object" ? rules.mergeQueue.method : undefined;
646 const mergeQueue = normalizeMergeQueue(rules.mergeQueue);
647 // A queue that makes merge commits could never merge into a branch that refuses them.
648 if (rules.requireLinearHistory && mergeQueue.method === "merge") {
649 if (asked !== undefined) throw new HttpError(422, "this branch requires linear history, so its merge queue must squash or rebase (mergeQueue.method), not create merge commits");
650 mergeQueue.method = "squash";
651 }
652 rules = { ...rules, mergeQueue };
653 }
654 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
655 if (rules && rules.allowAgentAutoMerge !== undefined && typeof rules.allowAgentAutoMerge !== "boolean") throw new HttpError(422, "allowAgentAutoMerge must be a boolean");
656 if (rules && rules.agentAutoMergeWithoutApproval !== undefined && typeof rules.agentAutoMergeWithoutApproval !== "boolean") throw new HttpError(422, "agentAutoMergeWithoutApproval must be a boolean");
657 if (rules?.agentAutoMergeWithoutApproval && !rules.allowAgentAutoMerge) throw new HttpError(422, "agentAutoMergeWithoutApproval needs allowAgentAutoMerge: it only applies to what Diffinery lands for agents");
658 if (rules && !rules.allowAgentAutoMerge) {
659 const { allowAgentAutoMerge: _, agentAutoMergeWithoutApproval: _w, ...rest } = rules;
660 rules = rest;
661 }
662 if (rules && !rules.agentAutoMergeWithoutApproval) {
663 const { agentAutoMergeWithoutApproval: _, ...rest } = rules;
664 rules = rest;
665 }
666 const before = this.sql.exec<{ rules: string }>("SELECT rules FROM protections WHERE pattern = ?", pattern).toArray()[0];
667 const old = before ? (JSON.parse(before.rules) as ProtectionRules) : null;
668 const was = !!old?.allowAgentAutoMerge;
669 const waived = !!old?.agentAutoMergeWithoutApproval;
670 if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules));
671 else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
672 if (was !== !!rules?.allowAgentAutoMerge) {
673 const m = this.meta;
674 this.emit({ type: "audit", action: "repo.protection.agent_auto_merge", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.allowAgentAutoMerge, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
675 }
676 if (waived !== !!rules?.agentAutoMergeWithoutApproval) {
677 const m = this.meta;
678 this.emit({ type: "audit", action: "repo.protection.agent_approval_waiver", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.agentAutoMergeWithoutApproval, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
679 }
680 // Turning a queue off (or changing its checks) affects what is queued, and what may auto-merge.
681 this.queue.schedule();
682 this.autoMerge.schedule();
683 }
684
685 // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
686 // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here.
687
688 issues(...a: Parameters<Collab["list"]>) {
689 return this.collab.list(...a);
690 }
691 issue(...a: Parameters<Collab["get"]>) {
692 return this.collab.get(...a);
693 }
694 createIssue(...a: Parameters<Collab["createIssue"]>) {
695 return this.collab.createIssue(...a);
696 }
697 updateIssue(...a: Parameters<Collab["update"]>) {
698 return this.collab.update(...a);
699 }
700 setPullDraft(...a: Parameters<Collab["setDraft"]>) {
701 return this.collab.setDraft(...a);
702 }
703 addComment(...a: Parameters<Collab["addComment"]>) {
704 return this.collab.addComment(...a);
705 }
706 editComment(...a: Parameters<Collab["editComment"]>) {
707 return this.collab.editComment(...a);
708 }
709 createPull(...a: Parameters<Collab["createPull"]>) {
710 return this.collab.createPull(...a);
711 }
712 pullFiles(...a: Parameters<Collab["pullFiles"]>) {
713 return this.collab.pullFiles(...a);
714 }
715 pullCommits(...a: Parameters<Collab["pullCommits"]>) {
716 return this.collab.pullCommits(...a);
717 }
718 submitReview(...a: Parameters<Collab["submitReview"]>) {
719 return this.collab.submitReview(...a);
720 }
721 resolveThread(...a: Parameters<Collab["resolveThread"]>) {
722 return this.collab.resolveThread(...a);
723 }
724 mergeability(...a: Parameters<Collab["mergeability"]>) {
725 return this.collab.mergeability(...a);
726 }
727 mergePull(...a: Parameters<Collab["merge"]>) {
728 return this.collab.merge(...a);
729 }
730 requestReviewers(...a: Parameters<Collab["requestReviewers"]>) {
731 return this.collab.requestReviewers(...a);
732 }
733 labels(...a: Parameters<Collab["labels"]>) {
734 return this.collab.labels(...a);
735 }
736 setLabel(...a: Parameters<Collab["setLabel"]>) {
737 return this.collab.setLabel(...a);
738 }
739 checks(...a: Parameters<Collab["checks"]>) {
740 return this.collab.checks(...a);
741 }
742 upsertCheck(...a: Parameters<Collab["upsertCheck"]>) {
743 return this.collab.upsertCheck(...a);
744 }
745 webhooks(...a: Parameters<Collab["webhooks"]>) {
746 return this.collab.webhooks(...a);
747 }
748 createWebhook(...a: Parameters<Collab["createWebhook"]>) {
749 return this.collab.createWebhook(...a);
750 }
751 deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) {
752 return this.collab.deleteWebhook(...a);
753 }
754 recordDelivery(...a: Parameters<Collab["recordDelivery"]>) {
755 return this.collab.recordDelivery(...a);
756 }
757
758 enqueuePull(...a: Parameters<MergeQueue["enqueue"]>) {
759 return this.queue.enqueue(...a);
760 }
761 dequeuePull(...a: Parameters<MergeQueue["dequeue"]>) {
762 return this.queue.dequeue(...a);
763 }
764 mergeQueue(...a: Parameters<MergeQueue["list"]>) {
765 return this.queue.list(...a);
766 }
767 mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) {
768 return this.queue.settings(...a);
769 }
770 enableAutoMerge(...a: Parameters<AutoMerge["enable"]>) {
771 return this.autoMerge.enable(...a);
772 }
773 disableAutoMerge(...a: Parameters<AutoMerge["disable"]>) {
774 return this.autoMerge.disable(...a);
775 }
776
777 runs(...a: Parameters<Actions["listRuns"]>) {
778 return this.actions.listRuns(...a);
779 }
780 run(...a: Parameters<Actions["getRun"]>) {
781 return this.actions.getRun(...a);
782 }
783 runJobs(...a: Parameters<Actions["jobs"]>) {
784 return this.actions.jobs(...a);
785 }
786 claimJob(...a: Parameters<Actions["claimJob"]>) {
787 return this.actions.claimJob(...a);
788 }
789 jobStatus(jobId: string): string {
790 return this.actions.getJob(jobId).status;
791 }
792 job(...a: Parameters<Actions["getJob"]>) {
793 return this.actions.getJob(...a);
794 }
795 jobLogs(...a: Parameters<Actions["logs"]>) {
796 return this.actions.logs(...a);
797 }
798 cancelRun(...a: Parameters<Actions["cancel"]>) {
799 return this.actions.cancel(...a);
800 }
801 rerun(...a: Parameters<Actions["rerun"]>) {
802 return this.actions.rerun(...a);
803 }
804 dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) {
805 return this.actions.dispatch(...a);
806 }
807 scheduleTick(...a: Parameters<Actions["schedule"]>) {
808 return this.actions.schedule(...a);
809 }
810 onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) {
811 return this.actions.onPullRequest(...a);
812 }
813 workflows(...a: Parameters<Actions["listWorkflows"]>) {
814 return this.actions.listWorkflows(...a);
815 }
816 runPlan(...a: Parameters<Actions["plan"]>) {
817 return this.actions.plan(...a);
818 }
819 startJobKey(...a: Parameters<Actions["startJobKey"]>) {
820 return this.actions.startJobKey(...a);
821 }
822 jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) {
823 return this.actions.jobTimedOut(...a);
824 }
825 finishRun(...a: Parameters<Actions["finishRun"]>) {
826 return this.actions.finishRun(...a);
827 }
828 oidcClaims(...a: Parameters<Actions["oidcClaims"]>) {
829 return this.actions.oidcClaims(...a);
830 }
831 runnerSpec(...a: Parameters<Actions["runnerSpec"]>) {
832 return this.actions.runnerSpec(...a);
833 }
834 runnerLogs(...a: Parameters<Actions["runnerLogs"]>) {
835 return this.actions.runnerLogs(...a);
836 }
837 runnerStep(...a: Parameters<Actions["runnerStep"]>) {
838 return this.actions.runnerStep(...a);
839 }
840 runnerComplete(...a: Parameters<Actions["runnerComplete"]>) {
841 return this.actions.runnerComplete(...a);
842 }
843 packagesPermission(...a: Parameters<Actions["packagesPermission"]>) {
844 return this.actions.packagesPermission(...a);
845 }
846 runnerAuth(...a: Parameters<Actions["runnerAuth"]>) {
847 return this.actions.runnerAuth(...a);
848 }
849 containerStopped(...a: Parameters<Actions["containerStopped"]>) {
850 return this.actions.containerStopped(...a);
851 }
852 setSecret(...a: Parameters<Actions["setSecret"]>) {
853 return this.actions.setSecret(...a);
854 }
855 deleteSecret(...a: Parameters<Actions["deleteSecret"]>) {
856 return this.actions.deleteSecret(...a);
857 }
858 secretNames(...a: Parameters<Actions["secretNames"]>) {
859 return this.actions.secretNames(...a);
860 }
861 setVariable(...a: Parameters<Actions["setVariable"]>) {
862 return this.actions.setVariable(...a);
863 }
864 variables(...a: Parameters<Actions["variables"]>) {
865 return this.actions.variables(...a);
866 }
867 cacheLookup(...a: Parameters<Actions["cacheLookup"]>) {
868 return this.actions.cacheLookup(...a);
869 }
870 cacheSaved(...a: Parameters<Actions["cacheSaved"]>) {
871 return this.actions.cacheSaved(...a);
872 }
873 artifactSaved(...a: Parameters<Actions["artifactSaved"]>) {
874 return this.actions.artifactSaved(...a);
875 }
876 artifacts(...a: Parameters<Actions["artifacts"]>) {
877 return this.actions.artifacts(...a);
878 }
879 createSandbox(...a: Parameters<Actions["createSandbox"]>) {
880 return this.actions.createSandbox(...a);
881 }
882 sandbox(...a: Parameters<Actions["getSandbox"]>) {
883 return this.actions.getSandbox(...a);
884 }
885 closeSandbox(...a: Parameters<Actions["closeSandbox"]>) {
886 return this.actions.closeSandbox(...a);
887 }
888
889 plannedRunError(runId: string, message: string) {
890 return this.actions.plannedError(runId, message);
891 }
892 cacheSaveKeys(...a: Parameters<Actions["cacheSaveKeys"]>) {
893 return this.actions.cacheSaveKeys(...a);
894 }
895 cacheReadKeys(...a: Parameters<Actions["cacheReadKeys"]>) {
896 return this.actions.cacheReadKeys(...a);
897 }
898 artifactKeysFor(...a: Parameters<Actions["artifactKeysFor"]>) {
899 return this.actions.artifactKeysFor(...a);
900 }
901 artifactQuota() {
902 return this.runtime.artifactQuota;
903 }
904
905 // ---------------------------------------------------------------- RPC passthroughs (Actions runtime services)
906 // The cache and artifact services of repo/runtime.ts, called by http/actions-runtime.ts
907 // with the job named by a runtime token, and by the REST caches/artifacts routes.
908
909 rtJob(...a: Parameters<Runtime["job"]>) {
910 return this.runtime.job(...a);
911 }
912 rtGrantFor(...a: Parameters<Runtime["grantFor"]>) {
913 return this.runtime.grantFor(...a);
914 }
915 rtCacheLookup(...a: Parameters<Runtime["cacheLookup"]>) {
916 return this.runtime.cacheLookup(...a);
917 }
918 rtCacheReserve(...a: Parameters<Runtime["cacheReserve"]>) {
919 return this.runtime.cacheReserve(...a);
920 }
921 rtCacheReservation(...a: Parameters<Runtime["cacheReservation"]>) {
922 return this.runtime.cacheReservation(...a);
923 }
924 rtCacheWritable(...a: Parameters<Runtime["cacheWritable"]>) {
925 return this.runtime.cacheWritable(...a);
926 }
927 rtCacheCommit(...a: Parameters<Runtime["cacheCommit"]>) {
928 return this.runtime.cacheCommit(...a);
929 }
930 rtCacheGet(...a: Parameters<Runtime["cacheGet"]>) {
931 return this.runtime.cacheGet(...a);
932 }
933 rtListCaches(...a: Parameters<Runtime["listCaches"]>) {
934 return this.runtime.listCaches(...a);
935 }
936 rtDeleteCaches(...a: Parameters<Runtime["deleteCaches"]>) {
937 return this.runtime.deleteCaches(...a);
938 }
939 rtCacheUsage(...a: Parameters<Runtime["cacheUsage"]>) {
940 return this.runtime.cacheUsage(...a);
941 }
942 rtArtifactCreate(...a: Parameters<Runtime["artifactCreate"]>) {
943 return this.runtime.artifactCreate(...a);
944 }
945 rtArtifactUpload(...a: Parameters<Runtime["artifactUpload"]>) {
946 return this.runtime.artifactUpload(...a);
947 }
948 rtArtifactWritable(...a: Parameters<Runtime["artifactWritable"]>) {
949 return this.runtime.artifactWritable(...a);
950 }
951 rtArtifactFinalize(...a: Parameters<Runtime["artifactFinalize"]>) {
952 return this.runtime.artifactFinalize(...a);
953 }
954 rtArtifactGet(...a: Parameters<Runtime["artifactGet"]>) {
955 return this.runtime.artifactGet(...a);
956 }
957 rtJobArtifacts(...a: Parameters<Runtime["jobArtifacts"]>) {
958 return this.runtime.jobArtifacts(...a);
959 }
960 rtRunArtifacts(...a: Parameters<Runtime["runArtifacts"]>) {
961 return this.runtime.runArtifacts(...a);
962 }
963 rtRepoArtifacts(...a: Parameters<Runtime["repoArtifacts"]>) {
964 return this.runtime.repoArtifacts(...a);
965 }
966 rtArtifactDelete(...a: Parameters<Runtime["artifactDelete"]>) {
967 return this.runtime.artifactDelete(...a);
968 }
969 rtArtifactDeleteByName(...a: Parameters<Runtime["artifactDeleteByName"]>) {
970 return this.runtime.artifactDeleteByName(...a);
971 }
972 workflowSettings() {
973 return this.policy.settings();
974 }
975 setWorkflowSettings(...a: Parameters<TokenPolicy["setSettings"]>) {
976 return this.policy.setSettings(...a);
977 }
978 rtStage(...a: Parameters<Runtime["stage"]>) {
979 return this.runtime.stage(...a);
980 }
981 rtUnstage(...a: Parameters<Runtime["unstage"]>) {
982 return this.runtime.unstage(...a);
983 }
984 artifactRoom() {
985 return this.runtime.artifactRoom();
986 }
987 rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) {
988 return this.runtime.checkCommitSize(...a);
989 }
990 rtCacheMissing(...a: Parameters<Runtime["cacheMissing"]>) {
991 return this.runtime.cacheMissing(...a);
992 }
993 rtComposeStep(...a: Parameters<Runtime["composeStep"]>) {
994 return this.runtime.composeStep(...a);
995 }
996
997 // ---------------------------------------------------------------- RPC passthroughs (GitHub-compatible extras)
998 // Releases, commit statuses, deployments and environments, issue sub-resources
999 // (labels, reactions, events, locking), the Git data API, search and misc.
1000 // The modules are created on first use.
1001
1002 private extras: { releases?: Releases; statuses?: Statuses; deploys?: Deployments; social?: Social; gitdata?: GitData } = {};
1003 get releases(): Releases {
1004 return (this.extras.releases ??= new Releases(this, this.env));
1005 }
1006 get statuses(): Statuses {
1007 return (this.extras.statuses ??= new Statuses(this));
1008 }
1009 get deploys(): Deployments {
1010 return (this.extras.deploys ??= new Deployments(this, this.env));
1011 }
1012 get social(): Social {
1013 return (this.extras.social ??= new Social(this));
1014 }
1015 get gitdata(): GitData {
1016 return (this.extras.gitdata ??= new GitData(this, this.env));
1017 }
1018
1019 listReleases(...a: Parameters<Releases["list"]>) {
1020 return this.releases.list(...a);
1021 }
1022 getRelease(...a: Parameters<Releases["get"]>) {
1023 return this.releases.get(...a);
1024 }
1025 releaseByTag(...a: Parameters<Releases["byTag"]>) {
1026 return this.releases.byTag(...a);
1027 }
1028 latestRelease() {
1029 return this.releases.latest();
1030 }
1031 releaseSummary(...a: Parameters<Releases["summary"]>) {
1032 return this.releases.summary(...a);
1033 }
1034 createRelease(...a: Parameters<Releases["create"]>) {
1035 return this.releases.create(...a);
1036 }
1037 updateRelease(...a: Parameters<Releases["update"]>) {
1038 return this.releases.update(...a);
1039 }
1040 deleteRelease(...a: Parameters<Releases["remove"]>) {
1041 return this.releases.remove(...a);
1042 }
1043 generateReleaseNotes(...a: Parameters<Releases["generateNotes"]>) {
1044 return this.releases.generateNotes(...a);
1045 }
1046 releaseAssets(...a: Parameters<Releases["assets"]>) {
1047 return this.releases.assets(...a);
1048 }
1049 releaseAsset(...a: Parameters<Releases["asset"]>) {
1050 return this.releases.asset(...a);
1051 }
1052 updateReleaseAsset(...a: Parameters<Releases["updateAsset"]>) {
1053 return this.releases.updateAsset(...a);
1054 }
1055 deleteReleaseAsset(...a: Parameters<Releases["deleteAsset"]>) {
1056 return this.releases.deleteAsset(...a);
1057 }
1058 startReleaseAsset(...a: Parameters<Releases["startAsset"]>) {
1059 return this.releases.startAsset(...a);
1060 }
1061 finishReleaseAsset(...a: Parameters<Releases["finishAsset"]>) {
1062 return this.releases.finishAsset(...a);
1063 }
1064 abortReleaseAsset(...a: Parameters<Releases["abortAsset"]>) {
1065 return this.releases.abortAsset(...a);
1066 }
1067 releaseDownload(...a: Parameters<Releases["download"]>) {
1068 return this.releases.download(...a);
1069 }
1070 /** For the GitHub importer: a release with its original dates and author (no tag, events or workflows). */
1071 importRelease(...a: Parameters<Releases["importRelease"]>) {
1072 return this.releases.importRelease(...a);
1073 }
1074 /** For the GitHub importer: an asset copied from a public https URL, or from an R2 key under this repository's prefix. */
1075 importReleaseAsset(...a: Parameters<Releases["importAsset"]>) {
1076 return this.releases.importAsset(...a);
1077 }
1078
1079 createStatus(...a: Parameters<Statuses["create"]>) {
1080 return this.statuses.create(...a);
1081 }
1082 commitStatuses(...a: Parameters<Statuses["list"]>) {
1083 return this.statuses.list(...a);
1084 }
1085 combinedStatus(...a: Parameters<Statuses["combined"]>) {
1086 return this.statuses.combined(...a);
1087 }
1088 /** Check runs and commit statuses together, as pull request and commit pages show them. */
1089 checksAndStatuses(sha: string) {
1090 return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)];
1091 }
1092
1093 createDeployment(...a: Parameters<Deployments["create"]>) {
1094 return this.deploys.create(...a);
1095 }
1096 deployments(...a: Parameters<Deployments["list"]>) {
1097 return this.deploys.list(...a);
1098 }
1099 deployment(...a: Parameters<Deployments["get"]>) {
1100 return this.deploys.get(...a);
1101 }
1102 deleteDeployment(...a: Parameters<Deployments["remove"]>) {
1103 return this.deploys.remove(...a);
1104 }
1105 createDeploymentStatus(...a: Parameters<Deployments["createStatus"]>) {
1106 return this.deploys.createStatus(...a);
1107 }
1108 deploymentStatuses(...a: Parameters<Deployments["statuses"]>) {
1109 return this.deploys.statuses(...a);
1110 }
1111 deploymentStatus(...a: Parameters<Deployments["status"]>) {
1112 return this.deploys.status(...a);
1113 }
1114 environments() {
1115 return this.deploys.environments();
1116 }
1117 environment(...a: Parameters<Deployments["environment"]>) {
1118 return this.deploys.environment(...a);
1119 }
1120 upsertEnvironment(...a: Parameters<Deployments["upsertEnvironment"]>) {
1121 return this.deploys.upsertEnvironment(...a);
1122 }
1123 deleteEnvironment(...a: Parameters<Deployments["deleteEnvironment"]>) {
1124 return this.deploys.deleteEnvironment(...a);
1125 }
1126 addEnvironmentBranchPattern(...a: Parameters<Deployments["addBranchPattern"]>) {
1127 return this.deploys.addBranchPattern(...a);
1128 }
1129 removeEnvironmentBranchPattern(...a: Parameters<Deployments["removeBranchPattern"]>) {
1130 return this.deploys.removeBranchPattern(...a);
1131 }
1132 setEnvironmentSecret(...a: Parameters<Deployments["setSecret"]>) {
1133 return this.deploys.setSecret(...a);
1134 }
1135 deleteEnvironmentSecret(...a: Parameters<Deployments["deleteSecret"]>) {
1136 return this.deploys.deleteSecret(...a);
1137 }
1138 environmentSecretNames(...a: Parameters<Deployments["secretNames"]>) {
1139 return this.deploys.secretNames(...a);
1140 }
1141 setEnvironmentVariable(...a: Parameters<Deployments["setVariable"]>) {
1142 return this.deploys.setVariable(...a);
1143 }
1144 environmentVariables(...a: Parameters<Deployments["variables"]>) {
1145 return this.deploys.variables(...a);
1146 }
1147
1148 /** What the GitHub JSON builders need for a set of issues: label rows, reaction counts and lock reasons. */
1149 issueExtras(numbers: number[], subjects: string[] = []) {
1150 const lockReasons: Record<number, string | null> = {};
1151 for (const n of numbers) lockReasons[n] = this.social.lockReason(n);
1152 return { labels: this.social.labelRows(), reactions: this.social.reactionCounts([...numbers.map((n) => `issue:${n}`), ...subjects]), lockReasons };
1153 }
1154 issueEvents(...a: Parameters<Social["events"]>) {
1155 return this.social.events(...a);
1156 }
1157 repoIssueEvents(...a: Parameters<Social["repoEvents"]>) {
1158 return this.social.repoEvents(...a);
1159 }
1160 issueEvent(...a: Parameters<Social["event"]>) {
1161 return this.social.event(...a);
1162 }
1163 issueTimeline(...a: Parameters<Social["timeline"]>) {
1164 return this.social.timeline(...a);
1165 }
1166 labelRows() {
1167 return this.social.labelRows();
1168 }
1169 label(...a: Parameters<Social["label"]>) {
1170 return this.social.label(...a);
1171 }
1172 createLabel(...a: Parameters<Social["createLabel"]>) {
1173 return this.social.createLabel(...a);
1174 }
1175 updateLabel(...a: Parameters<Social["updateLabel"]>) {
1176 return this.social.updateLabel(...a);
1177 }
1178 deleteLabel(...a: Parameters<Social["deleteLabel"]>) {
1179 return this.social.deleteLabel(...a);
1180 }
1181 issueLabels(...a: Parameters<Social["issueLabels"]>) {
1182 return this.social.issueLabels(...a);
1183 }
1184 addIssueLabels(...a: Parameters<Social["addLabels"]>) {
1185 return this.social.addLabels(...a);
1186 }
1187 setIssueLabels(...a: Parameters<Social["setLabels"]>) {
1188 return this.social.setLabels(...a);
1189 }
1190 removeIssueLabel(...a: Parameters<Social["removeLabel"]>) {
1191 return this.social.removeLabel(...a);
1192 }
1193 assignIssue(...a: Parameters<Social["assign"]>) {
1194 return this.social.assign(...a);
1195 }
1196 lockIssue(...a: Parameters<Social["lock"]>) {
1197 return this.social.lock(...a);
1198 }
1199 unlockIssue(...a: Parameters<Social["unlock"]>) {
1200 return this.social.unlock(...a);
1201 }
1202 comment(...a: Parameters<Social["comment"]>) {
1203 return this.social.comment(...a);
1204 }
1205 deleteComment(...a: Parameters<Social["deleteComment"]>) {
1206 return this.social.deleteComment(...a);
1207 }
1208 repoComments(...a: Parameters<Social["repoComments"]>) {
1209 return this.social.repoComments(...a);
1210 }
1211 createReviewComment(...a: Parameters<Social["createReviewComment"]>) {
1212 return this.social.createReviewComment(...a);
1213 }
1214 review(...a: Parameters<Social["review"]>) {
1215 return this.social.review(...a);
1216 }
1217 dismissReview(...a: Parameters<Social["dismissReview"]>) {
1218 return this.social.dismissReview(...a);
1219 }
1220 removeReviewers(...a: Parameters<Social["removeReviewers"]>) {
1221 return this.social.removeReviewers(...a);
1222 }
1223 updatePullBranch(...a: Parameters<Social["updateBranch"]>) {
1224 return this.social.updateBranch(...a);
1225 }
1226 pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) {
1227 return this.social.pullsForCommit(...a);
1228 }
1229 reactions(...a: Parameters<Social["reactions"]>) {
1230 return this.social.reactions(...a);
1231 }
1232 react(...a: Parameters<Social["react"]>) {
1233 return this.social.react(...a);
1234 }
1235 unreact(...a: Parameters<Social["unreact"]>) {
1236 return this.social.unreact(...a);
1237 }
1238 searchIssues(query: Parameters<typeof searchIssues>[1], limit: number) {
1239 return searchIssues(this, query, limit);
1240 }
1241 /** The issues for one page of search hits, in the order given (missing numbers are skipped). */
1242 issuesByNumber(numbers: number[]) {
1243 return numbers.slice(0, 100).flatMap((n) => {
1244 try {
1245 return [this.collab.get(n).issue];
1246 } catch {
1247 return [];
1248 }
1249 });
1250 }
1251
1252 objectType(...a: Parameters<GitData["objectType"]>) {
1253 return this.gitdata.objectType(...a);
1254 }
1255 refsWithTypes(...a: Parameters<GitData["refsWithTypes"]>) {
1256 return this.gitdata.refsWithTypes(...a);
1257 }
1258 createBlob(...a: Parameters<GitData["createBlob"]>) {
1259 return this.gitdata.createBlob(...a);
1260 }
1261 getBlob(...a: Parameters<GitData["getBlob"]>) {
1262 return this.gitdata.getBlob(...a);
1263 }
1264 createTree(...a: Parameters<GitData["createTree"]>) {
1265 return this.gitdata.createTree(...a);
1266 }
1267 getTree(...a: Parameters<GitData["getTree"]>) {
1268 return this.gitdata.getTree(...a);
1269 }
1270 createGitCommit(...a: Parameters<GitData["createCommit"]>) {
1271 return this.gitdata.createCommit(...a);
1272 }
1273 gitCommit(...a: Parameters<GitData["gitCommit"]>) {
1274 return this.gitdata.gitCommit(...a);
1275 }
1276 createGitTag(...a: Parameters<GitData["createTag"]>) {
1277 return this.gitdata.createTag(...a);
1278 }
1279 gitTag(...a: Parameters<GitData["getTag"]>) {
1280 return this.gitdata.getTag(...a);
1281 }
1282 putContents(...a: Parameters<GitData["putContents"]>) {
1283 return this.gitdata.putContents(...a);
1284 }
1285 deleteContents(...a: Parameters<GitData["deleteContents"]>) {
1286 return this.gitdata.deleteContents(...a);
1287 }
1288 readme(...a: Parameters<GitData["readme"]>) {
1289 return this.gitdata.readme(...a);
1290 }
1291 mergeBranches(...a: Parameters<GitData["merge"]>) {
1292 return this.gitdata.merge(...a);
1293 }
1294 languages() {
1295 return this.gitdata.languages();
1296 }
1297 archiveKey(...a: Parameters<GitData["archiveKey"]>) {
1298 return this.gitdata.archiveKey(...a);
1299 }
1300 buildArchive(...a: Parameters<GitData["buildArchive"]>) {
1301 return this.gitdata.buildArchive(...a);
1302 }
1303 runLogsZip(...a: Parameters<GitData["runLogsZip"]>) {
1304 return this.gitdata.runLogsZip(...a);
1305 }
1306 runAttemptId(...a: Parameters<GitData["runAttemptId"]>) {
1307 return this.gitdata.runAttemptId(...a);
1308 }
1309 checkRun(...a: Parameters<GitData["check"]>) {
1310 return this.gitdata.check(...a);
1311 }
1312 updateCheckRun(...a: Parameters<GitData["updateCheck"]>) {
1313 return this.gitdata.updateCheck(...a);
1314 }
1315 checkSuites(...a: Parameters<GitData["checkSuites"]>) {
1316 return this.gitdata.checkSuites(...a);
1317 }
1318 checkSuite(...a: Parameters<GitData["checkSuite"]>) {
1319 return this.gitdata.checkSuite(...a);
1320 }
1321
1322 /** repository_dispatch: an external event that starts workflows listening for its `event_type`. */
1323 async repositoryDispatch(eventType: string, clientPayload: Record<string, unknown>, caller: Caller): Promise<void> {
1324 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events");
1325 const sha = this.getRef(this.headRef);
1326 if (!sha) throw new HttpError(422, "the repository is empty");
1327 const m = this.meta;
1328 const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload };
1329 this.emit({ type: "repository_dispatch", action: eventType, repoId: m.repoId, owner: m.owner, repo: m.name, payload: JSON.parse(JSON.stringify(payload)), actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() });
1330 await triggerWorkflows(this, { name: "repository_dispatch", action: eventType, ref: this.headRef, sha, payload, title: eventType }, caller);
1331 }
1332
1333 // ---------------------------------------------------------------- RPC passthroughs: GitHub imports and milestones (repo/imports.ts)
1334
1335 private importsCache?: RepoImports;
1336 private get importsModule(): RepoImports {
1337 return (this.importsCache ??= new RepoImports(this.sql));
1338 }
1339
1340 /** The import that currently holds this repository, or null. */
1341 importLockHolder(): string | null {
1342 return this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'import_lock'").toArray()[0]?.value ?? null;
1343 }
1344
1345 /**
1346 * Claim the repository for an import. Refuses if another import holds it, or if the repo
1347 * already holds any ref, issue, comment, milestone or protection this import did not write
1348 * (so a retry never adopts content people added in the meantime — H2).
1349 */
1350 importBegin(importId: string): void {
1351 const holder = this.importLockHolder();
1352 if (holder && holder !== importId) throw new HttpError(409, "another import is in progress for this repository");
1353 // A ref not written by this import, or any foreign row, means the repo was adopted since.
1354 for (const r of this.refs()) {
1355 if (r.name.startsWith("refs/diffinery-import/")) continue;
1356 if (this.importsModule.importRefOid(r.name, importId) === null) {
1357 if (holder !== importId) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
1358 }
1359 }
1360 if (this.importsModule.hasForeignContent(importId)) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
1361 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('import_lock', ?)", importId);
1362 }
1363
1364 /** Release the import lock (on finalize, fail or cancel). */
1365 importEnd(importId: string): void {
1366 if (this.importLockHolder() === importId) this.sql.exec("DELETE FROM meta WHERE key = 'import_lock'");
1367 }
1368
1369 /** Refuse any non-import write while an import holds the repository. */
1370 private assertWritable(importId?: string): void {
1371 const holder = this.importLockHolder();
1372 if (holder && holder !== importId) throw new HttpError(423, "repository is locked by an import in progress");
1373 }
1374
1375 importProtection(pattern: string, rules: ProtectionRules | null, importId: string) {
1376 // What agents may do is a person's decision here, never something an import brings in.
1377 if (rules) {
1378 const { allowAgentAutoMerge: _a, agentAutoMergeWithoutApproval: _w, agentsMayPush: _p, agentsMayMerge: _m, ...rest } = rules;
1379 rules = rest;
1380 }
1381 if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
1382 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
1383 if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId);
1384 }
1385 importLabels(...a: Parameters<RepoImports["labels"]>) {
1386 return this.importsModule.labels(...a);
1387 }
1388 importMilestones(...a: Parameters<RepoImports["milestones"]>) {
1389 return this.importsModule.milestones(...a);
1390 }
1391 importIssues(...a: Parameters<RepoImports["issues"]>) {
1392 return this.importsModule.issues(...a);
1393 }
1394 importComments(...a: Parameters<RepoImports["comments"]>) {
1395 return this.importsModule.comments(...a);
1396 }
1397 importReviews(...a: Parameters<RepoImports["reviews"]>) {
1398 return this.importsModule.reviews(...a);
1399 }
1400 importSetMergedBy(...a: Parameters<RepoImports["setMergedBy"]>) {
1401 return this.importsModule.setMergedBy(...a);
1402 }
1403 importPullNumbers(...a: Parameters<RepoImports["pullNumbers"]>) {
1404 return this.importsModule.pullNumbers(...a);
1405 }
1406 importCounts() {
1407 return this.importsModule.counts();
1408 }
1409 importFindings() {
1410 return this.importsModule.findings();
1411 }
1412 /**
1413 * Point `refs/pull/N/head` at each imported pull request's head commit (the server owns
1414 * refs/pull/*; the agent pushed the commits to refs/diffinery-import/pull/N). Heads whose
1415 * commits never arrived (e.g. deleted forks) are reported, not fatal.
1416 */
1417 importPullRefs(importId?: string): { created: number; missing: number[] } {
1418 const missing: number[] = [];
1419 let created = 0;
1420 for (const p of this.importsModule.pullHeads()) {
1421 const ref = `refs/pull/${p.number}/head`;
1422 if (!/^[0-9a-f]{40}$/.test(p.headSha) || !this.store.has(p.headSha)) {
1423 missing.push(p.number);
1424 continue;
1425 }
1426 const current = this.getRef(ref);
1427 // Record provenance so a retry does not mistake refs/pull/*/head for content people adopted.
1428 if (importId) this.importsModule.recordImportRef(ref, p.headSha, importId);
1429 if (current === p.headSha) continue;
1430 if (this.casRef(ref, current, p.headSha)) created++;
1431 }
1432 return { created, missing };
1433 }
1434 /** Remove the import's own refs once refs/pull/N/head keep the pull request commits reachable. */
1435 importDropTempRefs(importId?: string): number {
1436 const refs = this.refs(IMPORT_REF_PREFIX);
1437 for (const r of refs) this.casRef(r.name, r.oid, null);
1438 if (importId) this.importsModule.dropImportRefs(importId);
1439 return refs.length;
1440 }
1441 setOrgVariables(...a: Parameters<RepoImports["setOrgVariables"]>) {
1442 return this.importsModule.setOrgVariables(...a);
1443 }
1444 issueMilestoneOf(number: number) {
1445 return this.importsModule.milestoneOf(number);
1446 }
1447 /** Check names that workflow jobs on the default branch report, keyed by job name (GitHub names checks by job). */
1448 async importCheckNames(): Promise<Record<string, string[]>> {
1449 const sha = await this.resolve("HEAD");
1450 const out: Record<string, string[]> = {};
1451 if (!sha) return out;
1452 for (const w of await this.actions.loadWorkflows(sha)) {
1453 if (!w.workflow) continue;
1454 const wf = w.workflow.name ?? w.path.split("/").pop()!;
1455 for (const [key, job] of Object.entries(w.workflow.jobs)) {
1456 const name = (job as { name?: unknown }).name;
1457 for (const n of new Set([key, typeof name === "string" ? name : key])) (out[n] ??= []).push(`${wf} / ${typeof name === "string" && !name.includes("${{") ? name : key}`);
1458 }
1459 }
1460 return out;
1461 }
1462 milestones(...a: Parameters<RepoImports["listMilestones"]>) {
1463 return this.importsModule.listMilestones(...a);
1464 }
1465 milestone(...a: Parameters<RepoImports["milestone"]>) {
1466 return this.importsModule.milestone(...a);
1467 }
1468 saveMilestone(...a: Parameters<RepoImports["saveMilestone"]>) {
1469 return this.importsModule.saveMilestone(...a);
1470 }
1471 setIssueMilestone(...a: Parameters<RepoImports["setIssueMilestone"]>) {
1472 return this.importsModule.setIssueMilestone(...a);
1473 }
1474
1475 // ---------------------------------------------------------------- live updates (WebSockets with hibernation)
1476
1477 override async fetch(request: Request): Promise<Response> {
1478 if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
1479 const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10);
1480 const pair = new WebSocketPair();
1481 this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]);
1482 return new Response(null, { status: 101, webSocket: pair[0] });
1483 }
1484
1485 override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
1486 if (message === "ping") ws.send("pong");
1487 }
1488
1489 override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
1490 try {
1491 ws.close(code, "bye");
1492 } catch {
1493 /* already closed */
1494 }
1495 }
1496
1497 broadcast(topic: string, data: unknown): void {
1498 const msg = JSON.stringify({ topic, data });
1499 for (const ws of this.ctx.getWebSockets(topic)) {
1500 try {
1501 ws.send(msg);
1502 } catch {
1503 /* client went away */
1504 }
1505 }
1506 }
1507}
1508
1509export function toInfo(sha: string, c: Commit): CommitInfo {
1510 return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig };
1511}
1512
1513export { fromUtf8 };