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.

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