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.

354 lines · 11.2 KBRaw History
1/**
2 * Mapping GitHub REST objects to the rows an import writes into a repository.
3 * Pure functions, unit tested; the workflow fetches and the repository stores.
4 */
5
6export interface GhUser {
7 login: string;
8 id?: number;
9 type?: string;
10}
11
12export interface GhLabel {
13 name: string;
14 color?: string | null;
15 description?: string | null;
16}
17
18export interface GhMilestone {
19 number: number;
20 title: string;
21 description?: string | null;
22 state: "open" | "closed";
23 due_on?: string | null;
24 created_at: string;
25 updated_at: string;
26 closed_at?: string | null;
27 creator?: GhUser | null;
28}
29
30export interface GhIssue {
31 number: number;
32 title: string;
33 body?: string | null;
34 state: "open" | "closed";
35 state_reason?: string | null;
36 user: GhUser | null;
37 labels?: Array<GhLabel | string>;
38 assignees?: GhUser[] | null;
39 milestone?: { number: number } | null;
40 locked?: boolean;
41 created_at: string;
42 updated_at: string;
43 closed_at?: string | null;
44 pull_request?: unknown;
45}
46
47export interface GhPull extends GhIssue {
48 draft?: boolean;
49 merged_at?: string | null;
50 merge_commit_sha?: string | null;
51 merged_by?: GhUser | null;
52 requested_reviewers?: GhUser[] | null;
53 head: { ref: string; sha: string; label?: string; repo: { full_name: string } | null };
54 base: { ref: string; sha?: string; repo?: { full_name: string } | null };
55}
56
57export interface GhComment {
58 id: number;
59 body?: string | null;
60 user: GhUser | null;
61 created_at: string;
62 updated_at: string;
63 issue_url?: string;
64}
65
66export interface GhReview {
67 id: number;
68 user: GhUser | null;
69 state: string;
70 body?: string | null;
71 commit_id?: string | null;
72 submitted_at?: string | null;
73}
74
75export interface GhReviewComment {
76 id: number;
77 pull_request_review_id?: number | null;
78 pull_request_url?: string;
79 user: GhUser | null;
80 body?: string | null;
81 path: string;
82 line?: number | null;
83 original_line?: number | null;
84 side?: "LEFT" | "RIGHT" | null;
85 commit_id?: string | null;
86 original_commit_id?: string | null;
87 in_reply_to_id?: number | null;
88 created_at: string;
89 updated_at: string;
90}
91
92/** Who did something: a linked Diffinery account, or a GitHub user shown as imported. */
93export interface Person {
94 id: string;
95 login: string;
96 kind: "user" | "github";
97}
98
99/** A verified link from a GitHub account to a Diffinery account (see account_links). */
100export interface Link {
101 externalId: string;
102 externalLogin: string;
103 accountId: string;
104 login: string;
105}
106
107/** Prefix for GitHub logins stored where Diffinery expects a login (assignees, reviewers, merged by). */
108export const IMPORTED_PREFIX = "github:";
109
110/**
111 * Maps GitHub users to Diffinery accounts only through verified links, matched by
112 * GitHub's numeric user id (logins can be renamed and reused). Everyone else stays
113 * a GitHub user, shown as imported; no accounts are created.
114 */
115export class People {
116 private byId = new Map<string, Link>();
117 private byLogin = new Map<string, Link>();
118 readonly mapped = new Set<string>();
119 readonly unmapped = new Set<string>();
120
121 constructor(links: Link[]) {
122 for (const l of links) {
123 this.byId.set(l.externalId, l);
124 this.byLogin.set(l.externalLogin.toLowerCase(), l);
125 }
126 }
127
128 private link(u: GhUser): Link | undefined {
129 // Objects without an id (rare, e.g. some Enterprise payloads) fall back to the linked login.
130 return u.id !== undefined && u.id !== null ? this.byId.get(String(u.id)) : this.byLogin.get(u.login.toLowerCase());
131 }
132
133 person(u: GhUser | null | undefined): Person {
134 if (!u) return { id: "github:ghost", login: "ghost", kind: "github" };
135 const l = this.link(u);
136 if (l) {
137 this.mapped.add(u.login);
138 return { id: l.accountId, login: l.login, kind: "user" };
139 }
140 this.unmapped.add(u.login);
141 return { id: `github:${u.id ?? u.login}`, login: u.login, kind: "github" };
142 }
143
144 /** A login for fields that hold logins: the Diffinery login when linked, else "github:<login>". */
145 login(u: GhUser | null | undefined): string {
146 const p = this.person(u);
147 return p.kind === "user" ? p.login : `${IMPORTED_PREFIX}${p.login}`;
148 }
149}
150
151export interface ImportedIssue {
152 number: number;
153 kind: "issue" | "pull";
154 title: string;
155 body: string;
156 state: "open" | "closed";
157 stateReason: string | null;
158 author: Person;
159 assignees: string[];
160 labels: string[];
161 milestone: number | null;
162 locked: boolean;
163 createdAt: string;
164 updatedAt: string;
165 closedAt: string | null;
166 pull?: {
167 headRef: string;
168 /** The GitHub repository a fork's head is in ("owner/name"); null for the same repository. */
169 headRepo: string | null;
170 headSha: string;
171 baseRef: string;
172 draft: boolean;
173 merged: boolean;
174 mergedAt: string | null;
175 mergedBy: string | null;
176 mergeCommit: string | null;
177 requestedReviewers: string[];
178 };
179}
180
181export interface ImportedComment {
182 id: string;
183 number: number;
184 author: Person;
185 body: string;
186 createdAt: string;
187 updatedAt: string;
188 reviewId?: string | null;
189 path?: string | null;
190 line?: number | null;
191 side?: "LEFT" | "RIGHT" | null;
192 commitId?: string | null;
193 inReplyTo?: string | null;
194}
195
196export interface ImportedReview {
197 id: string;
198 number: number;
199 author: Person;
200 state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED";
201 body: string;
202 commitId: string;
203 submittedAt: string;
204}
205
206export interface ImportedMilestone {
207 number: number;
208 title: string;
209 description: string;
210 state: "open" | "closed";
211 dueOn: string | null;
212 createdAt: string;
213 updatedAt: string;
214 closedAt: string | null;
215 creator: string | null;
216}
217
218const labelName = (l: GhLabel | string) => (typeof l === "string" ? l : l.name);
219
220const STATE_REASONS = new Set(["completed", "not_planned", "reopened", "duplicate"]);
221
222export function mapIssue(i: GhIssue, people: People): ImportedIssue {
223 return {
224 number: i.number,
225 kind: "issue",
226 title: i.title || `Issue #${i.number}`,
227 body: i.body ?? "",
228 state: i.state === "closed" ? "closed" : "open",
229 stateReason: i.state === "closed" ? (i.state_reason && STATE_REASONS.has(i.state_reason) ? i.state_reason : "completed") : null,
230 author: people.person(i.user),
231 assignees: (i.assignees ?? []).map((a) => people.login(a)),
232 labels: (i.labels ?? []).map(labelName).filter(Boolean),
233 milestone: i.milestone?.number ?? null,
234 locked: !!i.locked,
235 createdAt: i.created_at,
236 updatedAt: i.updated_at,
237 closedAt: i.state === "closed" ? (i.closed_at ?? i.updated_at) : null,
238 };
239}
240
241/**
242 * A pull request keeps its number, state and history. Same-repository heads keep
243 * their branch name; heads in forks have no branch here, so they are kept as
244 * "fork-owner:branch" (never a valid local branch, so pushes never move them)
245 * with the head commit pinned by `refs/pull/N/head`.
246 */
247export function mapPull(p: GhPull, sourceFullName: string, people: People): ImportedIssue {
248 const merged = !!p.merged_at;
249 const sameRepo = p.head.repo?.full_name?.toLowerCase() === sourceFullName.toLowerCase();
250 const forkOwner = p.head.label?.split(":")[0] ?? p.head.repo?.full_name?.split("/")[0] ?? "unknown";
251 return {
252 ...mapIssue(p, people),
253 kind: "pull",
254 title: p.title || `Pull request #${p.number}`,
255 stateReason: p.state === "closed" ? (merged ? "merged" : null) : null,
256 pull: {
257 headRef: sameRepo ? p.head.ref : `${forkOwner}:${p.head.ref}`,
258 headRepo: sameRepo ? null : (p.head.repo?.full_name ?? `${forkOwner}/unknown`),
259 headSha: p.head.sha,
260 baseRef: p.base.ref,
261 draft: !!p.draft,
262 merged,
263 mergedAt: p.merged_at ?? null,
264 mergedBy: merged && p.merged_by ? people.login(p.merged_by) : null,
265 mergeCommit: merged ? (p.merge_commit_sha ?? null) : null,
266 requestedReviewers: p.state === "open" ? (p.requested_reviewers ?? []).map((r) => people.login(r)) : [],
267 },
268 };
269}
270
271/** The issue or pull request number a comment belongs to, from its API URL. */
272export function numberFromUrl(url: string | undefined): number | null {
273 const m = /\/(?:issues|pulls)\/(\d+)$/.exec(url ?? "");
274 return m ? Number(m[1]) : null;
275}
276
277export function mapIssueComment(c: GhComment, people: People): ImportedComment | null {
278 const number = numberFromUrl(c.issue_url);
279 if (!number) return null;
280 return { id: `gh-ic${c.id}`, number, author: people.person(c.user), body: c.body ?? "", createdAt: c.created_at, updatedAt: c.updated_at };
281}
282
283export function mapReview(r: GhReview, number: number, people: People): ImportedReview | null {
284 if (!r.submitted_at || r.state === "PENDING") return null;
285 const state = (["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"] as const).find((s) => s === r.state) ?? "COMMENTED";
286 return { id: `gh-r${r.id}`, number, author: people.person(r.user), state, body: r.body ?? "", commitId: r.commit_id ?? "", submittedAt: r.submitted_at };
287}
288
289export function mapReviewComment(c: GhReviewComment, people: People): ImportedComment | null {
290 const number = numberFromUrl(c.pull_request_url);
291 if (!number) return null;
292 return {
293 id: `gh-rc${c.id}`,
294 number,
295 author: people.person(c.user),
296 body: c.body ?? "",
297 createdAt: c.created_at,
298 updatedAt: c.updated_at,
299 reviewId: c.pull_request_review_id ? `gh-r${c.pull_request_review_id}` : null,
300 path: c.path,
301 line: c.line ?? c.original_line ?? null,
302 side: c.side === "LEFT" ? "LEFT" : "RIGHT",
303 commitId: c.commit_id ?? c.original_commit_id ?? null,
304 inReplyTo: c.in_reply_to_id ? `gh-rc${c.in_reply_to_id}` : null,
305 };
306}
307
308export function mapMilestone(m: GhMilestone, people: People): ImportedMilestone {
309 return {
310 number: m.number,
311 title: m.title,
312 description: m.description ?? "",
313 state: m.state === "closed" ? "closed" : "open",
314 dueOn: m.due_on ?? null,
315 createdAt: m.created_at,
316 updatedAt: m.updated_at,
317 closedAt: m.closed_at ?? null,
318 creator: m.creator ? people.login(m.creator) : null,
319 };
320}
321
322/** GitHub branch protection (REST shape) to Diffinery's rules. */
323export interface GhProtection {
324 required_status_checks?: { contexts?: string[]; checks?: Array<{ context: string }> } | null;
325 required_pull_request_reviews?: { required_approving_review_count?: number; dismiss_stale_reviews?: boolean } | null;
326 enforce_admins?: { enabled?: boolean } | null;
327 required_linear_history?: { enabled?: boolean } | null;
328 allow_force_pushes?: { enabled?: boolean } | null;
329 allow_deletions?: { enabled?: boolean } | null;
330}
331
332export function mapProtection(p: GhProtection): {
333 requirePullRequest: boolean;
334 requiredApprovals: number;
335 dismissStaleReviews: boolean;
336 requiredChecks: string[];
337 allowForcePush: boolean;
338 allowDeletion: boolean;
339 requireLinearHistory: boolean;
340 enforceAdmins: boolean;
341} {
342 const reviews = p.required_pull_request_reviews ?? null;
343 const checks = [...(p.required_status_checks?.contexts ?? []), ...(p.required_status_checks?.checks ?? []).map((c) => c.context)];
344 return {
345 requirePullRequest: !!reviews,
346 requiredApprovals: reviews?.required_approving_review_count ?? 0,
347 dismissStaleReviews: !!reviews?.dismiss_stale_reviews,
348 requiredChecks: [...new Set(checks)],
349 allowForcePush: !!p.allow_force_pushes?.enabled,
350 allowDeletion: !!p.allow_deletions?.enabled,
351 requireLinearHistory: !!p.required_linear_history?.enabled,
352 enforceAdmins: !!p.enforce_admins?.enabled,
353 };
354}