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.

1947 lines · 89.6 KBRaw History
1import { DurableObject } from "cloudflare:workers";
2import { normalizeCheckSources, requiredCheck } from "./required.js";
3import { REVIEW_APP, REVIEW_CHECK } from "../agentic/review.js";
4import { unsafeTreePath } from "../lib/tree-names.js";
5import {
6 CODE_TYPES,
7 EMPTY_TREE_OID,
8 ZERO_OID,
9 applyChanges,
10 DiffBudget,
11 diffTrees,
12 fileDiff,
13 formatUnified,
14 fromUtf8,
15 isAncestor,
16 isBinary,
17 isOid,
18 isTreeMode,
19 mergeBase,
20 parseCommit,
21 packStream,
22 parsePack,
23 parseTree,
24 planFetch,
25 peelToCommit,
26 readCommit,
27 readTree,
28 resolvePath,
29 TooMuchMemory,
30 serializeCommit,
31 walkCommits,
32 type Commit,
33 type FileChange,
34 type Signature,
35 utf8,
36} from "@diffinery/git-core";
37import type { DiffineryEvent } from "@diffinery/shared";
38import type { Env } from "../env.js";
39import {
40 advertiseReceivePack,
41 advertiseUploadPackV2,
42 errorPacket,
43 handleUploadPackV2,
44 parseReceivePack,
45 reportStatus,
46 type ReceiveRequest,
47 unpackFailure,
48 type RefEntry,
49 type RefResult,
50} from "../git/protocol.js";
51import { memorySource, packedContents, readStagedHead, repositoryBases, stagedContents, streamedContents, type PushContents } from "../git/push-contents.js";
52import { scanForSecrets, secretBypassReason, type SecretHit } from "../git/secret-scan.js";
53import { GHOST, HttpError, isValidRefName, now } from "../lib/util.js";
54import { R2Sink } from "../lib/r2-sink.js";
55import { archivePrefix, deletePrefixes, repoPrefixes, type SpilledBody } from "../lib/storage-keys.js";
56import { storageRefusal } from "../lib/quotas.js";
57import { Actions } from "./actions.js";
58import { AutoMerge, isPerson } from "./auto-merge.js";
59import { Collab } from "./collab.js";
60import { Deployments } from "./deployments.js";
61import { triggerWorkflows } from "./extra-triggers.js";
62import { GitData } from "./gitdata.js";
63import { IMPORT_REF_PREFIX, RepoImports } from "./imports.js";
64import { RepoObjectStore } from "./objects.js";
65import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js";
66import { Releases } from "./releases.js";
67import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js";
68import { Runtime } from "./runtime.js";
69import { TokenPolicy } from "./token-policy.js";
70import { searchIssues } from "./search.js";
71import { repoToday } from "../agentic/today-repo.js";
72import { Social } from "./social.js";
73import { Statuses } from "./statuses.js";
74import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
75
76const MAX_PUSH_BYTES = 100 * 1024 * 1024;
77/** Pushed blobs this large are hashed, scanned and stored from their compressed bytes, never held inflated. */
78const LARGE_OBJECT_BYTES = 4 * 1024 * 1024;
79/** A purge walks every reachable commit and tree while holding the repository: bounded so it finishes in time. */
80export const MAX_PURGE_OBJECTS = 300_000;
81/** Storage key that marks a deleted repository whose R2 objects are still being deleted. */
82const PURGE_KEY = "purge";
83/** How often jobs waiting on the account's hosted-job quota are retried. */
84const HELD_RETRY_MS = 60_000;
85
86export type TreeItem = {
87 name: string;
88 path: string;
89 type: "tree" | "blob" | "commit";
90 mode: string;
91 oid: string;
92 size?: number;
93};
94
95export type CommitInfo = {
96 sha: string;
97 tree: string;
98 parents: string[];
99 author: Signature;
100 committer: Signature;
101 message: string;
102 signed: boolean;
103};
104
105/**
106 * One Durable Object per repository. It is the single writer for the repo's
107 * refs, Git objects, issues, pull requests, reviews, checks and workflow runs,
108 * which gives strongly consistent ref updates and merges without locks.
109 */
110/** How long a live-update socket lives before the page must reconnect and pass the access check again. */
111const LIVE_REAUTH_MS = 10 * 60 * 1000;
112/** The close code that asks a page to reconnect at once (not a failure: no backoff, no reload). */
113export const LIVE_REAUTH_CODE = 4001;
114
115/** A row of what someone wrote (RepoDO.authoredBy): column name to value. */
116type AuthoredRow = Record<string, string | number | null>;
117
118export class RepoDO extends DurableObject<Env> {
119 readonly sql: SqlStorage;
120 store!: RepoObjectStore;
121 readonly collab: Collab;
122 readonly actions: Actions;
123 readonly queue: MergeQueue;
124 readonly autoMerge: AutoMerge;
125 readonly runtime: Runtime;
126 readonly policy: TokenPolicy;
127 private metaCache: RepoMeta | null = null;
128
129 constructor(ctx: DurableObjectState, env: Env) {
130 super(ctx, env);
131 this.sql = ctx.storage.sql;
132 this.sql.exec(REPO_SCHEMA);
133 migrateRepoSchema(this.sql);
134 const meta = this.readMeta();
135 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId, () => this.storageChanged());
136 this.collab = new Collab(this);
137 this.actions = new Actions(this, env);
138 this.queue = new MergeQueue(this);
139 this.autoMerge = new AutoMerge(this, env);
140 this.runtime = new Runtime(this, env);
141 this.policy = new TokenPolicy(this);
142 }
143
144 // ---------------------------------------------------------------- meta
145
146 private readMeta(): RepoMeta | null {
147 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0];
148 this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null;
149 return this.metaCache;
150 }
151
152 get meta(): RepoMeta {
153 const m = this.metaCache ?? this.readMeta();
154 if (!m) throw new HttpError(404, "repository not initialised");
155 return m;
156 }
157
158 async init(meta: RepoMeta): Promise<void> {
159 // A repository created now (not one whose meta is updated) starts with read-only workflow tokens.
160 if (!this.readMeta()) this.policy.initNewRepo();
161 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta));
162 this.metaCache = meta;
163 this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId, () => this.storageChanged());
164 }
165
166 async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> {
167 const next = { ...this.meta, ...patch };
168 await this.init(next);
169 return next;
170 }
171
172 /**
173 * Whether the repository is archived (read-only). The Worker refuses writes to an archived
174 * repository, but work this object does by itself (auto-merge, the merge queue) must ask here.
175 */
176 async isArchived(): Promise<boolean> {
177 const known = this.meta.archived;
178 if (typeof known === "boolean") return known;
179 const row = await this.env.DB.prepare("SELECT archived FROM repos WHERE id = ?").bind(this.meta.repoId).first<{ archived: number }>();
180 // setArchived may have run while D1 answered; what it set wins.
181 if (typeof this.meta.archived === "boolean") return this.meta.archived;
182 const archived = !!row?.archived;
183 await this.updateMeta({ archived });
184 return archived;
185 }
186
187 /** The repository was archived or unarchived (D1 already says so). Archiving ends auto-merge and empties the merge queues. */
188 async setArchived(archived: boolean): Promise<void> {
189 await this.updateMeta({ archived });
190 if (!archived) return;
191 await this.autoMerge.repositoryArchived();
192 await this.queue.repositoryArchived();
193 }
194
195 /**
196 * Wipe all storage (repository deletion), then its R2 objects: git objects, LFS, release
197 * assets, caches, artifacts, logs and archives. One call can't delete more than about
198 * 450,000 objects, so the alarm keeps going until they are all gone.
199 */
200 async destroy(): Promise<void> {
201 const repoId = this.readMeta()?.repoId;
202 await this.ctx.storage.deleteAlarm();
203 await this.ctx.storage.deleteAll();
204 this.metaCache = null;
205 if (!repoId) return;
206 await this.ctx.storage.put(PURGE_KEY, repoId);
207 await this.purge(repoId);
208 }
209
210 /** Delete a deleted repository's R2 objects; returns false while some remain (the alarm retries). */
211 private async purge(repoId: string): Promise<void> {
212 const done = await deletePrefixes(this.env.BUCKET, repoPrefixes(repoId)).catch((e) => {
213 console.error("could not delete the repository's objects", repoId, e);
214 return false;
215 });
216 if (done) await this.ctx.storage.deleteAll();
217 else await this.ctx.storage.setAlarm(Date.now() + 5_000);
218 }
219
220 async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> {
221 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
222 return {
223 objects: this.store.count(),
224 bytes: this.store.sizeBytes(),
225 refs: q("SELECT count(*) AS n FROM refs"),
226 openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"),
227 openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"),
228 };
229 }
230
231 // ---------------------------------------------------------------- deleted and exported accounts (accounts/)
232
233 /**
234 * After an account is deleted its work here stays, attributed to the `ghost` user as on GitHub: issues and
235 * pull requests, comments, reviews, releases, statuses, deployments, events, workflow runs and reactions.
236 * Self-hosted runners it registered here (by login) are removed. Returns how many rows changed.
237 */
238 async forgetAccounts(ids: string[], logins: string[]): Promise<number> {
239 if (!ids.length) return 0;
240 const inIds = ids.map(() => "?").join(", ");
241 let changed = 0;
242 const run = (q: string, ...b: unknown[]) => void (changed += this.sql.exec(q, ...b).rowsWritten);
243 const people: Array<[table: string, prefix: string]> = [
244 ["issues", "author"],
245 ["comments", "author"],
246 ["reviews", "author"],
247 ["releases", "author"],
248 ["statuses", "creator"],
249 ["deployments", "creator"],
250 ["deployment_statuses", "creator"],
251 ["issue_events", "actor"],
252 ["checks", "creator"],
253 ["release_assets", "uploader"],
254 ["reactions", "user"],
255 ];
256 for (const [table, p] of people) {
257 // Reactions are one per person: where the ghost already reacted the same way, the deleted account's goes.
258 run(`UPDATE OR IGNORE ${table} SET ${p}_id = ?, ${p}_login = ?, ${p}_kind = ? WHERE ${p}_id IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
259 }
260 run(`DELETE FROM reactions WHERE user_id IN (${inIds})`, ...ids);
261 run(`UPDATE deployment_reviews SET user_id = ?, user_login = ? WHERE user_id IN (${inIds})`, GHOST.id, GHOST.login, ...ids);
262 run(`UPDATE runs SET actor = json_object('id', ?, 'login', ?, 'kind', ?) WHERE json_extract(actor, '$.id') IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
263 if (logins.length) {
264 const lower = logins.map((l) => l.toLowerCase());
265 const inLogins = lower.map(() => "?").join(", ");
266 run(`UPDATE pulls SET merged_by = ? WHERE lower(merged_by) IN (${inLogins})`, GHOST.login, ...lower);
267 run(`DELETE FROM runners WHERE lower(created_by) IN (${inLogins})`, ...lower);
268 run(`DELETE FROM runner_registrations WHERE lower(created_by) IN (${inLogins})`, ...lower);
269 }
270 return changed;
271 }
272
273 /** What these accounts wrote here, for an account export: issues and pull requests they opened, their comments and reviews (at most `limit` of each). */
274 async authoredBy(ids: string[], limit = 5000): Promise<{ issues: AuthoredRow[]; comments: AuthoredRow[]; reviews: AuthoredRow[] }> {
275 if (!ids.length || !this.readMeta()) return { issues: [], comments: [], reviews: [] };
276 const inIds = ids.map(() => "?").join(", ");
277 const rows = (q: string) => this.sql.exec<AuthoredRow>(q, ...ids, limit).toArray();
278 return {
279 issues: rows(`SELECT number, kind, title, body, state, state_reason, created_at, updated_at, closed_at FROM issues WHERE author_id IN (${inIds}) ORDER BY number LIMIT ?`),
280 comments: rows(`SELECT id, number, body, path, line, created_at, updated_at FROM comments WHERE author_id IN (${inIds}) ORDER BY created_at LIMIT ?`),
281 reviews: rows(`SELECT id, number, state, body, commit_id, submitted_at FROM reviews WHERE author_id IN (${inIds}) ORDER BY submitted_at LIMIT ?`),
282 };
283 }
284
285 /**
286 * Write a Git bundle (version 2) of the repository's branches and tags to R2 at `key`, for an account export.
287 * The pack streams out one object at a time, as a clone's does. Null for an empty repository; a bundle past
288 * `limit` bytes is refused with a 413 and nothing is kept.
289 */
290 async writeBundle(key: string, limit: number): Promise<{ size: number; refs: number } | null> {
291 if (!this.readMeta()) return null;
292 const refs = this.refs().filter((r) => r.name.startsWith("refs/heads/") || r.name.startsWith("refs/tags/"));
293 if (!refs.length) return null;
294 const plan = await planFetch(this.store, [...new Set(refs.map((r) => r.oid))], []);
295 const head = this.getRef(this.headRef);
296 const sink = new R2Sink(this.env.BUCKET, key, "application/x-git-bundle", limit, "the repository is too large to bundle");
297 const store = this.store;
298 async function* entries() {
299 for (const oid of plan.oids) {
300 const raw = await store.readRaw(oid);
301 if (!raw) throw new Error(`missing object ${oid}`);
302 yield { type: raw.type, size: raw.size, deflated: raw.deflated };
303 }
304 }
305 try {
306 await sink.write(utf8(`# v2 git bundle\n${head ? `${head} HEAD\n` : ""}${refs.map((r) => `${r.oid} ${r.name}\n`).join("")}\n`));
307 for await (const part of packStream(plan.oids.length, entries())) await sink.write(part);
308 return { size: await sink.close(), refs: refs.length };
309 } catch (e) {
310 await sink.abort();
311 throw e;
312 }
313 }
314
315 /** Keep the object alive for work that finishes after the current request. */
316 background(p: Promise<unknown>): void {
317 this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
318 }
319
320 /**
321 * Set (or with null, clear) the object's single alarm. The merge queue uses it for check timeouts;
322 * while jobs wait on the account's hosted-job quota it also fires every minute to retry them.
323 */
324 armAlarm(at: number | null): void {
325 const held = this.actions.hasAccountHeldJobs() ? Date.now() + HELD_RETRY_MS : null;
326 const next = at === null ? held : held === null ? at : Math.min(at, held);
327 this.ctx.waitUntil(next === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(next));
328 }
329
330 override async alarm(): Promise<void> {
331 const purging = await this.ctx.storage.get<string>(PURGE_KEY);
332 if (purging) return this.purge(purging);
333 await this.queue.process();
334 if (this.actions.hasAccountHeldJobs()) {
335 await this.actions.releaseAccountHeld();
336 // Keep the queue's own alarm if it set an earlier one.
337 const current = await this.ctx.storage.getAlarm();
338 if (this.actions.hasAccountHeldJobs() && (current === null || current <= Date.now() || current > Date.now() + HELD_RETRY_MS)) await this.ctx.storage.setAlarm(Date.now() + HELD_RETRY_MS);
339 }
340 }
341
342 /** 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). */
343 emit(event: DiffineryEvent, opts?: { live?: boolean }): void {
344 this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e)));
345 // Open pages only learn that something changed and fetch it with the viewer's own access: the event
346 // itself (an audit entry's detail, a dispatch's payload) is not every reader's to see.
347 if (opts?.live !== false && event.type !== "audit") this.broadcast("repo", { kind: "event", type: event.type });
348 }
349
350 // ---------------------------------------------------------------- purging unreachable objects
351
352 /** Bumped by every purge; a push that started before one refuses its ref updates. */
353 private purgeEpoch = 0;
354
355 /**
356 * Delete every object no ref reaches, so a secret force-pushed away can't be fetched by its SHA any
357 * more (git keeps them until a gc, and so did Diffinery, forever). Also kept: the commits of open
358 * pull requests, of the merge queue and of unfinished workflow runs. Runs with every other request
359 * held, so nothing writes a ref meanwhile.
360 */
361 async purgeUnreachable(caller: Caller): Promise<{ objects: number; bytes: number }> {
362 if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can purge unreachable objects");
363 const total = this.store.count();
364 if (total > MAX_PURGE_OBJECTS) throw new HttpError(422, `this repository has ${total} objects; purging works for up to ${MAX_PURGE_OBJECTS}. Ask a site administrator`);
365 return this.ctx.blockConcurrencyWhile(async () => {
366 const roots = [
367 ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
368 ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
369 ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
370 ];
371 const keep = new Set<string>();
372 const stack = [...roots];
373 while (stack.length) {
374 const oid = stack.pop()!;
375 if (keep.has(oid)) continue;
376 const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
377 if (!row) continue;
378 keep.add(oid);
379 const type = CODE_TYPES[row.type];
380 if (type === "blob") continue;
381 const obj = await this.store.read(oid);
382 if (!obj) continue;
383 if (type === "commit") {
384 const c = parseCommit(obj.content);
385 stack.push(c.tree, ...c.parents);
386 } else if (type === "tree") {
387 // A gitlink (160000) names a commit in another repository.
388 for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
389 } else if (type === "tag") {
390 const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
391 if (target) stack.push(target);
392 }
393 }
394 const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
395 for (let i = 0; i < doomed.length; i += 100) {
396 const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
397 this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
398 }
399 this.store.forget();
400 this.store.recount();
401 this.storageChanged();
402 this.purgeEpoch++;
403 const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
404 for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
405 // Cached zipballs and tarballs are rebuilt from what's left on the next download.
406 await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
407 return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
408 });
409 }
410
411 // ---------------------------------------------------------------- storage (the owner's storage quota, lib/quotas.ts)
412
413 /** The report to D1 waiting to run, and the last one started: reports run one at a time. */
414 private storageNext: Promise<void> | null = null;
415 private storageLast: Promise<void> = Promise.resolve();
416
417 /** Bytes the repository stores for its owner: git objects, Git LFS objects and release assets. */
418 async storageBytes(): Promise<number> {
419 await this.listLfs();
420 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
421 return this.store.sizeBytes() + q("SELECT coalesce(sum(size), 0) AS n FROM lfs_objects") + q("SELECT coalesce(sum(size), 0) AS n FROM release_assets");
422 }
423
424 /**
425 * Tell D1 what the repository stores now (repo_storage), where the owner's quota adds it up. Calls
426 * made while a report waits to run share it; one made while a report is under way gets the next.
427 */
428 reportStorage(): Promise<void> {
429 this.storageNext ??= this.storageLast.then(async () => {
430 this.storageNext = null;
431 const bytes = await this.storageBytes();
432 await this.env.DB.prepare("INSERT INTO repo_storage (repo_id, bytes, updated_at) VALUES (?, ?, ?) ON CONFLICT(repo_id) DO UPDATE SET bytes = excluded.bytes, updated_at = excluded.updated_at")
433 .bind(this.meta.repoId, bytes, now())
434 .run();
435 });
436 const next = this.storageNext;
437 this.storageLast = next.catch(() => {});
438 return next;
439 }
440
441 /** What the repository stores changed: report it in the background. */
442 storageChanged(): void {
443 this.background(this.reportStorage());
444 }
445
446 /** Why storing `adding` more bytes here must be refused (past the owner's storage quota), or null. */
447 async storageRefusal(adding = 0): Promise<string | null> {
448 const m = this.meta;
449 return storageRefusal(this.env, m.ownerId, m.owner, adding, { repoId: m.repoId, bytes: await this.storageBytes() });
450 }
451
452 /** Refuse storing `adding` more bytes past the owner's storage quota. */
453 async checkStorage(adding = 0): Promise<void> {
454 const refused = await this.storageRefusal(adding);
455 if (refused) throw new HttpError(403, refused);
456 }
457
458 /** A Git LFS object was stored in R2 (http/git.ts). */
459 recordLfs(oid: string, size: number): void {
460 if (this.sql.exec("INSERT OR IGNORE INTO lfs_objects (oid, size) VALUES (?, ?) RETURNING oid", oid, size).toArray().length) this.storageChanged();
461 }
462
463 /** Git LFS objects stored before lfs_objects existed: listed from R2, once. */
464 private async listLfs(): Promise<void> {
465 if (this.sql.exec("SELECT 1 FROM counters WHERE name = 'lfs_listed'").toArray().length) return;
466 let cursor: string | undefined;
467 do {
468 const page = await this.env.BUCKET.list({ prefix: `repos/${this.meta.repoId}/lfs/`, cursor, limit: 1000 });
469 for (const o of page.objects) this.sql.exec("INSERT OR IGNORE INTO lfs_objects (oid, size) VALUES (?, ?)", o.key.slice(o.key.lastIndexOf("/") + 1), o.size);
470 cursor = page.truncated ? page.cursor : undefined;
471 } while (cursor);
472 this.sql.exec("INSERT OR IGNORE INTO counters (name, value) VALUES ('lfs_listed', 1)");
473 }
474
475 // ---------------------------------------------------------------- refs
476
477 refs(prefix = "refs/"): RefEntry[] {
478 return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}￿`).toArray();
479 }
480
481 getRef(name: string): string | null {
482 return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null;
483 }
484
485 /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */
486 casRef(name: string, expected: string | null, next: string | null): boolean {
487 const current = this.getRef(name);
488 if (expected !== null && (current ?? ZERO_OID) !== expected) return false;
489 if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name);
490 else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next);
491 return true;
492 }
493
494 get headRef(): string {
495 return `refs/heads/${this.meta.defaultBranch}`;
496 }
497
498 /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */
499 async resolve(rev: string | undefined | null): Promise<string | null> {
500 const r = rev && rev.length ? rev : "HEAD";
501 let oid: string | null = null;
502 if (r === "HEAD") oid = this.getRef(this.headRef);
503 else if (isOid(r)) oid = this.store.has(r) ? r : null;
504 else if (r.startsWith("refs/")) oid = this.getRef(r);
505 else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`);
506 if (!oid && /^[0-9a-f]{7,39}$/.test(r)) {
507 const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray();
508 if (rows.length === 1) oid = rows[0]!.oid;
509 }
510 if (!oid) return null;
511 return peelToCommit(this.store, oid);
512 }
513
514 async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> {
515 return this.refs("refs/heads/").map((r) => {
516 const name = r.name.slice(11);
517 return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch };
518 });
519 }
520
521 async tags(): Promise<Array<{ name: string; sha: string; target: string }>> {
522 const out = [];
523 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) });
524 return out;
525 }
526
527 // ---------------------------------------------------------------- git smart HTTP
528
529 async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> {
530 if (service === "git-upload-pack") return advertiseUploadPackV2();
531 // Like GitHub, pull request refs are hidden from pushes (so `git push --mirror` leaves them alone).
532 return advertiseReceivePack(this.refs().filter((r) => !r.name.startsWith("refs/pull/")));
533 }
534
535 /** The response streams: the pack is read and sent one object at a time, as the client takes it. */
536 async gitUploadPack(body: Uint8Array): Promise<ReadableStream<Uint8Array>> {
537 const { readable, writable } = new IdentityTransformStream();
538 const writer = writable.getWriter();
539 let started = false;
540 const sink = (b: Uint8Array) => ((started = true), writer.write(b));
541 const ctx = { store: this.store, refs: this.refs(), headTarget: this.headRef };
542 void handleUploadPackV2(body, ctx, sink)
543 .catch((e) => (started ? undefined : writer.write(errorPacket((e as Error).message))))
544 .then(
545 () => writer.close(),
546 () => writer.abort(),
547 )
548 .catch(() => {}); // the client went away
549 return readable;
550 }
551
552 /** `moved`: the push came in under an old name (a transfer or rename), so say where the repository lives now, like GitHub. */
553 async gitReceivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean } = {}): Promise<Uint8Array> {
554 if (input instanceof Uint8Array) return this.receivePack(input, caller, o);
555 try {
556 return await this.receivePack(input, caller, o);
557 } finally {
558 await this.env.BUCKET.delete(input.spilled).catch(() => {});
559 }
560 }
561
562 private async receivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean }): Promise<Uint8Array> {
563 const size = input instanceof Uint8Array ? input.length : input.size;
564 if (size > MAX_PUSH_BYTES) {
565 const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null };
566 return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []);
567 }
568 // A push the Worker staged in R2 (over 16 MB) is read in ranges, never whole: its objects are
569 // unpacked one at a time (git/push-contents.ts), so its size doesn't decide the memory it takes.
570 let req: ReceiveRequest;
571 let packAt = 0;
572 if (input instanceof Uint8Array) req = parseReceivePack(input);
573 else {
574 try {
575 const head = await readStagedHead(this.env.BUCKET, input.spilled, input.size);
576 req = head.req;
577 packAt = head.packOffset;
578 } catch (e) {
579 return reportStatus({ commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }, unpackFailure(e), []);
580 }
581 }
582 // A purge of unreachable objects that ran while this push was in flight may have deleted objects
583 // the push counted on the repository having: refuse rather than point a ref at them.
584 const epoch = this.purgeEpoch;
585 // `git push -o diffinery-import` from a GitHub import's own token: no workflows, webhooks,
586 // pull request syncs or notifications (see apps/diffinery/src/imports).
587 const importMode = !!caller.importId && req.pushOptions.some((o) => o.trim() === "diffinery-import");
588 // H2: while an import holds the repository, only that import may push. Everything else waits
589 // until the import finalises, so protection, guardrails, CI and webhooks are never bypassed.
590 const lockHolder = this.importLockHolder();
591 if (lockHolder && (!importMode || caller.importId !== lockHolder)) {
592 const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" }));
593 return reportStatus(req, "repository is locked by an import in progress", denied);
594 }
595 const messages: string[] = o.moved ? ["This repository moved. Please use the new location:", ` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}.git`] : [];
596 let unpackError: string | null = null;
597 // Like git's quarantine: nothing from the pack is stored until push protection passes,
598 // so a blocked push leaves no objects behind for a retry to build on.
599 let contents: PushContents = packedContents(this.store, [], 0);
600 try {
601 if (!(input instanceof Uint8Array)) {
602 if (packAt < input.size) contents = await stagedContents(this.store, this.env.BUCKET, input.spilled, packAt, input.size - packAt, { largeObjectBytes: LARGE_OBJECT_BYTES });
603 } else if (req.pack && req.pack.length > 0) {
604 const pack = req.pack;
605 contents = await parsePack(pack, repositoryBases(this.store), { largeObjectBytes: LARGE_OBJECT_BYTES }).then(
606 (objects) => packedContents(this.store, objects, pack.length),
607 // Parsed whole, a pack holds every object at once: one that can't, such as a long history
608 // of a large file, is unpacked in pieces instead, holding only the objects in use.
609 (e: unknown) => {
610 if (!(e instanceof TooMuchMemory)) throw e;
611 return streamedContents(this.store, memorySource(pack), { largeObjectBytes: LARGE_OBJECT_BYTES });
612 },
613 );
614 }
615 } catch (e) {
616 unpackError = unpackFailure(e);
617 }
618 const results: RefResult[] = [];
619 if (unpackError) {
620 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
621 return reportStatus(req, unpackError, results);
622 }
623 // Like GitHub's receive.fsckObjects: no tree a git client would refuse, or that writes outside the work tree.
624 const bad = contents.fsck();
625 if (bad) {
626 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "fsck failed" });
627 return reportStatus(req, `fsck error: ${bad}`, results, [`Push refused: ${bad}`]);
628 }
629
630 const marked: SecretHit[] = [];
631 // Commit and tag messages are scanned too, not only files.
632 const leaks = contents.scan(marked);
633 // A file can opt out with `diffinery:allow-secret`; that it did is on the record.
634 if (marked.length) this.emit({ type: "audit", action: "push.secret_marked", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: [...new Set(marked.map((l) => l.kind))], blobs: marked.slice(0, 50).map((l) => l.oid), refs: req.commands.map((c) => c.ref) }, at: now() });
635 if (leaks.length) {
636 const kinds = leaks.map((l) => l.kind);
637 const target = `${this.meta.owner}/${this.meta.name}`;
638 // Like GitHub's push protection bypass: a human repository admin may push anyway by
639 // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited.
640 const reason = secretBypassReason(req.pushOptions);
641 if (reason && caller.role === "admin" && caller.kind === "user") {
642 messages.push(`Push protection bypassed by ${caller.login}: ${reason}`);
643 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
644 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() });
645 if (importMode) this.importsModule.recordFindings(leaks);
646 } else {
647 messages.push("Push blocked: possible secrets detected.");
648 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
649 messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
650 if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection.");
651 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
652 this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds, refs: req.commands.map((c) => c.ref) }, at: now() });
653 return reportStatus(req, null, results, messages);
654 }
655 }
656
657 // Past the owner's storage quota nothing new is stored; a push that only deletes or moves refs still works.
658 const full = contents.anyNew() ? await this.storageRefusal(contents.bytes) : null;
659 if (full) {
660 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "storage limit reached" });
661 return reportStatus(req, null, results, [...messages, `Push refused: ${full}`]);
662 }
663
664 try {
665 await contents.write();
666 } catch (e) {
667 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
668 return reportStatus(req, unpackFailure(e), results);
669 }
670
671 // Validate every command before applying any, so `atomic` is honoured.
672 const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = [];
673 for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller, importMode)) });
674 const atomic = req.capabilities.has("atomic");
675 const anyFailed = checks.some((c) => !c.ok);
676 let imported = 0;
677 for (const c of checks) {
678 if (!c.ok) {
679 results.push({ ref: c.ref, ok: false, reason: c.reason });
680 continue;
681 }
682 if (atomic && anyFailed) {
683 results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" });
684 continue;
685 }
686 if (epoch !== this.purgeEpoch) {
687 results.push({ ref: c.ref, ok: false, reason: "the repository's unreachable objects were purged during this push; push again" });
688 continue;
689 }
690 const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid);
691 results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" });
692 if (applied && importMode) {
693 imported++;
694 // Record provenance so a later push may move this ref only if the import still owns it.
695 if (c.cmd.newOid === ZERO_OID) this.sql.exec("DELETE FROM import_refs WHERE ref = ?", c.cmd.ref);
696 else this.importsModule.recordImportRef(c.cmd.ref, c.cmd.newOid, caller.importId!);
697 } else if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages);
698 }
699 // One audit entry per import push instead of push events.
700 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() });
701 return reportStatus(req, null, results, messages);
702 }
703
704 /** As on GitHub: a rule naming the branch exactly wins; otherwise the oldest matching pattern. */
705 protectionFor(branch: string): ProtectionRules | null {
706 const rows = this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY rowid").toArray();
707 const row = rows.find((r) => r.pattern === branch) ?? rows.find((r) => globToRegExp(r.pattern).test(branch));
708 return row ? (JSON.parse(row.rules) as ProtectionRules) : null;
709 }
710
711 /**
712 * Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails.
713 * `importMode`: a GitHub import filling the repository it created, which is the only writer
714 * until it finishes, so branch rules (copied from GitHub at its end) don't apply to it.
715 */
716 async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, importMode = false): Promise<RefResult> {
717 if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" };
718 if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" };
719 const deleting = newOid === ZERO_OID;
720 if (ref.startsWith("refs/pull/")) return { ref, ok: false, reason: "refs/pull/ is managed by Diffinery for pull requests" };
721 if (ref.startsWith(`refs/heads/${QUEUE_BRANCH_PREFIX}`)) return { ref, ok: false, reason: "merge queue branches are read-only" };
722 if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" };
723 if (ref.startsWith(IMPORT_REF_PREFIX) && !importMode) return { ref, ok: false, reason: `${IMPORT_REF_PREFIX} is reserved for GitHub imports` };
724 if (importMode) {
725 if (!/^refs\/(heads|tags|diffinery-import)\//.test(ref)) return { ref, ok: false, reason: "imports write branches and tags only" };
726 // H2: create a ref, or move one this import previously wrote — never a ref adopted since.
727 const current = this.getRef(ref);
728 if (current && !deleting) {
729 const owned = this.importsModule.importRefOid(ref, caller.importId!);
730 if (owned !== current) return { ref, ok: false, reason: "import may not overwrite a ref it did not write" };
731 }
732 return { ref, ok: true };
733 }
734 // A workflow's GITHUB_TOKEN never adds or changes workflow files (GitHub's `workflows` permission).
735 const workflowEdit = await this.policy.refuseWorkflowEdit(caller, oldOid, newOid);
736 if (workflowEdit) return { ref, ok: false, reason: workflowEdit };
737 if (!ref.startsWith("refs/heads/")) {
738 if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" };
739 return { ref, ok: true };
740 }
741 const branch = ref.slice(11);
742 const rules = this.protectionFor(branch);
743 const isDefault = branch === this.meta.defaultBranch;
744 if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) {
745 return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" };
746 }
747 if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" };
748 if (!rules) return { ref, ok: true };
749 const bypass = caller.role === "admin" && !rules.enforceAdmins;
750 if (bypass) return { ref, ok: true };
751 if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" };
752 if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" };
753 // As on GitHub, required checks gate direct pushes too: the commit must already have passed them.
754 for (const name of rules.requiredChecks ?? []) {
755 const c = requiredCheck(name, [...this.collab.checks(newOid), ...this.statuses.asChecks(newOid)], rules.requiredCheckSources?.[name]);
756 if (c.state !== "success") {
757 const why = c.state === "missing" ? "has not run" : c.state === "pending" ? "has not finished" : `ended with ${c.conclusion}`;
758 return { ref, ok: false, reason: `protected branch: required check '${name}' ${why} on ${newOid.slice(0, 7)}; push to another branch and open a pull request` };
759 }
760 }
761 if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) {
762 return { ref, ok: false, reason: "protected branch: force push not allowed" };
763 }
764 if (rules.requireLinearHistory) {
765 const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 });
766 if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" };
767 }
768 return { ref, ok: true };
769 }
770
771 /** Side effects after a ref moved: events, PR sync, workflow triggers. */
772 async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> {
773 const at = now();
774 const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid));
775 const ev: DiffineryEvent = {
776 type: "push",
777 repoId: this.meta.repoId,
778 owner: this.meta.owner,
779 repo: this.meta.name,
780 ref,
781 before: oldOid,
782 after: newOid,
783 actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf },
784 forced,
785 created: oldOid === ZERO_OID,
786 deleted: newOid === ZERO_OID,
787 at,
788 };
789 this.emit(ev);
790 this.policy.recordPush(ref, newOid, caller);
791 this.deploys.noteRefUpdate(ref, newOid, caller);
792 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) {
793 const branch = ref.slice(11);
794 const synced = await this.collab.syncPullsForBranch(branch, newOid, caller);
795 for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`);
796 if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) {
797 messages.push(`Create a pull request for '${branch}':`);
798 messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`);
799 }
800 }
801 if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
802 // A base branch that moved (outside the queue, or by it) needs its merge groups checked,
803 // and pull requests into it waiting to auto-merge may now be up to date.
804 if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11));
805 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) this.autoMerge.schedule({ base: ref.slice(11) });
806 }
807
808 // ---------------------------------------------------------------- browsing
809
810 async commitInfo(sha: string): Promise<CommitInfo> {
811 const c = await readCommit(this.store, sha);
812 return toInfo(sha, c);
813 }
814
815 async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> {
816 const sha = await this.resolve(rev);
817 if (!sha) return null;
818 const c = await readCommit(this.store, sha);
819 const entry = await resolvePath(this.store, c.tree, path);
820 if (!entry || !isTreeMode(entry.mode)) return null;
821 const items: TreeItem[] = [];
822 for (const e of await readTree(this.store, entry.oid)) {
823 const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob";
824 const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined;
825 items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size });
826 }
827 items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1));
828 return { sha, items };
829 }
830
831 async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> {
832 const sha = await this.resolve(rev);
833 if (!sha) return null;
834 const c = await readCommit(this.store, sha);
835 const entry = await resolvePath(this.store, c.tree, path);
836 if (!entry || isTreeMode(entry.mode)) return null;
837 const o = await this.store.read(entry.oid);
838 if (!o) return null;
839 return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content };
840 }
841
842 async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> {
843 const sha = await this.resolve(rev);
844 if (!sha) return [];
845 const limit = Math.min(opts.limit ?? 30, 100);
846 const skip = opts.skip ?? 0;
847 if (!opts.path) {
848 const commits = await walkCommits(this.store, [sha], { limit: limit + skip });
849 return commits.slice(skip).map((c) => toInfo(c.oid, c.commit));
850 }
851 // Path history: commits whose tree entry at `path` differs from their first parent's.
852 const out: CommitInfo[] = [];
853 let skipped = 0;
854 for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) {
855 const mine = await resolvePath(this.store, c.commit.tree, opts.path);
856 const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null;
857 const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null;
858 if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue;
859 if (skipped++ < skip) continue;
860 out.push(toInfo(c.oid, c.commit));
861 if (out.length >= limit) break;
862 }
863 return out;
864 }
865
866 /** Diff between two commits (or a commit and its first parent). */
867 async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) {
868 const head = await readCommit(this.store, headSha);
869 const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null;
870 const changes = await diffTrees(this.store, baseTree, head.tree);
871 const max = opts.maxFiles ?? 300;
872 const files = [];
873 const budget = new DiffBudget();
874 for (const ch of changes.slice(0, max)) {
875 const d = await fileDiff(this.store, ch, 3, budget);
876 files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) });
877 }
878 return {
879 files,
880 totalFiles: changes.length,
881 additions: files.reduce((s, f) => s + f.additions, 0),
882 deletions: files.reduce((s, f) => s + f.deletions, 0),
883 truncated: changes.length > max,
884 };
885 }
886
887 async commitWithDiff(rev: string) {
888 const sha = await this.resolve(rev);
889 if (!sha) throw new HttpError(404, "commit not found");
890 const info = await this.commitInfo(sha);
891 return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) };
892 }
893
894 async compare(base: string, head: string) {
895 const b = await this.resolve(base);
896 const h = await this.resolve(head);
897 if (!b || !h) throw new HttpError(404, "unknown base or head");
898 const mb = await mergeBase(this.store, b, h);
899 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));
900 const ahead = commits.length;
901 const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0;
902 return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) };
903 }
904
905 /**
906 * Push protection for commits made without a Git client: the same scan as `git push`, so the
907 * web editor, the contents and commits APIs and MCP cannot be used to slip a credential in.
908 */
909 private protectCommit(changes: readonly FileChange[], allowSecrets: string | undefined, caller: Caller, message = ""): void {
910 const blobs = changes.filter((ch) => !ch.delete && ch.content).map((ch) => ({ oid: ch.path, content: ch.content! }));
911 if (message) blobs.push({ oid: "(commit message)", content: utf8(message) });
912 const marked: SecretHit[] = [];
913 const leaks = scanForSecrets(blobs, marked);
914 const target = `${this.meta.owner}/${this.meta.name}`;
915 if (marked.length) this.emit({ type: "audit", action: "api.secret_marked", actor: caller, target, detail: { kinds: [...new Set(marked.map((l) => l.kind))], paths: [...new Set(marked.map((l) => l.oid))].slice(0, 10) }, at: now() });
916 if (!leaks.length) return;
917 const kinds = [...new Set(leaks.map((l) => l.kind))];
918 const paths = [...new Set(leaks.map((l) => l.oid))].slice(0, 10);
919 const reason = allowSecrets?.trim().slice(0, 200);
920 if (reason && caller.role === "admin" && caller.kind === "user") {
921 this.emit({ type: "audit", action: "api.secret_bypassed", actor: caller, target, detail: { kinds, paths, reason }, at: now() });
922 return;
923 }
924 this.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target, detail: { kinds, paths }, at: now() });
925 const hint = reason ? " Only repository admins (people, not agents) can bypass push protection." : "";
926 throw new HttpError(422, `push protection: possible secret detected (${kinds.join(", ")}) in ${paths.join(", ")}. Remove it, or rotate it and ask an admin.${hint}`);
927 }
928
929 /**
930 * Create a commit from file changes without a Git client (used by the web
931 * editor, the REST contents API and AI agents through MCP).
932 */
933 async commitFiles(input: {
934 branch: string;
935 message: string;
936 changes: FileChange[];
937 expectedHeadSha?: string;
938 createBranchFrom?: string;
939 author?: { name: string; email: string };
940 committer?: { name: string; email: string };
941 /** A human admin's reason for committing a detected secret anyway (like `git push -o allow-secrets=`). */
942 allowSecrets?: string;
943 }, caller: Caller): Promise<{ sha: string; branch: string }> {
944 const ref = `refs/heads/${input.branch}`;
945 let parent = this.getRef(ref);
946 if (!parent) {
947 if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom);
948 else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`);
949 }
950 if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent });
951 for (const ch of input.changes) {
952 const why = ch.delete ? null : unsafeTreePath(ch.path);
953 if (why) throw new HttpError(422, `invalid path ${JSON.stringify(ch.path)}: ${why}`);
954 }
955 const adding = input.changes.reduce((n, ch) => n + (ch.content?.length ?? 0), 0);
956 if (adding) await this.checkStorage(adding);
957 this.protectCommit(input.changes, input.allowSecrets, caller, input.message);
958 const baseTree = parent ? (await readCommit(this.store, parent)).tree : null;
959 const tree = await applyChanges(this.store, baseTree, input.changes);
960 const ts = Math.floor(Date.now() / 1000);
961 const committer = input.committer
962 ? { ...input.committer, timestamp: ts, timezone: "+0000" }
963 : { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" };
964 const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer;
965 const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
966 const sha = await this.store.write(
967 "commit",
968 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 }),
969 );
970 const old = parent && this.getRef(ref) ? parent : ZERO_OID;
971 const check = await this.checkRefUpdate(ref, old, sha, caller);
972 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
973 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry");
974 await this.afterRefUpdate(ref, old, sha, caller);
975 return { sha, branch: input.branch };
976 }
977
978 async createRef(ref: string, sha: string, caller: Caller): Promise<void> {
979 if (!this.store.has(sha)) throw new HttpError(422, "object does not exist");
980 const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller);
981 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
982 if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists");
983 await this.afterRefUpdate(ref, ZERO_OID, sha, caller);
984 }
985
986 async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> {
987 const old = this.getRef(ref);
988 if (!old) throw new HttpError(404, "reference not found");
989 if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward");
990 const check = await this.checkRefUpdate(ref, old, sha, caller);
991 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
992 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved");
993 await this.afterRefUpdate(ref, old, sha, caller);
994 }
995
996 async deleteRef(ref: string, caller: Caller): Promise<void> {
997 const old = this.getRef(ref);
998 if (!old) throw new HttpError(404, "reference not found");
999 const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller);
1000 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
1001 this.casRef(ref, old, null);
1002 await this.afterRefUpdate(ref, old, ZERO_OID, caller);
1003 }
1004
1005 // ---------------------------------------------------------------- protections
1006
1007 listProtections(): Array<{ pattern: string; rules: ProtectionRules }> {
1008 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) }));
1009 }
1010
1011 /**
1012 * Create, replace or (with null) delete a rule. Only people who administer the repository change
1013 * branch protection: never agents, never a workflow's token. Turning `allowAgentAutoMerge` or
1014 * `agentAutoMergeWithoutApproval` on or off is audited on its own, since they let agents land changes.
1015 */
1016 setProtection(pattern: string, rules: ProtectionRules | null, caller: Caller): void {
1017 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");
1018 if (rules?.mergeQueue) {
1019 const asked = typeof rules.mergeQueue === "object" ? rules.mergeQueue.method : undefined;
1020 const mergeQueue = normalizeMergeQueue(rules.mergeQueue);
1021 // A queue that makes merge commits could never merge into a branch that refuses them.
1022 if (rules.requireLinearHistory && mergeQueue.method === "merge") {
1023 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");
1024 mergeQueue.method = "squash";
1025 }
1026 rules = { ...rules, mergeQueue };
1027 }
1028 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
1029 // A required "Agent review" means Diffinery's own check (agentic/review.ts), not one anyone with write access could post.
1030 if (rules?.requiredChecks?.includes(REVIEW_CHECK) && !rules.requiredCheckSources?.[REVIEW_CHECK]) rules = { ...rules, requiredCheckSources: { ...rules.requiredCheckSources, [REVIEW_CHECK]: REVIEW_APP } };
1031 if (rules && rules.allowAgentAutoMerge !== undefined && typeof rules.allowAgentAutoMerge !== "boolean") throw new HttpError(422, "allowAgentAutoMerge must be a boolean");
1032 if (rules && rules.agentAutoMergeWithoutApproval !== undefined && typeof rules.agentAutoMergeWithoutApproval !== "boolean") throw new HttpError(422, "agentAutoMergeWithoutApproval must be a boolean");
1033 if (rules?.agentAutoMergeWithoutApproval && !rules.allowAgentAutoMerge) throw new HttpError(422, "agentAutoMergeWithoutApproval needs allowAgentAutoMerge: it only applies to what Diffinery lands for agents");
1034 if (rules && !rules.allowAgentAutoMerge) {
1035 const { allowAgentAutoMerge: _, agentAutoMergeWithoutApproval: _w, ...rest } = rules;
1036 rules = rest;
1037 }
1038 if (rules && !rules.agentAutoMergeWithoutApproval) {
1039 const { agentAutoMergeWithoutApproval: _, ...rest } = rules;
1040 rules = rest;
1041 }
1042 const before = this.sql.exec<{ rules: string }>("SELECT rules FROM protections WHERE pattern = ?", pattern).toArray()[0];
1043 const old = before ? (JSON.parse(before.rules) as ProtectionRules) : null;
1044 const was = !!old?.allowAgentAutoMerge;
1045 const waived = !!old?.agentAutoMergeWithoutApproval;
1046 if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules));
1047 else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
1048 if (was !== !!rules?.allowAgentAutoMerge) {
1049 const m = this.meta;
1050 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() });
1051 }
1052 if (waived !== !!rules?.agentAutoMergeWithoutApproval) {
1053 const m = this.meta;
1054 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() });
1055 }
1056 // Turning a queue off (or changing its checks) affects what is queued, and what may auto-merge.
1057 this.queue.schedule();
1058 this.autoMerge.schedule();
1059 }
1060
1061 // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
1062 // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here.
1063
1064 issues(...a: Parameters<Collab["list"]>) {
1065 return this.collab.list(...a);
1066 }
1067 issue(...a: Parameters<Collab["get"]>) {
1068 return this.collab.get(...a);
1069 }
1070 createIssue(...a: Parameters<Collab["createIssue"]>) {
1071 return this.collab.createIssue(...a);
1072 }
1073 updateIssue(...a: Parameters<Collab["update"]>) {
1074 return this.collab.update(...a);
1075 }
1076 setPullDraft(...a: Parameters<Collab["setDraft"]>) {
1077 return this.collab.setDraft(...a);
1078 }
1079 addComment(...a: Parameters<Collab["addComment"]>) {
1080 return this.collab.addComment(...a);
1081 }
1082 editComment(...a: Parameters<Collab["editComment"]>) {
1083 return this.collab.editComment(...a);
1084 }
1085 minimizeComment(...a: Parameters<Collab["minimizeComment"]>) {
1086 return this.collab.minimizeComment(...a);
1087 }
1088 createPull(...a: Parameters<Collab["createPull"]>) {
1089 return this.collab.createPull(...a);
1090 }
1091 pullFiles(...a: Parameters<Collab["pullFiles"]>) {
1092 return this.collab.pullFiles(...a);
1093 }
1094 pullCommits(...a: Parameters<Collab["pullCommits"]>) {
1095 return this.collab.pullCommits(...a);
1096 }
1097 submitReview(...a: Parameters<Collab["submitReview"]>) {
1098 return this.collab.submitReview(...a);
1099 }
1100 resolveThread(...a: Parameters<Collab["resolveThread"]>) {
1101 return this.collab.resolveThread(...a);
1102 }
1103 mergeability(...a: Parameters<Collab["mergeability"]>) {
1104 return this.collab.mergeability(...a);
1105 }
1106 mergePull(...a: Parameters<Collab["merge"]>) {
1107 return this.collab.merge(...a);
1108 }
1109 requestReviewers(...a: Parameters<Collab["requestReviewers"]>) {
1110 return this.collab.requestReviewers(...a);
1111 }
1112 labels(...a: Parameters<Collab["labels"]>) {
1113 return this.collab.labels(...a);
1114 }
1115 setLabel(...a: Parameters<Collab["setLabel"]>) {
1116 return this.collab.setLabel(...a);
1117 }
1118 checks(...a: Parameters<Collab["checks"]>) {
1119 return this.collab.checks(...a);
1120 }
1121 upsertCheck(...a: Parameters<Collab["upsertCheck"]>) {
1122 return this.collab.upsertCheck(...a);
1123 }
1124 webhooks(...a: Parameters<Collab["webhooks"]>) {
1125 return this.collab.webhooks(...a);
1126 }
1127 createWebhook(url: string, events: string[], secret?: string) {
1128 return this.collab.createWebhook(url, events, secret, new URL(this.env.PUBLIC_URL).hostname);
1129 }
1130 deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) {
1131 return this.collab.deleteWebhook(...a);
1132 }
1133 recordDelivery(...a: Parameters<Collab["recordDelivery"]>) {
1134 return this.collab.recordDelivery(...a);
1135 }
1136
1137 enqueuePull(...a: Parameters<MergeQueue["enqueue"]>) {
1138 return this.queue.enqueue(...a);
1139 }
1140 dequeuePull(...a: Parameters<MergeQueue["dequeue"]>) {
1141 return this.queue.dequeue(...a);
1142 }
1143 mergeQueue(...a: Parameters<MergeQueue["list"]>) {
1144 return this.queue.list(...a);
1145 }
1146 mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) {
1147 return this.queue.settings(...a);
1148 }
1149 enableAutoMerge(...a: Parameters<AutoMerge["enable"]>) {
1150 return this.autoMerge.enable(...a);
1151 }
1152 disableAutoMerge(...a: Parameters<AutoMerge["disable"]>) {
1153 return this.autoMerge.disable(...a);
1154 }
1155
1156 runs(...a: Parameters<Actions["listRuns"]>) {
1157 return this.actions.listRuns(...a);
1158 }
1159 run(...a: Parameters<Actions["getRun"]>) {
1160 return this.actions.getRun(...a);
1161 }
1162 runJobs(...a: Parameters<Actions["jobs"]>) {
1163 return this.actions.jobs(...a);
1164 }
1165 claimJobAsRunner(...a: Parameters<Actions["claimJobAsRunner"]>) {
1166 return this.actions.claimJobAsRunner(...a);
1167 }
1168 runnerRegistrationToken(...a: Parameters<Actions["runnerRegistrationToken"]>) {
1169 return this.actions.runnerRegistrationToken(...a);
1170 }
1171 registerRunner(...a: Parameters<Actions["registerRunner"]>) {
1172 return this.actions.registerRunner(...a);
1173 }
1174 runners(...a: Parameters<Actions["runners"]>) {
1175 return this.actions.runners(...a);
1176 }
1177 deleteRunner(...a: Parameters<Actions["deleteRunner"]>) {
1178 return this.actions.deleteRunner(...a);
1179 }
1180 jobStatus(jobId: string): string {
1181 return this.actions.getJob(jobId).status;
1182 }
1183 job(...a: Parameters<Actions["getJob"]>) {
1184 return this.actions.getJob(...a);
1185 }
1186 jobLogs(...a: Parameters<Actions["logs"]>) {
1187 return this.actions.logs(...a);
1188 }
1189 cancelRun(...a: Parameters<Actions["cancel"]>) {
1190 return this.actions.cancel(...a);
1191 }
1192 rerun(...a: Parameters<Actions["rerun"]>) {
1193 return this.actions.rerun(...a);
1194 }
1195 rerunJob(...a: Parameters<Actions["rerunJob"]>) {
1196 return this.actions.rerunJob(...a);
1197 }
1198 dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) {
1199 return this.actions.dispatch(...a);
1200 }
1201 scheduleTick(...a: Parameters<Actions["schedule"]>) {
1202 return this.actions.schedule(...a);
1203 }
1204 onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) {
1205 return this.actions.onPullRequest(...a);
1206 }
1207 workflows(...a: Parameters<Actions["listWorkflows"]>) {
1208 return this.actions.listWorkflows(...a);
1209 }
1210 runPlan(...a: Parameters<Actions["plan"]>) {
1211 return this.actions.plan(...a);
1212 }
1213 startJobKey(...a: Parameters<Actions["startJobKey"]>) {
1214 return this.actions.startJobKey(...a);
1215 }
1216 jobGate(...a: Parameters<Actions["jobGate"]>) {
1217 return this.actions.jobGate(...a);
1218 }
1219 pendingDeployments(...a: Parameters<Actions["pendingDeployments"]>) {
1220 return this.actions.pendingDeployments(...a);
1221 }
1222 reviewDeployments(...a: Parameters<Actions["reviewDeployments"]>) {
1223 return this.actions.reviewDeployments(...a);
1224 }
1225 deploymentReviews(...a: Parameters<Actions["deploymentReviews"]>) {
1226 return this.actions.deploymentReviews(...a);
1227 }
1228 jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) {
1229 return this.actions.jobTimedOut(...a);
1230 }
1231 finishRun(...a: Parameters<Actions["finishRun"]>) {
1232 return this.actions.finishRun(...a);
1233 }
1234 oidcClaims(...a: Parameters<Actions["oidcClaims"]>) {
1235 return this.actions.oidcClaims(...a);
1236 }
1237 runnerSpec(...a: Parameters<Actions["runnerSpec"]>) {
1238 return this.actions.runnerSpec(...a);
1239 }
1240 runnerLogs(...a: Parameters<Actions["runnerLogs"]>) {
1241 return this.actions.runnerLogs(...a);
1242 }
1243 runnerStep(...a: Parameters<Actions["runnerStep"]>) {
1244 return this.actions.runnerStep(...a);
1245 }
1246 runnerComplete(...a: Parameters<Actions["runnerComplete"]>) {
1247 return this.actions.runnerComplete(...a);
1248 }
1249 packagesPermission(...a: Parameters<Actions["packagesPermission"]>) {
1250 return this.actions.packagesPermission(...a);
1251 }
1252 runnerAuth(...a: Parameters<Actions["runnerAuth"]>) {
1253 return this.actions.runnerAuth(...a);
1254 }
1255 containerStopped(...a: Parameters<Actions["containerStopped"]>) {
1256 return this.actions.containerStopped(...a);
1257 }
1258 relaunchJob(...a: Parameters<Actions["relaunchJob"]>) {
1259 return this.actions.relaunchJob(...a);
1260 }
1261 setSecret(...a: Parameters<Actions["setSecret"]>) {
1262 return this.actions.setSecret(...a);
1263 }
1264 deleteSecret(...a: Parameters<Actions["deleteSecret"]>) {
1265 return this.actions.deleteSecret(...a);
1266 }
1267 secretNames(...a: Parameters<Actions["secretNames"]>) {
1268 return this.actions.secretNames(...a);
1269 }
1270 setVariable(...a: Parameters<Actions["setVariable"]>) {
1271 return this.actions.setVariable(...a);
1272 }
1273 variables(...a: Parameters<Actions["variables"]>) {
1274 return this.actions.variables(...a);
1275 }
1276 cacheLookup(...a: Parameters<Actions["cacheLookup"]>) {
1277 return this.actions.cacheLookup(...a);
1278 }
1279 cacheSaved(...a: Parameters<Actions["cacheSaved"]>) {
1280 return this.actions.cacheSaved(...a);
1281 }
1282 artifactUploadKeys(...a: Parameters<Actions["artifactUploadKeys"]>) {
1283 return this.actions.artifactUploadKeys(...a);
1284 }
1285 artifactSaved(...a: Parameters<Actions["artifactSaved"]>) {
1286 return this.actions.artifactSaved(...a);
1287 }
1288 artifacts(...a: Parameters<Actions["artifacts"]>) {
1289 return this.actions.artifacts(...a);
1290 }
1291 createSandbox(...a: Parameters<Actions["createSandbox"]>) {
1292 return this.actions.createSandbox(...a);
1293 }
1294 sandbox(...a: Parameters<Actions["getSandbox"]>) {
1295 return this.actions.getSandbox(...a);
1296 }
1297 closeSandbox(...a: Parameters<Actions["closeSandbox"]>) {
1298 return this.actions.closeSandbox(...a);
1299 }
1300 closeTaskSandbox(...a: Parameters<Actions["closeTaskSandbox"]>) {
1301 return this.actions.closeTaskSandbox(...a);
1302 }
1303 /** A kill switch was turned on (lib/killswitch.ts): stop what runs here now. */
1304 stopActions(...a: Parameters<Actions["stopActions"]>) {
1305 return this.actions.stopActions(...a);
1306 }
1307
1308 plannedRunError(runId: string, message: string) {
1309 return this.actions.plannedError(runId, message);
1310 }
1311 cacheSaveKeys(...a: Parameters<Actions["cacheSaveKeys"]>) {
1312 return this.actions.cacheSaveKeys(...a);
1313 }
1314 cacheReadKeys(...a: Parameters<Actions["cacheReadKeys"]>) {
1315 return this.actions.cacheReadKeys(...a);
1316 }
1317 artifactKeysFor(...a: Parameters<Actions["artifactKeysFor"]>) {
1318 return this.actions.artifactKeysFor(...a);
1319 }
1320 artifactQuota() {
1321 return this.runtime.artifactQuota;
1322 }
1323
1324 // ---------------------------------------------------------------- RPC passthroughs (Actions runtime services)
1325 // The cache and artifact services of repo/runtime.ts, called by http/actions-runtime.ts
1326 // with the job named by a runtime token, and by the REST caches/artifacts routes.
1327
1328 rtJob(...a: Parameters<Runtime["job"]>) {
1329 return this.runtime.job(...a);
1330 }
1331 rtGrantFor(...a: Parameters<Runtime["grantFor"]>) {
1332 return this.runtime.grantFor(...a);
1333 }
1334 rtCacheLookup(...a: Parameters<Runtime["cacheLookup"]>) {
1335 return this.runtime.cacheLookup(...a);
1336 }
1337 rtCacheReserve(...a: Parameters<Runtime["cacheReserve"]>) {
1338 return this.runtime.cacheReserve(...a);
1339 }
1340 rtCacheReservation(...a: Parameters<Runtime["cacheReservation"]>) {
1341 return this.runtime.cacheReservation(...a);
1342 }
1343 rtCacheWritable(...a: Parameters<Runtime["cacheWritable"]>) {
1344 return this.runtime.cacheWritable(...a);
1345 }
1346 rtCacheCommit(...a: Parameters<Runtime["cacheCommit"]>) {
1347 return this.runtime.cacheCommit(...a);
1348 }
1349 rtCacheGet(...a: Parameters<Runtime["cacheGet"]>) {
1350 return this.runtime.cacheGet(...a);
1351 }
1352 rtListCaches(...a: Parameters<Runtime["listCaches"]>) {
1353 return this.runtime.listCaches(...a);
1354 }
1355 rtDeleteCaches(...a: Parameters<Runtime["deleteCaches"]>) {
1356 return this.runtime.deleteCaches(...a);
1357 }
1358 rtCacheUsage(...a: Parameters<Runtime["cacheUsage"]>) {
1359 return this.runtime.cacheUsage(...a);
1360 }
1361 rtArtifactCreate(...a: Parameters<Runtime["artifactCreate"]>) {
1362 return this.runtime.artifactCreate(...a);
1363 }
1364 rtArtifactUpload(...a: Parameters<Runtime["artifactUpload"]>) {
1365 return this.runtime.artifactUpload(...a);
1366 }
1367 rtArtifactWritable(...a: Parameters<Runtime["artifactWritable"]>) {
1368 return this.runtime.artifactWritable(...a);
1369 }
1370 rtArtifactFinalize(...a: Parameters<Runtime["artifactFinalize"]>) {
1371 return this.runtime.artifactFinalize(...a);
1372 }
1373 rtArtifactGet(...a: Parameters<Runtime["artifactGet"]>) {
1374 return this.runtime.artifactGet(...a);
1375 }
1376 rtJobArtifacts(...a: Parameters<Runtime["jobArtifacts"]>) {
1377 return this.runtime.jobArtifacts(...a);
1378 }
1379 rtRunArtifacts(...a: Parameters<Runtime["runArtifacts"]>) {
1380 return this.runtime.runArtifacts(...a);
1381 }
1382 rtRepoArtifacts(...a: Parameters<Runtime["repoArtifacts"]>) {
1383 return this.runtime.repoArtifacts(...a);
1384 }
1385 rtArtifactDelete(...a: Parameters<Runtime["artifactDelete"]>) {
1386 return this.runtime.artifactDelete(...a);
1387 }
1388 rtArtifactDeleteByName(...a: Parameters<Runtime["artifactDeleteByName"]>) {
1389 return this.runtime.artifactDeleteByName(...a);
1390 }
1391 workflowSettings() {
1392 return this.policy.settings();
1393 }
1394 setWorkflowSettings(...a: Parameters<TokenPolicy["setSettings"]>) {
1395 return this.policy.setSettings(...a);
1396 }
1397 rtStage(...a: Parameters<Runtime["stage"]>) {
1398 return this.runtime.stage(...a);
1399 }
1400 rtUnstage(...a: Parameters<Runtime["unstage"]>) {
1401 return this.runtime.unstage(...a);
1402 }
1403 rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) {
1404 return this.runtime.checkCommitSize(...a);
1405 }
1406 rtCacheMissing(...a: Parameters<Runtime["cacheMissing"]>) {
1407 return this.runtime.cacheMissing(...a);
1408 }
1409 rtComposeStep(...a: Parameters<Runtime["composeStep"]>) {
1410 return this.runtime.composeStep(...a);
1411 }
1412
1413 // ---------------------------------------------------------------- RPC passthroughs (GitHub-compatible extras)
1414 // Releases, commit statuses, deployments and environments, issue sub-resources
1415 // (labels, reactions, events, locking), the Git data API, search and misc.
1416 // The modules are created on first use.
1417
1418 private extras: { releases?: Releases; statuses?: Statuses; deploys?: Deployments; social?: Social; gitdata?: GitData } = {};
1419 get releases(): Releases {
1420 return (this.extras.releases ??= new Releases(this, this.env));
1421 }
1422 get statuses(): Statuses {
1423 return (this.extras.statuses ??= new Statuses(this));
1424 }
1425 get deploys(): Deployments {
1426 return (this.extras.deploys ??= new Deployments(this, this.env));
1427 }
1428 get social(): Social {
1429 return (this.extras.social ??= new Social(this));
1430 }
1431 get gitdata(): GitData {
1432 return (this.extras.gitdata ??= new GitData(this, this.env));
1433 }
1434
1435 listReleases(...a: Parameters<Releases["list"]>) {
1436 return this.releases.list(...a);
1437 }
1438 getRelease(...a: Parameters<Releases["get"]>) {
1439 return this.releases.get(...a);
1440 }
1441 releaseByTag(...a: Parameters<Releases["byTag"]>) {
1442 return this.releases.byTag(...a);
1443 }
1444 latestRelease() {
1445 return this.releases.latest();
1446 }
1447 releaseSummary(...a: Parameters<Releases["summary"]>) {
1448 return this.releases.summary(...a);
1449 }
1450 createRelease(...a: Parameters<Releases["create"]>) {
1451 return this.releases.create(...a);
1452 }
1453 updateRelease(...a: Parameters<Releases["update"]>) {
1454 return this.releases.update(...a);
1455 }
1456 deleteRelease(...a: Parameters<Releases["remove"]>) {
1457 return this.releases.remove(...a);
1458 }
1459 generateReleaseNotes(...a: Parameters<Releases["generateNotes"]>) {
1460 return this.releases.generateNotes(...a);
1461 }
1462 releaseAssets(...a: Parameters<Releases["assets"]>) {
1463 return this.releases.assets(...a);
1464 }
1465 releaseAsset(...a: Parameters<Releases["asset"]>) {
1466 return this.releases.asset(...a);
1467 }
1468 updateReleaseAsset(...a: Parameters<Releases["updateAsset"]>) {
1469 return this.releases.updateAsset(...a);
1470 }
1471 deleteReleaseAsset(...a: Parameters<Releases["deleteAsset"]>) {
1472 return this.releases.deleteAsset(...a);
1473 }
1474 startReleaseAsset(...a: Parameters<Releases["startAsset"]>) {
1475 return this.releases.startAsset(...a);
1476 }
1477 finishReleaseAsset(...a: Parameters<Releases["finishAsset"]>) {
1478 return this.releases.finishAsset(...a);
1479 }
1480 abortReleaseAsset(...a: Parameters<Releases["abortAsset"]>) {
1481 return this.releases.abortAsset(...a);
1482 }
1483 releaseDownload(...a: Parameters<Releases["download"]>) {
1484 return this.releases.download(...a);
1485 }
1486 /** For the GitHub importer: a release with its original dates and author (no tag, events or workflows). */
1487 importRelease(...a: Parameters<Releases["importRelease"]>) {
1488 return this.releases.importRelease(...a);
1489 }
1490 /** For the GitHub importer: an asset copied from a public https URL, or from an R2 key under this repository's prefix. */
1491 importReleaseAsset(...a: Parameters<Releases["importAsset"]>) {
1492 return this.releases.importAsset(...a);
1493 }
1494
1495 createStatus(...a: Parameters<Statuses["create"]>) {
1496 return this.statuses.create(...a);
1497 }
1498 commitStatuses(...a: Parameters<Statuses["list"]>) {
1499 return this.statuses.list(...a);
1500 }
1501 combinedStatus(...a: Parameters<Statuses["combined"]>) {
1502 return this.statuses.combined(...a);
1503 }
1504 /** Check runs and commit statuses together, as pull request and commit pages show them. */
1505 checksAndStatuses(sha: string) {
1506 return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)];
1507 }
1508
1509 /** This repository's part of the Today view (agentic/today.ts): open pull requests and what landed or failed since `since`. */
1510 todaySummary(since: string) {
1511 return repoToday(this.sql, (sha) => this.checksAndStatuses(sha), since);
1512 }
1513
1514 createDeployment(...a: Parameters<Deployments["create"]>) {
1515 return this.deploys.create(...a);
1516 }
1517 deployments(...a: Parameters<Deployments["list"]>) {
1518 return this.deploys.list(...a);
1519 }
1520 deployment(...a: Parameters<Deployments["get"]>) {
1521 return this.deploys.get(...a);
1522 }
1523 deleteDeployment(...a: Parameters<Deployments["remove"]>) {
1524 return this.deploys.remove(...a);
1525 }
1526 createDeploymentStatus(...a: Parameters<Deployments["createStatus"]>) {
1527 return this.deploys.createStatus(...a);
1528 }
1529 deploymentStatuses(...a: Parameters<Deployments["statuses"]>) {
1530 return this.deploys.statuses(...a);
1531 }
1532 deploymentStatus(...a: Parameters<Deployments["status"]>) {
1533 return this.deploys.status(...a);
1534 }
1535 environments() {
1536 return this.deploys.environments();
1537 }
1538 environment(...a: Parameters<Deployments["environment"]>) {
1539 return this.deploys.environment(...a);
1540 }
1541 upsertEnvironment(...a: Parameters<Deployments["upsertEnvironment"]>) {
1542 return this.deploys.upsertEnvironment(...a);
1543 }
1544 deleteEnvironment(...a: Parameters<Deployments["deleteEnvironment"]>) {
1545 return this.deploys.deleteEnvironment(...a);
1546 }
1547 addEnvironmentBranchPattern(...a: Parameters<Deployments["addBranchPattern"]>) {
1548 return this.deploys.addBranchPattern(...a);
1549 }
1550 removeEnvironmentBranchPattern(...a: Parameters<Deployments["removeBranchPattern"]>) {
1551 return this.deploys.removeBranchPattern(...a);
1552 }
1553 setEnvironmentSecret(...a: Parameters<Deployments["setSecret"]>) {
1554 return this.deploys.setSecret(...a);
1555 }
1556 deleteEnvironmentSecret(...a: Parameters<Deployments["deleteSecret"]>) {
1557 return this.deploys.deleteSecret(...a);
1558 }
1559 environmentSecretNames(...a: Parameters<Deployments["secretNames"]>) {
1560 return this.deploys.secretNames(...a);
1561 }
1562 setEnvironmentVariable(...a: Parameters<Deployments["setVariable"]>) {
1563 return this.deploys.setVariable(...a);
1564 }
1565 environmentVariables(...a: Parameters<Deployments["variables"]>) {
1566 return this.deploys.variables(...a);
1567 }
1568
1569 /** What the GitHub JSON builders need for a set of issues: label rows, reaction counts and lock reasons. */
1570 issueExtras(numbers: number[], subjects: string[] = []) {
1571 const lockReasons: Record<number, string | null> = {};
1572 for (const n of numbers) lockReasons[n] = this.social.lockReason(n);
1573 return { labels: this.social.labelRows(), reactions: this.social.reactionCounts([...numbers.map((n) => `issue:${n}`), ...subjects]), lockReasons };
1574 }
1575 issueEvents(...a: Parameters<Social["events"]>) {
1576 return this.social.events(...a);
1577 }
1578 repoIssueEvents(...a: Parameters<Social["repoEvents"]>) {
1579 return this.social.repoEvents(...a);
1580 }
1581 issueEvent(...a: Parameters<Social["event"]>) {
1582 return this.social.event(...a);
1583 }
1584 issueTimeline(...a: Parameters<Social["timeline"]>) {
1585 return this.social.timeline(...a);
1586 }
1587 labelRows() {
1588 return this.social.labelRows();
1589 }
1590 label(...a: Parameters<Social["label"]>) {
1591 return this.social.label(...a);
1592 }
1593 createLabel(...a: Parameters<Social["createLabel"]>) {
1594 return this.social.createLabel(...a);
1595 }
1596 updateLabel(...a: Parameters<Social["updateLabel"]>) {
1597 return this.social.updateLabel(...a);
1598 }
1599 deleteLabel(...a: Parameters<Social["deleteLabel"]>) {
1600 return this.social.deleteLabel(...a);
1601 }
1602 issueLabels(...a: Parameters<Social["issueLabels"]>) {
1603 return this.social.issueLabels(...a);
1604 }
1605 addIssueLabels(...a: Parameters<Social["addLabels"]>) {
1606 return this.social.addLabels(...a);
1607 }
1608 setIssueLabels(...a: Parameters<Social["setLabels"]>) {
1609 return this.social.setLabels(...a);
1610 }
1611 removeIssueLabel(...a: Parameters<Social["removeLabel"]>) {
1612 return this.social.removeLabel(...a);
1613 }
1614 assignIssue(...a: Parameters<Social["assign"]>) {
1615 return this.social.assign(...a);
1616 }
1617 lockIssue(...a: Parameters<Social["lock"]>) {
1618 return this.social.lock(...a);
1619 }
1620 unlockIssue(...a: Parameters<Social["unlock"]>) {
1621 return this.social.unlock(...a);
1622 }
1623 comment(...a: Parameters<Social["comment"]>) {
1624 return this.social.comment(...a);
1625 }
1626 deleteComment(...a: Parameters<Social["deleteComment"]>) {
1627 return this.social.deleteComment(...a);
1628 }
1629 repoComments(...a: Parameters<Social["repoComments"]>) {
1630 return this.social.repoComments(...a);
1631 }
1632 createReviewComment(...a: Parameters<Social["createReviewComment"]>) {
1633 return this.social.createReviewComment(...a);
1634 }
1635 review(...a: Parameters<Social["review"]>) {
1636 return this.social.review(...a);
1637 }
1638 dismissReview(...a: Parameters<Social["dismissReview"]>) {
1639 return this.social.dismissReview(...a);
1640 }
1641 removeReviewers(...a: Parameters<Social["removeReviewers"]>) {
1642 return this.social.removeReviewers(...a);
1643 }
1644 updatePullBranch(...a: Parameters<Social["updateBranch"]>) {
1645 return this.social.updateBranch(...a);
1646 }
1647 pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) {
1648 return this.social.pullsForCommit(...a);
1649 }
1650 reactions(...a: Parameters<Social["reactions"]>) {
1651 return this.social.reactions(...a);
1652 }
1653 react(...a: Parameters<Social["react"]>) {
1654 return this.social.react(...a);
1655 }
1656 unreact(...a: Parameters<Social["unreact"]>) {
1657 return this.social.unreact(...a);
1658 }
1659 searchIssues(query: Parameters<typeof searchIssues>[1], limit: number) {
1660 return searchIssues(this, query, limit);
1661 }
1662 /** The issues for one page of search hits, in the order given (missing numbers are skipped). */
1663 issuesByNumber(numbers: number[], hide?: string[]) {
1664 return numbers.slice(0, 100).flatMap((n) => {
1665 try {
1666 return [this.collab.get(n, hide).issue];
1667 } catch {
1668 return [];
1669 }
1670 });
1671 }
1672
1673 objectType(...a: Parameters<GitData["objectType"]>) {
1674 return this.gitdata.objectType(...a);
1675 }
1676 refsWithTypes(...a: Parameters<GitData["refsWithTypes"]>) {
1677 return this.gitdata.refsWithTypes(...a);
1678 }
1679 createBlob(content: Uint8Array, caller: Caller) {
1680 return this.gitdata.createBlob(content, caller);
1681 }
1682 getBlob(...a: Parameters<GitData["getBlob"]>) {
1683 return this.gitdata.getBlob(...a);
1684 }
1685 createTree(...a: Parameters<GitData["createTree"]>) {
1686 return this.gitdata.createTree(...a);
1687 }
1688 getTree(...a: Parameters<GitData["getTree"]>) {
1689 return this.gitdata.getTree(...a);
1690 }
1691 createGitCommit(...a: Parameters<GitData["createCommit"]>) {
1692 return this.gitdata.createCommit(...a);
1693 }
1694 gitCommit(...a: Parameters<GitData["gitCommit"]>) {
1695 return this.gitdata.gitCommit(...a);
1696 }
1697 createGitTag(...a: Parameters<GitData["createTag"]>) {
1698 return this.gitdata.createTag(...a);
1699 }
1700 gitTag(...a: Parameters<GitData["getTag"]>) {
1701 return this.gitdata.getTag(...a);
1702 }
1703 putContents(...a: Parameters<GitData["putContents"]>) {
1704 return this.gitdata.putContents(...a);
1705 }
1706 deleteContents(...a: Parameters<GitData["deleteContents"]>) {
1707 return this.gitdata.deleteContents(...a);
1708 }
1709 readme(...a: Parameters<GitData["readme"]>) {
1710 return this.gitdata.readme(...a);
1711 }
1712 mergeBranches(...a: Parameters<GitData["merge"]>) {
1713 return this.gitdata.merge(...a);
1714 }
1715 languages() {
1716 return this.gitdata.languages();
1717 }
1718 archiveKey(...a: Parameters<GitData["archiveKey"]>) {
1719 return this.gitdata.archiveKey(...a);
1720 }
1721 buildArchive(...a: Parameters<GitData["buildArchive"]>) {
1722 return this.gitdata.buildArchive(...a);
1723 }
1724 runLogsZip(...a: Parameters<GitData["runLogsZip"]>) {
1725 return this.gitdata.runLogsZip(...a);
1726 }
1727 runAttemptId(...a: Parameters<GitData["runAttemptId"]>) {
1728 return this.gitdata.runAttemptId(...a);
1729 }
1730 checkRun(...a: Parameters<GitData["check"]>) {
1731 return this.gitdata.check(...a);
1732 }
1733 updateCheckRun(...a: Parameters<GitData["updateCheck"]>) {
1734 return this.gitdata.updateCheck(...a);
1735 }
1736 checkSuites(...a: Parameters<GitData["checkSuites"]>) {
1737 return this.gitdata.checkSuites(...a);
1738 }
1739 checkSuite(...a: Parameters<GitData["checkSuite"]>) {
1740 return this.gitdata.checkSuite(...a);
1741 }
1742
1743 /** repository_dispatch: an external event that starts workflows listening for its `event_type`. */
1744 async repositoryDispatch(eventType: string, clientPayload: Record<string, unknown>, caller: Caller): Promise<void> {
1745 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events");
1746 const sha = this.getRef(this.headRef);
1747 if (!sha) throw new HttpError(422, "the repository is empty");
1748 // Suspended Actions refuse the dispatch with the reason (triggerWorkflows would swallow it).
1749 await this.actions.refuseIfBlocked("run");
1750 const m = this.meta;
1751 const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload };
1752 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() });
1753 await triggerWorkflows(this, { name: "repository_dispatch", action: eventType, ref: this.headRef, sha, payload, title: eventType }, caller);
1754 }
1755
1756 // ---------------------------------------------------------------- RPC passthroughs: GitHub imports and milestones (repo/imports.ts)
1757
1758 private importsCache?: RepoImports;
1759 private get importsModule(): RepoImports {
1760 return (this.importsCache ??= new RepoImports(this.sql));
1761 }
1762
1763 /** The import that currently holds this repository, or null. Other writes are refused meanwhile (loadRepo, gitReceivePack). */
1764 importLockHolder(): string | null {
1765 return this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'import_lock'").toArray()[0]?.value ?? null;
1766 }
1767
1768 /**
1769 * Claim the repository for an import. Refuses if another import holds it, or if the repo
1770 * already holds any ref, issue, comment, milestone, protection or release this import did not write
1771 * (so a retry never adopts content people added in the meantime — H2).
1772 */
1773 importBegin(importId: string): void {
1774 const holder = this.importLockHolder();
1775 if (holder && holder !== importId) throw new HttpError(409, "another import is in progress for this repository");
1776 // A ref not written by this import, or any foreign row, means the repo was adopted since.
1777 for (const r of this.refs()) {
1778 if (r.name.startsWith("refs/diffinery-import/")) continue;
1779 if (this.importsModule.importRefOid(r.name, importId) === null) {
1780 if (holder !== importId) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
1781 }
1782 }
1783 if (this.importsModule.hasForeignContent(importId)) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
1784 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('import_lock', ?)", importId);
1785 }
1786
1787 /** Release the import lock (on finalize, fail or cancel). */
1788 importEnd(importId: string): void {
1789 if (this.importLockHolder() === importId) this.sql.exec("DELETE FROM meta WHERE key = 'import_lock'");
1790 }
1791
1792 importProtection(pattern: string, rules: ProtectionRules | null, importId: string) {
1793 // What agents may do is a person's decision here, never something an import brings in.
1794 if (rules) {
1795 const { allowAgentAutoMerge: _a, agentAutoMergeWithoutApproval: _w, agentsMayPush: _p, agentsMayMerge: _m, ...rest } = rules;
1796 rules = rest;
1797 }
1798 if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
1799 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
1800 if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId);
1801 }
1802 importLabels(...a: Parameters<RepoImports["labels"]>) {
1803 return this.importsModule.labels(...a);
1804 }
1805 importMilestones(...a: Parameters<RepoImports["milestones"]>) {
1806 return this.importsModule.milestones(...a);
1807 }
1808 importIssues(...a: Parameters<RepoImports["issues"]>) {
1809 return this.importsModule.issues(...a);
1810 }
1811 importComments(...a: Parameters<RepoImports["comments"]>) {
1812 return this.importsModule.comments(...a);
1813 }
1814 importReviews(...a: Parameters<RepoImports["reviews"]>) {
1815 return this.importsModule.reviews(...a);
1816 }
1817 importSetMergedBy(...a: Parameters<RepoImports["setMergedBy"]>) {
1818 return this.importsModule.setMergedBy(...a);
1819 }
1820 importPullNumbers(...a: Parameters<RepoImports["pullNumbers"]>) {
1821 return this.importsModule.pullNumbers(...a);
1822 }
1823 importCounts() {
1824 return this.importsModule.counts();
1825 }
1826 importFindings() {
1827 return this.importsModule.findings();
1828 }
1829 /**
1830 * Point `refs/pull/N/head` at each imported pull request's head commit (the server owns
1831 * refs/pull/*; the agent pushed the commits to refs/diffinery-import/pull/N). Heads whose
1832 * commits never arrived (e.g. deleted forks) are reported, not fatal.
1833 */
1834 importPullRefs(importId?: string): { created: number; missing: number[] } {
1835 const missing: number[] = [];
1836 let created = 0;
1837 for (const p of this.importsModule.pullHeads()) {
1838 const ref = `refs/pull/${p.number}/head`;
1839 if (!/^[0-9a-f]{40}$/.test(p.headSha) || !this.store.has(p.headSha)) {
1840 missing.push(p.number);
1841 continue;
1842 }
1843 const current = this.getRef(ref);
1844 // Record provenance so a retry does not mistake refs/pull/*/head for content people adopted.
1845 if (importId) this.importsModule.recordImportRef(ref, p.headSha, importId);
1846 if (current === p.headSha) continue;
1847 if (this.casRef(ref, current, p.headSha)) created++;
1848 }
1849 return { created, missing };
1850 }
1851 /** Remove the import's own refs once refs/pull/N/head keep the pull request commits reachable. */
1852 importDropTempRefs(importId?: string): number {
1853 const refs = this.refs(IMPORT_REF_PREFIX);
1854 for (const r of refs) this.casRef(r.name, r.oid, null);
1855 if (importId) this.importsModule.dropImportRefs(importId);
1856 return refs.length;
1857 }
1858 setOrgVariables(...a: Parameters<RepoImports["setOrgVariables"]>) {
1859 return this.importsModule.setOrgVariables(...a);
1860 }
1861 issueMilestoneOf(number: number) {
1862 return this.importsModule.milestoneOf(number);
1863 }
1864 /** Check names that workflow jobs on the default branch report, keyed by job name (GitHub names checks by job). */
1865 async importCheckNames(): Promise<Record<string, string[]>> {
1866 const sha = await this.resolve("HEAD");
1867 const out: Record<string, string[]> = {};
1868 if (!sha) return out;
1869 for (const w of await this.actions.loadWorkflows(sha)) {
1870 if (!w.workflow) continue;
1871 const wf = w.workflow.name ?? w.path.split("/").pop()!;
1872 for (const [key, job] of Object.entries(w.workflow.jobs)) {
1873 const name = (job as { name?: unknown }).name;
1874 for (const n of new Set([key, typeof name === "string" ? name : key])) (out[n] ??= []).push(`${wf} / ${typeof name === "string" && !name.includes("${{") ? name : key}`);
1875 }
1876 }
1877 return out;
1878 }
1879 milestones(...a: Parameters<RepoImports["listMilestones"]>) {
1880 return this.importsModule.listMilestones(...a);
1881 }
1882 milestone(...a: Parameters<RepoImports["milestone"]>) {
1883 return this.importsModule.milestone(...a);
1884 }
1885 saveMilestone(...a: Parameters<RepoImports["saveMilestone"]>) {
1886 return this.importsModule.saveMilestone(...a);
1887 }
1888 setIssueMilestone(...a: Parameters<RepoImports["setIssueMilestone"]>) {
1889 return this.importsModule.setIssueMilestone(...a);
1890 }
1891
1892 // ---------------------------------------------------------------- live updates (WebSockets with hibernation)
1893
1894 override async fetch(request: Request): Promise<Response> {
1895 if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
1896 const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10);
1897 const pair = new WebSocketPair();
1898 this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]);
1899 // Access is checked when a page connects; reconnecting checks it again, so someone who lost access
1900 // (removed, or the repository made private) stops getting updates within LIVE_REAUTH_MS.
1901 pair[1].serializeAttachment({ until: Date.now() + LIVE_REAUTH_MS });
1902 return new Response(null, { status: 101, webSocket: pair[0] });
1903 }
1904
1905 /** Close a socket whose access check is too old; the page reconnects, and the Worker checks access again. */
1906 private expired(ws: WebSocket): boolean {
1907 const a = ws.deserializeAttachment() as { until?: number } | null;
1908 if (a?.until && a.until > Date.now()) return false;
1909 try {
1910 ws.close(LIVE_REAUTH_CODE, "reconnect");
1911 } catch {
1912 /* already closed */
1913 }
1914 return true;
1915 }
1916
1917 override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
1918 if (this.expired(ws)) return;
1919 if (message === "ping") ws.send("pong");
1920 }
1921
1922 override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
1923 try {
1924 ws.close(code, "bye");
1925 } catch {
1926 /* already closed */
1927 }
1928 }
1929
1930 broadcast(topic: string, data: unknown): void {
1931 const msg = JSON.stringify({ topic, data });
1932 for (const ws of this.ctx.getWebSockets(topic)) {
1933 if (this.expired(ws)) continue;
1934 try {
1935 ws.send(msg);
1936 } catch {
1937 /* client went away */
1938 }
1939 }
1940 }
1941}
1942
1943export function toInfo(sha: string, c: Commit): CommitInfo {
1944 return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig };
1945}
1946
1947export { fromUtf8 };