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.

388 lines · 17.1 KBRaw History
1import type { ImportedComment, ImportedIssue, ImportedMilestone, ImportedReview } from "../imports/mapping.js";
2import { HttpError, now } from "../lib/util.js";
3
4/** Refs only a GitHub import writes (pull request heads and chunks in flight); see containers/runner/src/importer.ts. */
5export const IMPORT_REF_PREFIX = "refs/diffinery-import/";
6
7export type Milestone = {
8 number: number;
9 title: string;
10 description: string;
11 state: "open" | "closed";
12 dueOn: string | null;
13 createdAt: string;
14 updatedAt: string;
15 closedAt: string | null;
16 creator: string | null;
17 openIssues: number;
18 closedIssues: number;
19};
20
21type MilestoneRow = {
22 number: number;
23 title: string;
24 description: string;
25 state: "open" | "closed";
26 due_on: string | null;
27 created_at: string;
28 updated_at: string;
29 closed_at: string | null;
30 creator: string | null;
31};
32
33/**
34 * Repository side of GitHub imports, plus milestones. Imported rows keep their
35 * GitHub numbers, timestamps and people, and every write is an upsert keyed by
36 * the GitHub id, so a retried import step never duplicates anything. Nothing
37 * here emits events: an import fires no webhooks, workflows or notifications.
38 */
39export class RepoImports {
40 constructor(private readonly sql: SqlStorage) {}
41
42 // ---------------------------------------------------------------- labels and milestones
43
44 labels(list: Array<{ name: string; color: string; description: string }>, importId: string): number {
45 for (const l of list)
46 this.sql.exec(
47 `INSERT INTO labels (name, color, description, imported) VALUES (?, ?, ?, ?)
48 ON CONFLICT(name) DO UPDATE SET color = excluded.color, description = excluded.description, imported = excluded.imported
49 WHERE labels.imported = excluded.imported`,
50 l.name,
51 (l.color || "888888").replace(/^#/, ""),
52 l.description ?? "",
53 importId,
54 );
55 return list.length;
56 }
57
58 milestones(list: ImportedMilestone[], importId: string): number {
59 for (const m of list) {
60 this.sql.exec(
61 `INSERT INTO milestones (number, title, description, state, due_on, created_at, updated_at, closed_at, creator, imported)
62 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63 ON CONFLICT(number) DO UPDATE SET title = excluded.title, description = excluded.description, state = excluded.state,
64 due_on = excluded.due_on, created_at = excluded.created_at, updated_at = excluded.updated_at, closed_at = excluded.closed_at,
65 creator = excluded.creator, imported = excluded.imported
66 WHERE milestones.imported = excluded.imported`,
67 m.number,
68 m.title,
69 m.description,
70 m.state,
71 m.dueOn,
72 m.createdAt,
73 m.updatedAt,
74 m.closedAt,
75 m.creator,
76 importId,
77 );
78 }
79 this.raiseCounter("milestone", Math.max(0, ...list.map((m) => m.number)));
80 return list.length;
81 }
82
83 // ---------------------------------------------------------------- issues and pull requests
84
85 /** Insert issues and pull requests with their original numbers; only overwrites rows this import owns. */
86 issues(list: ImportedIssue[], importId: string): number {
87 for (const i of list) {
88 this.sql.exec(
89 `INSERT INTO issues (number, kind, title, body, state, state_reason, author_id, author_login, author_kind, assignees, labels, created_at, updated_at, closed_at, locked, imported)
90 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
91 ON CONFLICT(number) DO UPDATE SET kind = excluded.kind, title = excluded.title, body = excluded.body, state = excluded.state,
92 state_reason = excluded.state_reason, author_id = excluded.author_id, author_login = excluded.author_login, author_kind = excluded.author_kind,
93 assignees = excluded.assignees, labels = excluded.labels, created_at = excluded.created_at, updated_at = excluded.updated_at,
94 closed_at = excluded.closed_at, locked = excluded.locked, imported = excluded.imported
95 WHERE issues.imported = excluded.imported`,
96 i.number,
97 i.kind,
98 i.title,
99 i.body,
100 i.state,
101 i.stateReason,
102 i.author.id,
103 i.author.login,
104 i.author.kind,
105 JSON.stringify(i.assignees),
106 JSON.stringify(i.labels),
107 i.createdAt,
108 i.updatedAt,
109 i.closedAt,
110 i.locked ? 1 : 0,
111 importId,
112 );
113 if (i.pull) {
114 const p = i.pull;
115 this.sql.exec(
116 `INSERT INTO pulls (number, head_ref, head_repo_id, head_sha, base_ref, draft, merged, merged_at, merged_by, merge_commit, merged_base, requested_reviewers, imported)
117 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
118 ON CONFLICT(number) DO UPDATE SET head_ref = excluded.head_ref, head_repo_id = excluded.head_repo_id, head_sha = excluded.head_sha, base_ref = excluded.base_ref, draft = excluded.draft,
119 merged = excluded.merged, merged_at = excluded.merged_at, merged_by = COALESCE(excluded.merged_by, pulls.merged_by),
120 merge_commit = excluded.merge_commit, merged_base = excluded.merged_base, requested_reviewers = excluded.requested_reviewers, imported = excluded.imported
121 WHERE pulls.imported = excluded.imported`,
122 i.number,
123 p.headRef,
124 // A fork on GitHub: marks the pull request as one whose runs get no write and no secrets.
125 p.headRepo ? `github:${p.headRepo}` : null,
126 p.headSha,
127 p.baseRef,
128 p.draft ? 1 : 0,
129 p.merged ? 1 : 0,
130 p.mergedAt,
131 p.mergedBy,
132 p.mergeCommit,
133 p.baseSha ?? null,
134 JSON.stringify(p.requestedReviewers),
135 importId,
136 );
137 }
138 if (i.milestone) this.sql.exec("INSERT OR REPLACE INTO issue_milestones (number, milestone) VALUES (?, ?)", i.number, i.milestone);
139 else this.sql.exec("DELETE FROM issue_milestones WHERE number = ?", i.number);
140 }
141 this.raiseCounter("issue", Math.max(0, ...list.map((i) => i.number)));
142 return list.length;
143 }
144
145 /** GitHub reports who merged a pull request only on the single pull request. */
146 setMergedBy(number: number, login: string): void {
147 this.sql.exec("UPDATE pulls SET merged_by = ? WHERE number = ? AND merged = 1", login, number);
148 }
149
150 /** Pull request numbers after `after`, for the reviews pass. */
151 pullNumbers(after: number, limit: number): Array<{ number: number; merged: boolean }> {
152 return this.sql
153 .exec<{ number: number; merged: number }>("SELECT number, merged FROM pulls WHERE number > ? ORDER BY number LIMIT ?", after, limit)
154 .toArray()
155 .map((r) => ({ number: r.number, merged: !!r.merged }));
156 }
157
158 comments(list: ImportedComment[], importId: string): number {
159 let n = 0;
160 for (const c of list) {
161 if (!this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issues WHERE number = ?", c.number).one().n) continue;
162 // Not INSERT OR REPLACE: that reassigns the rowid, and comment "seq" (the public id) is the rowid.
163 this.sql.exec(
164 `INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at, review_id, path, line, side, commit_id, in_reply_to, imported)
165 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
166 ON CONFLICT(id) DO UPDATE SET number = excluded.number, author_id = excluded.author_id, author_login = excluded.author_login,
167 author_kind = excluded.author_kind, body = excluded.body, created_at = excluded.created_at, updated_at = excluded.updated_at,
168 review_id = excluded.review_id, path = excluded.path, line = excluded.line, side = excluded.side, commit_id = excluded.commit_id,
169 in_reply_to = excluded.in_reply_to, imported = excluded.imported
170 WHERE comments.imported = excluded.imported`,
171 c.id,
172 c.number,
173 c.author.id,
174 c.author.login,
175 c.author.kind,
176 c.body,
177 c.createdAt,
178 c.updatedAt,
179 c.reviewId ?? null,
180 c.path ?? null,
181 c.line ?? null,
182 c.side ?? null,
183 c.commitId ?? null,
184 c.inReplyTo ?? null,
185 importId,
186 );
187 n++;
188 }
189 return n;
190 }
191
192 reviews(list: ImportedReview[], importId: string): number {
193 for (const r of list) {
194 this.sql.exec(
195 `INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at, imported)
196 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
197 ON CONFLICT(id) DO UPDATE SET number = excluded.number, author_id = excluded.author_id, author_login = excluded.author_login,
198 author_kind = excluded.author_kind, state = excluded.state, body = excluded.body, commit_id = excluded.commit_id,
199 submitted_at = excluded.submitted_at, imported = excluded.imported
200 WHERE reviews.imported = excluded.imported`,
201 r.id,
202 r.number,
203 r.author.id,
204 r.author.login,
205 r.author.kind,
206 r.state,
207 r.body,
208 r.commitId,
209 r.submittedAt,
210 importId,
211 );
212 }
213 return list.length;
214 }
215
216 /** New issues continue after the highest imported number. */
217 private raiseCounter(name: string, value: number): void {
218 if (value > 0) this.sql.exec("INSERT INTO counters (name, value) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET value = max(value, excluded.value)", name, value);
219 }
220
221 counts(): { issues: number; pulls: number; comments: number; reviews: number; labels: number; milestones: number; maxNumber: number } {
222 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
223 return {
224 issues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue'"),
225 pulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull'"),
226 comments: q("SELECT count(*) AS n FROM comments"),
227 reviews: q("SELECT count(*) AS n FROM reviews"),
228 labels: q("SELECT count(*) AS n FROM labels"),
229 milestones: q("SELECT count(*) AS n FROM milestones"),
230 maxNumber: q("SELECT COALESCE(max(number), 0) AS n FROM issues"),
231 };
232 }
233
234 // ---------------------------------------------------------------- push protection findings
235
236 /** Secrets an import pushed with an admin bypass, so the summary can list what to rotate. */
237 recordFindings(leaks: Array<{ oid: string; kind: string }>): void {
238 for (const l of leaks.slice(0, 500)) this.sql.exec("INSERT OR IGNORE INTO import_findings (blob, kind, at) VALUES (?, ?, ?)", l.oid, l.kind, now());
239 }
240
241 findings(): Array<{ blob: string; kind: string; at: string }> {
242 return this.sql.exec<{ blob: string; kind: string; at: string }>("SELECT blob, kind, at FROM import_findings ORDER BY at, blob").toArray();
243 }
244
245 /** Imported pull requests and the head commit each one pins. */
246 pullHeads(): Array<{ number: number; headSha: string }> {
247 return this.sql.exec<{ number: number; head_sha: string }>("SELECT number, head_sha FROM pulls ORDER BY number").toArray().map((r) => ({ number: r.number, headSha: r.head_sha }));
248 }
249
250 // ---------------------------------------------------------------- import lock and provenance (H2)
251
252 /**
253 * True when the repository already holds any issue, pull request, comment, review, label,
254 * milestone, protection or release that this import did not write. A retry refuses to reuse a repo
255 * that people have since adopted, so an import never clobbers real content.
256 */
257 hasForeignContent(importId: string): boolean {
258 const tables = ["issues", "pulls", "comments", "reviews", "labels", "milestones", "protections", "releases"];
259 for (const t of tables) {
260 const n = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM ${t} WHERE imported IS NULL OR imported <> ?`, importId).one().n;
261 if (n > 0) return true;
262 }
263 // A milestone assignment or issue-milestone link is content too; covered by issues/milestones above.
264 return false;
265 }
266
267 /** Record that this import set `ref` to `oid` (so a later update may move only refs it owns). */
268 recordImportRef(ref: string, oid: string, importId: string): void {
269 this.sql.exec("INSERT INTO import_refs (ref, oid, import_id) VALUES (?, ?, ?) ON CONFLICT(ref) DO UPDATE SET oid = excluded.oid, import_id = excluded.import_id", ref, oid, importId);
270 }
271
272 /** The oid this import last wrote to `ref`, or null if the import never wrote it. */
273 importRefOid(ref: string, importId: string): string | null {
274 const r = this.sql.exec<{ oid: string }>("SELECT oid FROM import_refs WHERE ref = ? AND import_id = ?", ref, importId).toArray()[0];
275 return r ? r.oid : null;
276 }
277
278 dropImportRefs(importId: string): void {
279 this.sql.exec("DELETE FROM import_refs WHERE import_id = ?", importId);
280 }
281
282 /** Write imported branch protection, only overwriting a rule this import owns. */
283 protection(pattern: string, rules: string, importId: string): void {
284 this.sql.exec(
285 `INSERT INTO protections (pattern, rules, imported) VALUES (?, ?, ?)
286 ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules, imported = excluded.imported
287 WHERE protections.imported = excluded.imported`,
288 pattern,
289 rules,
290 importId,
291 );
292 }
293
294 // ---------------------------------------------------------------- organization variables
295
296 /** Replace the copy of the owning organization's Actions variables. */
297 setOrgVariables(vars: Record<string, string>): void {
298 this.sql.exec("DELETE FROM org_variables");
299 for (const [name, value] of Object.entries(vars)) this.sql.exec("INSERT INTO org_variables (name, value) VALUES (?, ?)", name, value);
300 }
301
302 // ---------------------------------------------------------------- milestones
303
304 private milestoneRow(m: MilestoneRow): Milestone {
305 const counts = this.sql
306 .exec<{ state: string; n: number }>("SELECT i.state, count(*) AS n FROM issue_milestones x JOIN issues i ON i.number = x.number WHERE x.milestone = ? GROUP BY i.state", m.number)
307 .toArray();
308 return {
309 number: m.number,
310 title: m.title,
311 description: m.description,
312 state: m.state,
313 dueOn: m.due_on,
314 createdAt: m.created_at,
315 updatedAt: m.updated_at,
316 closedAt: m.closed_at,
317 creator: m.creator,
318 openIssues: counts.find((c) => c.state === "open")?.n ?? 0,
319 closedIssues: counts.find((c) => c.state === "closed")?.n ?? 0,
320 };
321 }
322
323 listMilestones(state: "open" | "closed" | "all" = "open"): Milestone[] {
324 const rows = this.sql
325 .exec<MilestoneRow>(`SELECT * FROM milestones ${state === "all" ? "" : "WHERE state = ?"} ORDER BY COALESCE(due_on, '9999'), number`, ...(state === "all" ? [] : [state]))
326 .toArray();
327 return rows.map((r) => this.milestoneRow(r));
328 }
329
330 milestone(number: number): Milestone {
331 const r = this.sql.exec<MilestoneRow>("SELECT * FROM milestones WHERE number = ?", number).toArray()[0];
332 if (!r) throw new HttpError(404, "milestone not found");
333 return this.milestoneRow(r);
334 }
335
336 saveMilestone(number: number | null, input: { title?: string; description?: string; state?: "open" | "closed"; dueOn?: string | null }, creator: string): Milestone {
337 const t = now();
338 if (number === null) {
339 if (!input.title?.trim()) throw new HttpError(422, "title is required");
340 this.sql.exec("INSERT INTO counters (name, value) VALUES ('milestone', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
341 const n = this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'milestone'").one().value;
342 const state = input.state ?? "open";
343 this.sql.exec(
344 "INSERT INTO milestones (number, title, description, state, due_on, created_at, updated_at, creator, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
345 n,
346 input.title.trim(),
347 input.description ?? "",
348 state,
349 input.dueOn ?? null,
350 t,
351 t,
352 creator,
353 state === "closed" ? t : null,
354 );
355 return this.milestone(n);
356 }
357 const m = this.milestone(number);
358 const state = input.state ?? m.state;
359 this.sql.exec(
360 "UPDATE milestones SET title = ?, description = ?, state = ?, due_on = ?, updated_at = ?, closed_at = ? WHERE number = ?",
361 input.title?.trim() || m.title,
362 input.description ?? m.description,
363 state,
364 input.dueOn === undefined ? m.dueOn : input.dueOn,
365 t,
366 state === "closed" ? (m.closedAt ?? t) : null,
367 number,
368 );
369 return this.milestone(number);
370 }
371
372 setIssueMilestone(number: number, milestone: number | null): void {
373 if (!this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issues WHERE number = ?", number).one().n) throw new HttpError(404, `#${number} not found`);
374 if (milestone === null) {
375 this.sql.exec("DELETE FROM issue_milestones WHERE number = ?", number);
376 return;
377 }
378 this.milestone(milestone);
379 this.sql.exec("INSERT OR REPLACE INTO issue_milestones (number, milestone) VALUES (?, ?)", number, milestone);
380 }
381
382 milestoneOf(number: number): { number: number; title: string; state: "open" | "closed"; dueOn: string | null } | null {
383 const r = this.sql
384 .exec<{ number: number; title: string; state: "open" | "closed"; due_on: string | null }>("SELECT m.number, m.title, m.state, m.due_on FROM issue_milestones x JOIN milestones m ON m.number = x.milestone WHERE x.number = ?", number)
385 .toArray()[0];
386 return r ? { number: r.number, title: r.title, state: r.state, dueOn: r.due_on } : null;
387 }
388}