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.

357 lines · 11.4 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 /** GitHub's `base.sha` of a merged pull request: the base branch's commit its commits and files are counted from. */
178 baseSha: string | null;
179 requestedReviewers: string[];
180 };
181}
182
183export interface ImportedComment {
184 id: string;
185 number: number;
186 author: Person;
187 body: string;
188 createdAt: string;
189 updatedAt: string;
190 reviewId?: string | null;
191 path?: string | null;
192 line?: number | null;
193 side?: "LEFT" | "RIGHT" | null;
194 commitId?: string | null;
195 inReplyTo?: string | null;
196}
197
198export interface ImportedReview {
199 id: string;
200 number: number;
201 author: Person;
202 state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED";
203 body: string;
204 commitId: string;
205 submittedAt: string;
206}
207
208export interface ImportedMilestone {
209 number: number;
210 title: string;
211 description: string;
212 state: "open" | "closed";
213 dueOn: string | null;
214 createdAt: string;
215 updatedAt: string;
216 closedAt: string | null;
217 creator: string | null;
218}
219
220const labelName = (l: GhLabel | string) => (typeof l === "string" ? l : l.name);
221
222const STATE_REASONS = new Set(["completed", "not_planned", "reopened", "duplicate"]);
223
224export function mapIssue(i: GhIssue, people: People): ImportedIssue {
225 return {
226 number: i.number,
227 kind: "issue",
228 title: i.title || `Issue #${i.number}`,
229 body: i.body ?? "",
230 state: i.state === "closed" ? "closed" : "open",
231 stateReason: i.state === "closed" ? (i.state_reason && STATE_REASONS.has(i.state_reason) ? i.state_reason : "completed") : null,
232 author: people.person(i.user),
233 assignees: (i.assignees ?? []).map((a) => people.login(a)),
234 labels: (i.labels ?? []).map(labelName).filter(Boolean),
235 milestone: i.milestone?.number ?? null,
236 locked: !!i.locked,
237 createdAt: i.created_at,
238 updatedAt: i.updated_at,
239 closedAt: i.state === "closed" ? (i.closed_at ?? i.updated_at) : null,
240 };
241}
242
243/**
244 * A pull request keeps its number, state and history. Same-repository heads keep
245 * their branch name; heads in forks have no branch here, so they are kept as
246 * "fork-owner:branch" (never a valid local branch, so pushes never move them)
247 * with the head commit pinned by `refs/pull/N/head`.
248 */
249export function mapPull(p: GhPull, sourceFullName: string, people: People): ImportedIssue {
250 const merged = !!p.merged_at;
251 const sameRepo = p.head.repo?.full_name?.toLowerCase() === sourceFullName.toLowerCase();
252 const forkOwner = p.head.label?.split(":")[0] ?? p.head.repo?.full_name?.split("/")[0] ?? "unknown";
253 return {
254 ...mapIssue(p, people),
255 kind: "pull",
256 title: p.title || `Pull request #${p.number}`,
257 stateReason: p.state === "closed" ? (merged ? "merged" : null) : null,
258 pull: {
259 headRef: sameRepo ? p.head.ref : `${forkOwner}:${p.head.ref}`,
260 headRepo: sameRepo ? null : (p.head.repo?.full_name ?? `${forkOwner}/unknown`),
261 headSha: p.head.sha,
262 baseRef: p.base.ref,
263 draft: !!p.draft,
264 merged,
265 mergedAt: p.merged_at ?? null,
266 mergedBy: merged && p.merged_by ? people.login(p.merged_by) : null,
267 mergeCommit: merged ? (p.merge_commit_sha ?? null) : null,
268 baseSha: merged ? (p.base.sha ?? null) : null,
269 requestedReviewers: p.state === "open" ? (p.requested_reviewers ?? []).map((r) => people.login(r)) : [],
270 },
271 };
272}
273
274/** The issue or pull request number a comment belongs to, from its API URL. */
275export function numberFromUrl(url: string | undefined): number | null {
276 const m = /\/(?:issues|pulls)\/(\d+)$/.exec(url ?? "");
277 return m ? Number(m[1]) : null;
278}
279
280export function mapIssueComment(c: GhComment, people: People): ImportedComment | null {
281 const number = numberFromUrl(c.issue_url);
282 if (!number) return null;
283 return { id: `gh-ic${c.id}`, number, author: people.person(c.user), body: c.body ?? "", createdAt: c.created_at, updatedAt: c.updated_at };
284}
285
286export function mapReview(r: GhReview, number: number, people: People): ImportedReview | null {
287 if (!r.submitted_at || r.state === "PENDING") return null;
288 const state = (["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"] as const).find((s) => s === r.state) ?? "COMMENTED";
289 return { id: `gh-r${r.id}`, number, author: people.person(r.user), state, body: r.body ?? "", commitId: r.commit_id ?? "", submittedAt: r.submitted_at };
290}
291
292export function mapReviewComment(c: GhReviewComment, people: People): ImportedComment | null {
293 const number = numberFromUrl(c.pull_request_url);
294 if (!number) return null;
295 return {
296 id: `gh-rc${c.id}`,
297 number,
298 author: people.person(c.user),
299 body: c.body ?? "",
300 createdAt: c.created_at,
301 updatedAt: c.updated_at,
302 reviewId: c.pull_request_review_id ? `gh-r${c.pull_request_review_id}` : null,
303 path: c.path,
304 line: c.line ?? c.original_line ?? null,
305 side: c.side === "LEFT" ? "LEFT" : "RIGHT",
306 commitId: c.commit_id ?? c.original_commit_id ?? null,
307 inReplyTo: c.in_reply_to_id ? `gh-rc${c.in_reply_to_id}` : null,
308 };
309}
310
311export function mapMilestone(m: GhMilestone, people: People): ImportedMilestone {
312 return {
313 number: m.number,
314 title: m.title,
315 description: m.description ?? "",
316 state: m.state === "closed" ? "closed" : "open",
317 dueOn: m.due_on ?? null,
318 createdAt: m.created_at,
319 updatedAt: m.updated_at,
320 closedAt: m.closed_at ?? null,
321 creator: m.creator ? people.login(m.creator) : null,
322 };
323}
324
325/** GitHub branch protection (REST shape) to Diffinery's rules. */
326export interface GhProtection {
327 required_status_checks?: { contexts?: string[]; checks?: Array<{ context: string }> } | null;
328 required_pull_request_reviews?: { required_approving_review_count?: number; dismiss_stale_reviews?: boolean } | null;
329 enforce_admins?: { enabled?: boolean } | null;
330 required_linear_history?: { enabled?: boolean } | null;
331 allow_force_pushes?: { enabled?: boolean } | null;
332 allow_deletions?: { enabled?: boolean } | null;
333}
334
335export function mapProtection(p: GhProtection): {
336 requirePullRequest: boolean;
337 requiredApprovals: number;
338 dismissStaleReviews: boolean;
339 requiredChecks: string[];
340 allowForcePush: boolean;
341 allowDeletion: boolean;
342 requireLinearHistory: boolean;
343 enforceAdmins: boolean;
344} {
345 const reviews = p.required_pull_request_reviews ?? null;
346 const checks = [...(p.required_status_checks?.contexts ?? []), ...(p.required_status_checks?.checks ?? []).map((c) => c.context)];
347 return {
348 requirePullRequest: !!reviews,
349 requiredApprovals: reviews?.required_approving_review_count ?? 0,
350 dismissStaleReviews: !!reviews?.dismiss_stale_reviews,
351 requiredChecks: [...new Set(checks)],
352 allowForcePush: !!p.allow_force_pushes?.enabled,
353 allowDeletion: !!p.allow_deletions?.enabled,
354 requireLinearHistory: !!p.required_linear_history?.enabled,
355 enforceAdmins: !!p.enforce_admins?.enabled,
356 };
357}