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.

373 lines · 16.6 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_sha, base_ref, draft, merged, merged_at, merged_by, merge_commit, requested_reviewers, imported)
117 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
118 ON CONFLICT(number) DO UPDATE SET head_ref = excluded.head_ref, 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, requested_reviewers = excluded.requested_reviewers, imported = excluded.imported
121 WHERE pulls.imported = excluded.imported`,
122 i.number,
123 p.headRef,
124 p.headSha,
125 p.baseRef,
126 p.draft ? 1 : 0,
127 p.merged ? 1 : 0,
128 p.mergedAt,
129 p.mergedBy,
130 p.mergeCommit,
131 JSON.stringify(p.requestedReviewers),
132 importId,
133 );
134 }
135 if (i.milestone) this.sql.exec("INSERT OR REPLACE INTO issue_milestones (number, milestone) VALUES (?, ?)", i.number, i.milestone);
136 else this.sql.exec("DELETE FROM issue_milestones WHERE number = ?", i.number);
137 }
138 this.raiseCounter("issue", Math.max(0, ...list.map((i) => i.number)));
139 return list.length;
140 }
141
142 /** GitHub reports who merged a pull request only on the single pull request. */
143 setMergedBy(number: number, login: string): void {
144 this.sql.exec("UPDATE pulls SET merged_by = ? WHERE number = ? AND merged = 1", login, number);
145 }
146
147 /** Pull request numbers after `after`, for the reviews pass. */
148 pullNumbers(after: number, limit: number): Array<{ number: number; merged: boolean }> {
149 return this.sql
150 .exec<{ number: number; merged: number }>("SELECT number, merged FROM pulls WHERE number > ? ORDER BY number LIMIT ?", after, limit)
151 .toArray()
152 .map((r) => ({ number: r.number, merged: !!r.merged }));
153 }
154
155 comments(list: ImportedComment[], importId: string): number {
156 let n = 0;
157 for (const c of list) {
158 if (!this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issues WHERE number = ?", c.number).one().n) continue;
159 // Not INSERT OR REPLACE: that reassigns the rowid, and comment "seq" (the public id) is the rowid.
160 this.sql.exec(
161 `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)
162 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
163 ON CONFLICT(id) DO UPDATE SET number = excluded.number, author_id = excluded.author_id, author_login = excluded.author_login,
164 author_kind = excluded.author_kind, body = excluded.body, created_at = excluded.created_at, updated_at = excluded.updated_at,
165 review_id = excluded.review_id, path = excluded.path, line = excluded.line, side = excluded.side, commit_id = excluded.commit_id,
166 in_reply_to = excluded.in_reply_to, imported = excluded.imported
167 WHERE comments.imported = excluded.imported`,
168 c.id,
169 c.number,
170 c.author.id,
171 c.author.login,
172 c.author.kind,
173 c.body,
174 c.createdAt,
175 c.updatedAt,
176 c.reviewId ?? null,
177 c.path ?? null,
178 c.line ?? null,
179 c.side ?? null,
180 c.commitId ?? null,
181 c.inReplyTo ?? null,
182 importId,
183 );
184 n++;
185 }
186 return n;
187 }
188
189 reviews(list: ImportedReview[], importId: string): number {
190 for (const r of list) {
191 this.sql.exec(
192 `INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at, imported)
193 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
194 ON CONFLICT(id) DO UPDATE SET number = excluded.number, author_id = excluded.author_id, author_login = excluded.author_login,
195 author_kind = excluded.author_kind, state = excluded.state, body = excluded.body, commit_id = excluded.commit_id,
196 submitted_at = excluded.submitted_at, imported = excluded.imported
197 WHERE reviews.imported = excluded.imported`,
198 r.id,
199 r.number,
200 r.author.id,
201 r.author.login,
202 r.author.kind,
203 r.state,
204 r.body,
205 r.commitId,
206 r.submittedAt,
207 importId,
208 );
209 }
210 return list.length;
211 }
212
213 /** New issues continue after the highest imported number. */
214 private raiseCounter(name: string, value: number): void {
215 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);
216 }
217
218 counts(): { issues: number; pulls: number; comments: number; reviews: number; labels: number; milestones: number; maxNumber: number } {
219 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
220 return {
221 issues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue'"),
222 pulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull'"),
223 comments: q("SELECT count(*) AS n FROM comments"),
224 reviews: q("SELECT count(*) AS n FROM reviews"),
225 labels: q("SELECT count(*) AS n FROM labels"),
226 milestones: q("SELECT count(*) AS n FROM milestones"),
227 maxNumber: q("SELECT COALESCE(max(number), 0) AS n FROM issues"),
228 };
229 }
230
231 // ---------------------------------------------------------------- push protection findings
232
233 /** Secrets an import pushed with an admin bypass, so the summary can list what to rotate. */
234 recordFindings(leaks: Array<{ oid: string; kind: string }>): void {
235 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());
236 }
237
238 findings(): Array<{ blob: string; kind: string; at: string }> {
239 return this.sql.exec<{ blob: string; kind: string; at: string }>("SELECT blob, kind, at FROM import_findings ORDER BY at, blob").toArray();
240 }
241
242 /** Imported pull requests and the head commit each one pins. */
243 pullHeads(): Array<{ number: number; headSha: string }> {
244 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 }));
245 }
246
247 // ---------------------------------------------------------------- import lock and provenance (H2)
248
249 /**
250 * True when the repository already holds any issue, pull request, comment, review, label,
251 * milestone or protection that this import did not write. A retry refuses to reuse a repo
252 * that people have since adopted, so an import never clobbers real content.
253 */
254 hasForeignContent(importId: string): boolean {
255 const tables = ["issues", "pulls", "comments", "reviews", "labels", "milestones", "protections"];
256 for (const t of tables) {
257 const n = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM ${t} WHERE imported IS NULL OR imported <> ?`, importId).one().n;
258 if (n > 0) return true;
259 }
260 // A milestone assignment or issue-milestone link is content too; covered by issues/milestones above.
261 return false;
262 }
263
264 /** Record that this import set `ref` to `oid` (so a later update may move only refs it owns). */
265 recordImportRef(ref: string, oid: string, importId: string): void {
266 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);
267 }
268
269 /** The oid this import last wrote to `ref`, or null if the import never wrote it. */
270 importRefOid(ref: string, importId: string): string | null {
271 const r = this.sql.exec<{ oid: string }>("SELECT oid FROM import_refs WHERE ref = ? AND import_id = ?", ref, importId).toArray()[0];
272 return r ? r.oid : null;
273 }
274
275 dropImportRefs(importId: string): void {
276 this.sql.exec("DELETE FROM import_refs WHERE import_id = ?", importId);
277 }
278
279 /** Write imported branch protection, only overwriting a rule this import owns. */
280 protection(pattern: string, rules: string, importId: string): void {
281 this.sql.exec(
282 `INSERT INTO protections (pattern, rules, imported) VALUES (?, ?, ?)
283 ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules, imported = excluded.imported
284 WHERE protections.imported = excluded.imported`,
285 pattern,
286 rules,
287 importId,
288 );
289 }
290
291 // ---------------------------------------------------------------- organization variables
292
293 /** Replace the copy of the owning organization's Actions variables. */
294 setOrgVariables(vars: Record<string, string>): void {
295 this.sql.exec("DELETE FROM org_variables");
296 for (const [name, value] of Object.entries(vars)) this.sql.exec("INSERT INTO org_variables (name, value) VALUES (?, ?)", name, value);
297 }
298
299 // ---------------------------------------------------------------- milestones
300
301 private milestoneRow(m: MilestoneRow): Milestone {
302 const counts = this.sql
303 .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)
304 .toArray();
305 return {
306 number: m.number,
307 title: m.title,
308 description: m.description,
309 state: m.state,
310 dueOn: m.due_on,
311 createdAt: m.created_at,
312 updatedAt: m.updated_at,
313 closedAt: m.closed_at,
314 creator: m.creator,
315 openIssues: counts.find((c) => c.state === "open")?.n ?? 0,
316 closedIssues: counts.find((c) => c.state === "closed")?.n ?? 0,
317 };
318 }
319
320 listMilestones(state: "open" | "closed" | "all" = "open"): Milestone[] {
321 const rows = this.sql
322 .exec<MilestoneRow>(`SELECT * FROM milestones ${state === "all" ? "" : "WHERE state = ?"} ORDER BY COALESCE(due_on, '9999'), number`, ...(state === "all" ? [] : [state]))
323 .toArray();
324 return rows.map((r) => this.milestoneRow(r));
325 }
326
327 milestone(number: number): Milestone {
328 const r = this.sql.exec<MilestoneRow>("SELECT * FROM milestones WHERE number = ?", number).toArray()[0];
329 if (!r) throw new HttpError(404, "milestone not found");
330 return this.milestoneRow(r);
331 }
332
333 saveMilestone(number: number | null, input: { title?: string; description?: string; state?: "open" | "closed"; dueOn?: string | null }, creator: string): Milestone {
334 const t = now();
335 if (number === null) {
336 if (!input.title?.trim()) throw new HttpError(422, "title is required");
337 this.sql.exec("INSERT INTO counters (name, value) VALUES ('milestone', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1");
338 const n = this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'milestone'").one().value;
339 this.sql.exec("INSERT INTO milestones (number, title, description, state, due_on, created_at, updated_at, creator) VALUES (?, ?, ?, 'open', ?, ?, ?, ?)", n, input.title.trim(), input.description ?? "", input.dueOn ?? null, t, t, creator);
340 return this.milestone(n);
341 }
342 const m = this.milestone(number);
343 const state = input.state ?? m.state;
344 this.sql.exec(
345 "UPDATE milestones SET title = ?, description = ?, state = ?, due_on = ?, updated_at = ?, closed_at = ? WHERE number = ?",
346 input.title?.trim() || m.title,
347 input.description ?? m.description,
348 state,
349 input.dueOn === undefined ? m.dueOn : input.dueOn,
350 t,
351 state === "closed" ? (m.closedAt ?? t) : null,
352 number,
353 );
354 return this.milestone(number);
355 }
356
357 setIssueMilestone(number: number, milestone: number | null): void {
358 if (!this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM issues WHERE number = ?", number).one().n) throw new HttpError(404, `#${number} not found`);
359 if (milestone === null) {
360 this.sql.exec("DELETE FROM issue_milestones WHERE number = ?", number);
361 return;
362 }
363 this.milestone(milestone);
364 this.sql.exec("INSERT OR REPLACE INTO issue_milestones (number, milestone) VALUES (?, ?)", number, milestone);
365 }
366
367 milestoneOf(number: number): { number: number; title: string; state: "open" | "closed"; dueOn: string | null } | null {
368 const r = this.sql
369 .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)
370 .toArray()[0];
371 return r ? { number: r.number, title: r.title, state: r.state, dueOn: r.due_on } : null;
372 }
373}