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.

861 lines · 34.4 KBRaw History
1/**
2 * GitHub REST v3 JSON shapes and pagination, shared by every API route.
3 *
4 * Octokit, `gh api`, `actions/github-script` and marketplace actions read
5 * fields like `node_id`, `url`, `html_url`, full `user` objects and `Link`
6 * headers, so responses carry them alongside Diffinery's own fields. The
7 * builders take the instance's public URL (no Worker context) so the
8 * repository Durable Object can use them for webhook payloads too.
9 */
10import type { Context } from "hono";
11import { b64, HttpError } from "../lib/util.js";
12import { atLeast, type Role } from "../repo/types.js";
13
14export const GITHUB_DOCS = "https://docs.github.com/rest";
15
16/** PUBLIC_URL without a trailing slash. */
17export const baseOf = (c: { env: { PUBLIC_URL: string } }): string => c.env.PUBLIC_URL.replace(/\/$/, "");
18export const apiRoot = (base: string): string => `${base}/api/v3`;
19export const repoApi = (base: string, owner: string, repo: string): string => `${base}/api/v3/repos/${owner}/${repo}`;
20export const repoHtml = (base: string, owner: string, repo: string): string => `${base}/${owner}/${repo}`;
21export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
22
23/** Opaque GraphQL-style node id, stable for a type and id. */
24export const nodeId = (type: string, id: string | number): string => b64(new TextEncoder().encode(`${type}:${id}`)).replace(/=+$/, "");
25
26/** ISO 8601 without milliseconds, like GitHub. */
27export const isoSeconds = (iso: string | null | undefined): string | null => (iso ? iso.replace(/\.\d{3}Z$/, "Z") : null);
28export const unixIso = (seconds: number): string => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
29
30// ---------------------------------------------------------------- users and repositories
31
32/**
33 * A stable positive integer for an internal string id (two FNV-1a hashes, 53
34 * bits), so users and repositories get numeric `id`s like GitHub's. Internal
35 * ids stay strings; nothing looks an account or repository up by this number.
36 */
37export function numericId(s: string): number {
38 let a = 0x811c9dc5;
39 let b = 0x01000193 ^ 0x5bd1e995;
40 for (let i = 0; i < s.length; i++) {
41 const ch = s.charCodeAt(i);
42 a = Math.imul(a ^ ch, 0x01000193);
43 b = Math.imul(b ^ ch, 0x5bd1e995);
44 b ^= b >>> 13;
45 }
46 return ((a >>> 0) % 0x200000) * 0x100000000 + (b >>> 0) || 1;
47}
48
49/**
50 * GitHub's numeric id for a workflow run or job, whose Diffinery ids are UUIDs written as 32 hex
51 * digits. It keeps 52 of the UUID's random bits (the version digit skipped) above 10^15, so it
52 * always has 16 digits: shorter numbers stay run numbers (`github.run_id`, `diffinery run view 42`),
53 * and `uuidPattern` turns it back into a lookup. Anything else gets `numericId`'s hash.
54 */
55export function uuidNumber(id: string): number {
56 if (!/^[0-9a-f]{32}$/.test(id)) return numericId(id);
57 return UUID_NUMBERS + parseInt(id.slice(0, 12) + id[13], 16);
58}
59
60/** The SQLite GLOB that matches the ids `uuidNumber` maps to `raw`, or null when `raw` isn't such a number. */
61export function uuidPattern(raw: string): string | null {
62 if (!/^\d{16}$/.test(raw)) return null;
63 const n = Number(raw) - UUID_NUMBERS;
64 if (n < 0 || n >= 2 ** 52) return null;
65 const h = n.toString(16).padStart(13, "0");
66 return `${h.slice(0, 12)}?${h[12]}*`;
67}
68
69const UUID_NUMBERS = 1e15;
70
71/** A workflow's numeric id (GitHub's `workflow_id`): a hash of its path, which the API also takes. */
72export function workflowNumber(path: string): number {
73 return numericId(`workflow:${path}`);
74}
75
76export type Who = { id: string; login: string; kind?: string | null };
77
78export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User");
79
80export function ghUser(base: string, u: Who | null | undefined) {
81 if (!u) return null;
82 // People imported from GitHub without a Diffinery account are mannequins: login "github:<login>",
83 // type "Mannequin", pointing at their GitHub profile. They are clearly not Diffinery accounts.
84 if (u.kind === "github" || u.login.startsWith("github:")) {
85 const login = u.login.replace(/^github:/, "");
86 return {
87 login: `github:${login}`,
88 id: numericId(u.id),
89 node_id: nodeId("Mannequin", u.id),
90 avatar_url: `${base}/api/avatars/${encodeURIComponent(login)}`,
91 gravatar_id: "",
92 url: `https://github.com/${encodeURIComponent(login)}`,
93 html_url: `https://github.com/${encodeURIComponent(login)}`,
94 type: "Mannequin",
95 user_view_type: "public",
96 site_admin: false,
97 };
98 }
99 const api = `${apiRoot(base)}/users/${encodeURIComponent(u.login)}`;
100 return {
101 login: u.login,
102 id: numericId(u.id),
103 node_id: nodeId(userType(u.kind), u.id),
104 avatar_url: `${base}/api/avatars/${encodeURIComponent(u.login)}`,
105 gravatar_id: "",
106 url: api,
107 html_url: `${base}/${u.login}`,
108 repos_url: `${api}/repos`,
109 organizations_url: `${api}/orgs`,
110 type: userType(u.kind),
111 user_view_type: "public",
112 site_admin: false,
113 };
114}
115
116/** The repository row as the directory stores it (topics arrive as a JSON string). */
117export interface RepoRow {
118 id: string;
119 owner_id: string;
120 owner_login: string;
121 owner_kind: string;
122 name: string;
123 description: string;
124 visibility: "private" | "public";
125 default_branch: string;
126 archived: number;
127 created_at: string;
128 updated_at: string;
129 pushed_at: string | null;
130 topics?: string | null;
131 homepage?: string | null;
132}
133
134export function topicsOf(r: Pick<RepoRow, "topics">): string[] {
135 try {
136 const t = JSON.parse(r.topics ?? "[]");
137 return Array.isArray(t) ? t.map(String) : [];
138 } catch {
139 return [];
140 }
141}
142
143/** A repository's website (GitHub's `homepage`): "" clears it, anything else must be an http(s) URL of at most 500 characters. */
144export function normalizeHomepage(v: unknown): string | undefined {
145 if (v === undefined || v === null) return v === null ? "" : undefined;
146 const s = String(v).trim();
147 if (!s) return "";
148 let ok = false;
149 try {
150 ok = s.length <= 500 && ["http:", "https:"].includes(new URL(s).protocol);
151 } catch {
152 ok = false;
153 }
154 if (!ok) throw new HttpError(422, "homepage must be an http or https URL of at most 500 characters", { errors: [{ resource: "Repository", code: "invalid", field: "homepage" }] });
155 return s;
156}
157
158/** Lowercases, dedupes and checks a list of topics as GitHub does (422 when one is invalid or there are more than 20). */
159export function normalizeTopics(raw: unknown[]): string[] {
160 const names = [...new Set(raw.map((n) => String(n).trim().toLowerCase()).filter(Boolean))];
161 const bad = names.find((n) => !/^[a-z0-9][a-z0-9-]{0,49}$/.test(n));
162 if (bad) throw new HttpError(422, `${bad} is not a valid topic. Topics must start with a lowercase letter or number, consist of 50 characters or less, and can include hyphens.`, { errors: [{ resource: "Repository", code: "invalid", field: "names" }] });
163 if (names.length > 20) throw new HttpError(422, "a repository can have at most 20 topics");
164 return names;
165}
166
167export function permissionsFor(role: Role | null | undefined) {
168 return { admin: atLeast(role, "admin"), maintain: atLeast(role, "maintain"), push: atLeast(role, "write"), triage: atLeast(role, "triage"), pull: atLeast(role, "read") };
169}
170
171export function ghRepo(base: string, r: RepoRow, role?: Role | null, extra: { openIssues?: number } = {}) {
172 const full = `${r.owner_login}/${r.name}`;
173 const api = repoApi(base, r.owner_login, r.name);
174 const html = repoHtml(base, r.owner_login, r.name);
175 const open = extra.openIssues ?? 0;
176 return {
177 id: numericId(r.id),
178 node_id: nodeId("Repository", r.id),
179 name: r.name,
180 full_name: full,
181 private: r.visibility !== "public",
182 owner: ghUser(base, { id: r.owner_id, login: r.owner_login, kind: r.owner_kind }),
183 html_url: html,
184 description: r.description || null,
185 fork: false,
186 url: api,
187 archive_url: `${api}/{archive_format}{/ref}`,
188 branches_url: `${api}/branches{/branch}`,
189 commits_url: `${api}/commits{/sha}`,
190 compare_url: `${api}/compare/{base}...{head}`,
191 contents_url: `${api}/contents/{+path}`,
192 deployments_url: `${api}/deployments`,
193 git_refs_url: `${api}/git/refs{/sha}`,
194 hooks_url: `${api}/hooks`,
195 issues_url: `${api}/issues{/number}`,
196 labels_url: `${api}/labels{/name}`,
197 languages_url: `${api}/languages`,
198 merges_url: `${api}/merges`,
199 pulls_url: `${api}/pulls{/number}`,
200 releases_url: `${api}/releases{/id}`,
201 statuses_url: `${api}/statuses/{sha}`,
202 tags_url: `${api}/tags`,
203 git_url: `${html}.git`,
204 ssh_url: `${html}.git`,
205 clone_url: `${html}.git`,
206 svn_url: html,
207 homepage: r.homepage || null,
208 size: 0,
209 stargazers_count: 0,
210 watchers_count: 0,
211 language: null,
212 has_issues: true,
213 has_projects: false,
214 has_downloads: true,
215 has_wiki: false,
216 has_pages: false,
217 has_discussions: false,
218 forks_count: 0,
219 mirror_url: null,
220 archived: !!r.archived,
221 disabled: false,
222 open_issues_count: open,
223 license: null,
224 allow_forking: false,
225 is_template: false,
226 web_commit_signoff_required: false,
227 topics: topicsOf(r),
228 visibility: r.visibility,
229 forks: 0,
230 open_issues: open,
231 watchers: 0,
232 default_branch: r.default_branch,
233 created_at: isoSeconds(r.created_at),
234 updated_at: isoSeconds(r.updated_at),
235 pushed_at: isoSeconds(r.pushed_at),
236 ...(role ? { permissions: { ...permissionsFor(role), role } } : {}),
237 };
238}
239
240// ---------------------------------------------------------------- labels, reactions, issues
241
242export type LabelRow = { id: number; name: string; color: string; description: string };
243
244export function ghLabel(base: string, owner: string, repo: string, l: Pick<LabelRow, "name"> & Partial<LabelRow>) {
245 return {
246 id: l.id ?? 0,
247 node_id: nodeId("Label", `${owner}/${repo}/${l.name}`),
248 url: `${repoApi(base, owner, repo)}/labels/${encodeURIComponent(l.name)}`,
249 name: l.name,
250 color: l.color ?? "ededed",
251 default: false,
252 description: l.description || null,
253 };
254}
255
256export const REACTIONS = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] as const;
257export type ReactionContent = (typeof REACTIONS)[number];
258export type ReactionCounts = Partial<Record<ReactionContent, number>>;
259
260export function ghReactions(url: string, counts: ReactionCounts | undefined) {
261 const out: Record<string, number | string> = { url, total_count: 0 };
262 let total = 0;
263 for (const r of REACTIONS) {
264 out[r] = counts?.[r] ?? 0;
265 total += counts?.[r] ?? 0;
266 }
267 out.total_count = total;
268 return out;
269}
270
271/** Issue and pull request fields as `../repo/collab.ts` returns them (plus `locked`). */
272export interface IssueLike {
273 number: number;
274 kind: "issue" | "pull";
275 title: string;
276 body: string;
277 state: "open" | "closed";
278 stateReason: string | null;
279 author: { id: string; login: string; kind: string };
280 assignees: string[];
281 labels: string[];
282 createdAt: string;
283 updatedAt: string;
284 closedAt: string | null;
285 comments: number;
286 locked?: boolean;
287 lockReason?: string | null;
288 milestone?: { number: number; title: string; state: "open" | "closed"; dueOn: string | null } | null;
289 pull?: {
290 head: string;
291 headSha: string;
292 base: string;
293 draft: boolean;
294 merged: boolean;
295 mergedAt: string | null;
296 mergedBy: string | null;
297 mergeCommit: string | null;
298 requestedReviewers: string[];
299 intent: string | null;
300 autoMerge?: AutoMergeLike | null;
301 };
302}
303
304/** Auto-merge as `../repo/auto-merge.ts` reports it. */
305export type AutoMergeLike = { method: string; commitTitle: string | null; commitMessage: string | null; enabledBy: Who; deleteBranch?: boolean; byRule?: string; byDefault?: boolean };
306
307/** GitHub's `auto_merge` object of a pull request (null while auto-merge is off). */
308export function ghAutoMerge(base: string, a: AutoMergeLike | null | undefined) {
309 // Diffinery's own: delete_branch (the head branch is deleted once it lands), and what turned it on by itself:
310 // by_rule (a branch rule's agents) or by_default (the author's own setting, accounts.auto_merge).
311 return a
312 ? { enabled_by: ghUser(base, a.enabledBy), merge_method: a.method, commit_title: a.commitTitle, commit_message: a.commitMessage, delete_branch: !!a.deleteBranch, ...(a.byRule ? { by_rule: a.byRule } : {}), ...(a.byDefault ? { by_default: true } : {}) }
313 : null;
314}
315
316/** Per-request lookups the issue builders need (labels by name, people by login, reactions by subject). */
317export interface IssueCtx {
318 base: string;
319 owner: string;
320 repo: string;
321 repoId: string;
322 labels?: Map<string, LabelRow>;
323 people?: Map<string, Who>;
324 reactions?: Record<string, ReactionCounts>;
325 lockReasons?: Record<number, string | null>;
326}
327
328const personByLogin = (x: IssueCtx, login: string): Who => x.people?.get(login.toLowerCase()) ?? { id: login, login, kind: "user" };
329
330/** A GitHub-shaped milestone object for embedding in an issue or pull request. */
331function ghIssueMilestone(x: IssueCtx, m: { number: number; title: string; state: "open" | "closed"; dueOn: string | null }) {
332 const api = repoApi(x.base, x.owner, x.repo);
333 return { url: `${api}/milestones/${m.number}`, html_url: `${repoHtml(x.base, x.owner, x.repo)}/milestone/${m.number}`, id: m.number, number: m.number, title: m.title, description: null, state: m.state, due_on: m.dueOn };
334}
335const association = (x: IssueCtx, login: string) => (login.toLowerCase() === x.owner.toLowerCase() ? "OWNER" : "NONE");
336
337export function ghIssue(x: IssueCtx, i: IssueLike) {
338 const api = repoApi(x.base, x.owner, x.repo);
339 const html = repoHtml(x.base, x.owner, x.repo);
340 const url = `${api}/issues/${i.number}`;
341 const assignees = i.assignees.map((l) => ghUser(x.base, personByLogin(x, l)));
342 const merged = !!i.pull?.merged;
343 return {
344 url,
345 repository_url: api,
346 labels_url: `${url}/labels{/name}`,
347 comments_url: `${url}/comments`,
348 events_url: `${url}/events`,
349 html_url: `${html}/${i.kind === "pull" ? "pull" : "issues"}/${i.number}`,
350 id: i.number,
351 node_id: nodeId(i.kind === "pull" ? "PullRequest" : "Issue", `${x.repoId}/${i.number}`),
352 number: i.number,
353 title: i.title,
354 user: ghUser(x.base, i.author),
355 labels: i.labels.map((name) => ghLabel(x.base, x.owner, x.repo, x.labels?.get(name) ?? { name })),
356 state: merged ? "closed" : i.state,
357 locked: !!i.locked,
358 assignee: assignees[0] ?? null,
359 assignees,
360 milestone: i.milestone ? ghIssueMilestone(x, i.milestone) : null,
361 comments: i.comments,
362 created_at: isoSeconds(i.createdAt),
363 updated_at: isoSeconds(i.updatedAt),
364 closed_at: isoSeconds(i.closedAt),
365 closed_by: null,
366 author_association: association(x, i.author.login),
367 active_lock_reason: i.locked ? (i.lockReason ?? x.lockReasons?.[i.number] ?? null) : null,
368 body: i.body || null,
369 reactions: ghReactions(`${url}/reactions`, x.reactions?.[`issue:${i.number}`]),
370 timeline_url: `${url}/timeline`,
371 performed_via_github_app: null,
372 state_reason: i.stateReason,
373 ...(i.pull
374 ? {
375 draft: i.pull.draft,
376 pull_request: { url: `${api}/pulls/${i.number}`, html_url: `${html}/pull/${i.number}`, diff_url: `${html}/pull/${i.number}.diff`, patch_url: `${html}/pull/${i.number}.patch`, merged_at: isoSeconds(i.pull.mergedAt) },
377 }
378 : {}),
379 };
380}
381
382/** A pull request; `repoJson` is the full repository object used for `head.repo` and `base.repo`. */
383export function ghPull(x: IssueCtx, i: IssueLike, repoJson: unknown, owner: Who, baseSha: string | null) {
384 const p = i.pull!;
385 const api = repoApi(x.base, x.owner, x.repo);
386 const html = repoHtml(x.base, x.owner, x.repo);
387 const url = `${api}/pulls/${i.number}`;
388 const issue = ghIssue(x, i);
389 const ownerJson = ghUser(x.base, owner);
390 return {
391 url,
392 id: i.number,
393 node_id: issue.node_id,
394 html_url: `${html}/pull/${i.number}`,
395 diff_url: `${html}/pull/${i.number}.diff`,
396 patch_url: `${html}/pull/${i.number}.patch`,
397 issue_url: `${api}/issues/${i.number}`,
398 commits_url: `${url}/commits`,
399 review_comments_url: `${url}/comments`,
400 review_comment_url: `${api}/pulls/comments{/number}`,
401 comments_url: `${api}/issues/${i.number}/comments`,
402 statuses_url: `${api}/statuses/${p.headSha}`,
403 number: i.number,
404 state: issue.state,
405 state_reason: i.stateReason,
406 locked: issue.locked,
407 title: i.title,
408 user: issue.user,
409 body: issue.body,
410 labels: issue.labels,
411 milestone: i.milestone ? ghIssueMilestone(x, i.milestone) : null,
412 active_lock_reason: issue.active_lock_reason,
413 created_at: issue.created_at,
414 updated_at: issue.updated_at,
415 closed_at: issue.closed_at,
416 merged_at: isoSeconds(p.mergedAt),
417 merge_commit_sha: p.mergeCommit,
418 assignee: issue.assignee,
419 assignees: issue.assignees,
420 requested_reviewers: p.requestedReviewers.map((l) => ghUser(x.base, personByLogin(x, l))),
421 requested_teams: [],
422 head: { label: `${x.owner}:${p.head}`, ref: p.head, sha: p.headSha, user: ownerJson, repo: repoJson },
423 base: { label: `${x.owner}:${p.base}`, ref: p.base, sha: baseSha, user: ownerJson, repo: repoJson },
424 _links: {
425 self: { href: url },
426 html: { href: `${html}/pull/${i.number}` },
427 issue: { href: `${api}/issues/${i.number}` },
428 comments: { href: `${api}/issues/${i.number}/comments` },
429 review_comments: { href: `${url}/comments` },
430 review_comment: { href: `${api}/pulls/comments{/number}` },
431 commits: { href: `${url}/commits` },
432 statuses: { href: `${api}/statuses/${p.headSha}` },
433 },
434 author_association: issue.author_association,
435 auto_merge: ghAutoMerge(x.base, p.autoMerge),
436 draft: p.draft,
437 merged: p.merged,
438 merged_by: p.mergedBy ? ghUser(x.base, personByLogin(x, p.mergedBy)) : null,
439 comments: i.comments,
440 maintainer_can_modify: false,
441 // Diffinery: the change's purpose, written by agents for reviewers.
442 intent: p.intent,
443 pull_request: { url },
444 };
445}
446
447export type CommentLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; body: string; createdAt: string; updatedAt: string };
448
449export function ghComment(x: IssueCtx, number: number, isPull: boolean, cm: CommentLike) {
450 const api = repoApi(x.base, x.owner, x.repo);
451 const id = cm.seq ?? cm.id;
452 const url = `${api}/issues/comments/${id}`;
453 return {
454 url,
455 html_url: `${repoHtml(x.base, x.owner, x.repo)}/${isPull ? "pull" : "issues"}/${number}#issuecomment-${cm.id}`,
456 issue_url: `${api}/issues/${number}`,
457 id,
458 node_id: nodeId("IssueComment", `${x.repoId}/${cm.id}`),
459 user: ghUser(x.base, cm.author),
460 created_at: isoSeconds(cm.createdAt),
461 updated_at: isoSeconds(cm.updatedAt),
462 author_association: association(x, cm.author.login),
463 body: cm.body,
464 reactions: ghReactions(`${url}/reactions`, x.reactions?.[`comment:${cm.id}`]),
465 performed_via_github_app: null,
466 };
467}
468
469export type ReviewCommentLike = CommentLike & { reviewId: string | null; reviewSeq?: number | null; path: string | null; line: number | null; side: string | null; commitId: string | null; inReplyTo: string | null; inReplyToSeq?: number | null; resolved: boolean };
470
471export function ghReviewComment(x: IssueCtx, number: number, cm: ReviewCommentLike) {
472 const api = repoApi(x.base, x.owner, x.repo);
473 const html = `${repoHtml(x.base, x.owner, x.repo)}/pull/${number}`;
474 const id = cm.seq ?? cm.id;
475 const url = `${api}/pulls/comments/${id}`;
476 return {
477 url,
478 pull_request_review_id: cm.reviewSeq ?? cm.reviewId,
479 id,
480 node_id: nodeId("PullRequestReviewComment", `${x.repoId}/${cm.id}`),
481 diff_hunk: "",
482 path: cm.path,
483 position: cm.line,
484 original_position: cm.line,
485 commit_id: cm.commitId,
486 original_commit_id: cm.commitId,
487 in_reply_to_id: cm.inReplyTo ? (cm.inReplyToSeq ?? cm.inReplyTo) : undefined,
488 user: ghUser(x.base, cm.author),
489 body: cm.body,
490 created_at: isoSeconds(cm.createdAt),
491 updated_at: isoSeconds(cm.updatedAt),
492 html_url: `${html}#discussion_r${id}`,
493 pull_request_url: `${api}/pulls/${number}`,
494 author_association: association(x, cm.author.login),
495 _links: { self: { href: url }, html: { href: `${html}#discussion_r${id}` }, pull_request: { href: `${api}/pulls/${number}` } },
496 start_line: null,
497 original_start_line: null,
498 start_side: null,
499 line: cm.line,
500 original_line: cm.line,
501 side: cm.side ?? "RIGHT",
502 subject_type: "line",
503 reactions: ghReactions(`${url}/reactions`, x.reactions?.[`review_comment:${cm.id}`]),
504 // Diffinery: whether the thread was resolved.
505 resolved: cm.resolved,
506 };
507}
508
509export type ReviewLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; state: string; body: string; commitId: string; submittedAt: string };
510
511export function ghReview(x: IssueCtx, number: number, r: ReviewLike) {
512 const api = repoApi(x.base, x.owner, x.repo);
513 const html = `${repoHtml(x.base, x.owner, x.repo)}/pull/${number}#pullrequestreview-${r.seq ?? r.id}`;
514 return {
515 id: r.seq ?? r.id,
516 node_id: nodeId("PullRequestReview", `${x.repoId}/${r.id}`),
517 user: ghUser(x.base, r.author),
518 body: r.body,
519 state: r.state,
520 html_url: html,
521 pull_request_url: `${api}/pulls/${number}`,
522 author_association: association(x, r.author.login),
523 _links: { html: { href: html }, pull_request: { href: `${api}/pulls/${number}` } },
524 submitted_at: isoSeconds(r.submittedAt),
525 commit_id: r.commitId,
526 };
527}
528
529// ---------------------------------------------------------------- commits and files
530
531export type Sig = { name: string; email: string; timestamp: number; timezone: string };
532export type CommitLike = { sha: string; tree: string; parents: string[]; author: Sig; committer: Sig; message: string; signed: boolean };
533
534export const sigJson = (s: Sig) => ({ name: s.name, email: s.email, date: unixIso(s.timestamp) });
535
536/** Verification block; Diffinery does not verify signatures yet. */
537export const verification = (signed: boolean) => ({ verified: false, reason: signed ? "unknown_key" : "unsigned", signature: null, payload: null, verified_at: null });
538
539export function ghCommit(base: string, owner: string, repo: string, c: CommitLike, people?: Map<string, Who>) {
540 const api = repoApi(base, owner, repo);
541 const html = repoHtml(base, owner, repo);
542 const who = (s: Sig) => people?.get(s.email.toLowerCase()) ?? null;
543 return {
544 sha: c.sha,
545 node_id: nodeId("Commit", c.sha),
546 commit: {
547 author: sigJson(c.author),
548 committer: sigJson(c.committer),
549 message: c.message.replace(/\n$/, ""),
550 tree: { sha: c.tree, url: `${api}/git/trees/${c.tree}` },
551 url: `${api}/git/commits/${c.sha}`,
552 comment_count: 0,
553 verification: verification(c.signed),
554 },
555 url: `${api}/commits/${c.sha}`,
556 html_url: `${html}/commit/${c.sha}`,
557 comments_url: `${api}/commits/${c.sha}/comments`,
558 author: ghUser(base, who(c.author)),
559 committer: ghUser(base, who(c.committer)),
560 parents: c.parents.map((p) => ({ sha: p, url: `${api}/commits/${p}`, html_url: `${html}/commit/${p}` })),
561 };
562}
563
564/** A reference (GET /git/ref/:ref): `type` is the kind of object it points at. */
565export function ghRef(base: string, owner: string, repo: string, r: { ref: string; sha: string; type: string }) {
566 const api = repoApi(base, owner, repo);
567 const kind = r.type === "tag" ? "tags" : r.type === "tree" ? "trees" : r.type === "blob" ? "blobs" : "commits";
568 return { ref: r.ref, node_id: nodeId("Ref", `${owner}/${repo}/${r.ref}`), url: `${api}/git/${r.ref}`, object: { sha: r.sha, type: r.type, url: `${api}/git/${kind}/${r.sha}` } };
569}
570
571/** A commit as the Git data API returns it (GET /git/commits/:sha). */
572export function ghGitCommit(base: string, owner: string, repo: string, c: CommitLike) {
573 const api = repoApi(base, owner, repo);
574 const html = repoHtml(base, owner, repo);
575 return {
576 sha: c.sha,
577 node_id: nodeId("Commit", c.sha),
578 url: `${api}/git/commits/${c.sha}`,
579 html_url: `${html}/commit/${c.sha}`,
580 author: sigJson(c.author),
581 committer: sigJson(c.committer),
582 message: c.message,
583 tree: { sha: c.tree, url: `${api}/git/trees/${c.tree}` },
584 parents: c.parents.map((p) => ({ sha: p, url: `${api}/git/commits/${p}`, html_url: `${html}/commit/${p}` })),
585 verification: verification(c.signed),
586 };
587}
588
589export type FileLike = { path: string; status: string; oldOid?: string; newOid?: string; additions: number; deletions: number; patch?: string; binary?: boolean };
590
591export function ghFile(base: string, owner: string, repo: string, ref: string, f: FileLike) {
592 const api = repoApi(base, owner, repo);
593 const html = repoHtml(base, owner, repo);
594 return {
595 sha: f.newOid ?? f.oldOid ?? null,
596 filename: f.path,
597 status: f.status === "deleted" ? "removed" : f.status,
598 additions: f.additions,
599 deletions: f.deletions,
600 changes: f.additions + f.deletions,
601 blob_url: `${html}/blob/${ref}/${encPath(f.path)}`,
602 raw_url: `${html}/raw/${ref}/${encPath(f.path)}`,
603 contents_url: `${api}/contents/${encPath(f.path)}?ref=${ref}`,
604 patch: f.binary ? undefined : f.patch,
605 };
606}
607
608// ---------------------------------------------------------------- checks
609
610export function ghCheckRun(base: string, owner: string, repo: string, r: { id: string; seq?: number | null; sha: string; name: string; status: string; conclusion: string | null; runId: string | null; jobId: string | null; detailsUrl: string | null; output: Record<string, unknown> | null; app: string; startedAt: string | null; completedAt: string | null }) {
611 const api = repoApi(base, owner, repo);
612 const id = r.seq ?? r.id;
613 const out = (r.output ?? {}) as { title?: string; summary?: string; text?: string; annotations?: unknown[]; external_id?: string };
614 return {
615 id,
616 head_sha: r.sha,
617 node_id: nodeId("CheckRun", r.id),
618 external_id: out.external_id ?? "",
619 url: `${api}/check-runs/${id}`,
620 html_url: r.detailsUrl,
621 details_url: r.detailsUrl,
622 status: r.status,
623 conclusion: r.conclusion,
624 started_at: isoSeconds(r.startedAt),
625 completed_at: isoSeconds(r.completedAt),
626 output: { title: out.title ?? null, summary: out.summary ?? null, text: out.text ?? null, annotations_count: out.annotations?.length ?? 0, annotations_url: `${api}/check-runs/${id}/annotations` },
627 name: r.name,
628 check_suite: { id: r.runId ? uuidNumber(r.runId) : numericId(`app-${r.app}-${r.sha}`) },
629 app: { id: r.app, slug: r.app, node_id: nodeId("App", r.app), name: r.app, owner: null },
630 pull_requests: [],
631 // Diffinery: the workflow run and job behind an Actions check.
632 run_id: r.runId && uuidNumber(r.runId),
633 job_id: r.jobId && uuidNumber(r.jobId),
634 };
635}
636
637// ---------------------------------------------------------------- Actions runs and jobs
638
639export type RunLike = {
640 id: string;
641 number: number;
642 attempt: number;
643 workflowPath: string;
644 workflowName: string;
645 title: string;
646 event: string;
647 ref: string;
648 sha: string;
649 headSha: string;
650 headBranch: string;
651 actor: Who;
652 status: string;
653 conclusion: string | null;
654 createdAt: string;
655 updatedAt: string;
656 prNumber: number | null;
657};
658
659/**
660 * A workflow run: GitHub's fields plus Diffinery's `sha`/`ref` (what the run checked out) and `pull_request_number`.
661 * Its `id`, `workflow_id` and `check_suite_id` are numbers, as clients like gh expect; the API takes them back too.
662 */
663export function ghRun(base: string, owner: string, repo: string, r: RunLike) {
664 const api = repoApi(base, owner, repo);
665 const id = uuidNumber(r.id);
666 const url = `${api}/actions/runs/${id}`;
667 return {
668 id,
669 node_id: nodeId("WorkflowRun", r.id),
670 run_number: r.number,
671 run_attempt: r.attempt,
672 name: r.workflowName,
673 display_title: r.title,
674 path: r.workflowPath,
675 workflow_id: workflowNumber(r.workflowPath),
676 check_suite_id: id,
677 event: r.event,
678 head_branch: r.headBranch,
679 head_sha: r.headSha,
680 // The commit the run checked out (GITHUB_SHA): the test merge commit for pull_request runs.
681 sha: r.sha,
682 ref: r.ref,
683 status: r.status,
684 conclusion: r.conclusion,
685 actor: ghUser(base, r.actor),
686 triggering_actor: ghUser(base, r.actor),
687 created_at: isoSeconds(r.createdAt),
688 updated_at: isoSeconds(r.updatedAt),
689 run_started_at: isoSeconds(r.createdAt),
690 url,
691 html_url: `${repoHtml(base, owner, repo)}/actions/runs/${r.id}`,
692 jobs_url: `${url}/jobs`,
693 logs_url: `${url}/logs`,
694 artifacts_url: `${url}/artifacts`,
695 cancel_url: `${url}/cancel`,
696 rerun_url: `${url}/rerun`,
697 workflow_url: `${api}/actions/workflows/${workflowNumber(r.workflowPath)}`,
698 previous_attempt_url: r.attempt > 1 ? `${url}/attempts/${r.attempt - 1}` : null,
699 pull_requests: r.prNumber ? [{ number: r.prNumber, url: `${api}/pulls/${r.prNumber}` }] : [],
700 pull_request_number: r.prNumber,
701 };
702}
703
704export type JobLike = {
705 id: string;
706 runId: string;
707 carriedFrom?: string | null;
708 name: string;
709 status: string;
710 conclusion: string | null;
711 startedAt: string | null;
712 completedAt: string | null;
713 runnerName?: string | null;
714 runnerSize?: string | null;
715 steps: Array<{ index: number; name: string; status: string; conclusion?: string | null; startedAt?: string; completedAt?: string }>;
716};
717
718/**
719 * A job: GitHub's snake_case fields next to Diffinery's own (camelCase) ones, which the CLI and web UI read.
720 * `id`, `run_id` and `carriedFrom` are numbers like GitHub's ids (`runId` stays Diffinery's own id).
721 */
722export function ghJob<J extends JobLike>(base: string, owner: string, repo: string, run: RunLike, j: J) {
723 const api = repoApi(base, owner, repo);
724 const id = uuidNumber(j.id);
725 const runId = uuidNumber(j.runId);
726 return {
727 ...j,
728 id,
729 carriedFrom: j.carriedFrom ? uuidNumber(j.carriedFrom) : null,
730 // A job held by its environment's reviewers or wait timer is "waiting", as on GitHub.
731 status: j.status === "approval" ? "waiting" : j.status,
732 run_id: runId,
733 run_url: `${api}/actions/runs/${runId}`,
734 run_attempt: run.attempt,
735 node_id: nodeId("Job", j.id),
736 head_sha: run.headSha,
737 head_branch: run.headBranch,
738 url: `${api}/actions/jobs/${id}`,
739 html_url: `${repoHtml(base, owner, repo)}/actions/runs/${j.runId}/job/${j.id}`,
740 started_at: isoSeconds(j.startedAt),
741 completed_at: isoSeconds(j.completedAt),
742 workflow_name: run.workflowName,
743 check_run_url: `${api}/check-runs/${id}`,
744 labels: [] as string[],
745 runner_name: j.runnerName ?? null,
746 // Hosted runner size (large, medium, small, tiny); `usage` (from ...j) is what the job used of it.
747 runner_size: j.runnerSize ?? null,
748 steps: j.steps.map((s) => ({ ...s, number: s.index + 1, started_at: isoSeconds(s.startedAt), completed_at: isoSeconds(s.completedAt), conclusion: s.conclusion ?? null })),
749 };
750}
751
752// ---------------------------------------------------------------- pagination
753
754export interface Page {
755 page: number;
756 perPage: number;
757 offset: number;
758}
759
760export function pageOf(c: Context, def = 30): Page {
761 const per = parseInt(c.req.query("per_page") ?? "", 10);
762 const perPage = Math.min(Math.max(Number.isFinite(per) && per > 0 ? per : def, 1), 100);
763 const pg = parseInt(c.req.query("page") ?? "", 10);
764 const page = Math.min(Math.max(Number.isFinite(pg) && pg > 0 ? pg : 1, 1), 100_000);
765 return { page, perPage, offset: (page - 1) * perPage };
766}
767
768/**
769 * `Link` header with first/prev/next/last, as Octokit's paginate() follows it.
770 * Pass `total` when known; otherwise `hasMore` decides whether there is a next page.
771 */
772export function setLinks(c: Context, p: Page, total: number | null, hasMore?: boolean): void {
773 const url = new URL(c.req.url);
774 const at = (n: number) => {
775 url.searchParams.set("per_page", String(p.perPage));
776 url.searchParams.set("page", String(n));
777 return url.toString();
778 };
779 const last = total === null ? null : Math.max(1, Math.ceil(total / p.perPage));
780 const more = last === null ? !!hasMore : p.page < last;
781 const links: string[] = [];
782 if (p.page > 1) links.push(`<${at(p.page - 1)}>; rel="prev"`, `<${at(1)}>; rel="first"`);
783 if (more) links.push(`<${at(p.page + 1)}>; rel="next"`);
784 if (last !== null && p.page < last) links.push(`<${at(last)}>; rel="last"`);
785 if (links.length) c.header("link", links.join(", "));
786}
787
788/** Paginate an in-memory list and set the Link header. */
789export function paginate<T>(c: Context, items: readonly T[], def = 30): T[] {
790 const p = pageOf(c, def);
791 setLinks(c, p, items.length);
792 return items.slice(p.offset, p.offset + p.perPage);
793}
794
795// ---------------------------------------------------------------- releases
796
797export type AssetLike = { id: number; name: string; label: string; contentType: string; size: number; state: string; downloadCount: number; uploader: Who | null; createdAt: string; updatedAt: string };
798export type ReleaseLike = {
799 id: number;
800 tagName: string;
801 targetCommitish: string;
802 name: string;
803 body: string;
804 draft: boolean;
805 prerelease: boolean;
806 author: Who;
807 createdAt: string;
808 publishedAt: string | null;
809 updatedAt: string;
810 latest?: boolean;
811 assets: AssetLike[];
812};
813
814export function ghAsset(base: string, owner: string, repo: string, tag: string, a: AssetLike) {
815 return {
816 url: `${repoApi(base, owner, repo)}/releases/assets/${a.id}`,
817 id: a.id,
818 node_id: nodeId("ReleaseAsset", a.id),
819 name: a.name,
820 label: a.label || null,
821 uploader: ghUser(base, a.uploader),
822 content_type: a.contentType,
823 state: a.state,
824 size: a.size,
825 digest: null,
826 download_count: a.downloadCount,
827 created_at: isoSeconds(a.createdAt),
828 updated_at: isoSeconds(a.updatedAt),
829 browser_download_url: `${repoHtml(base, owner, repo)}/releases/download/${encPath(tag)}/${encodeURIComponent(a.name)}`,
830 };
831}
832
833export function ghRelease(base: string, owner: string, repo: string, r: ReleaseLike, reactions?: ReactionCounts) {
834 const api = repoApi(base, owner, repo);
835 const url = `${api}/releases/${r.id}`;
836 return {
837 url,
838 assets_url: `${url}/assets`,
839 upload_url: `${base}/api/uploads/repos/${owner}/${repo}/releases/${r.id}/assets{?name,label}`,
840 html_url: `${repoHtml(base, owner, repo)}/releases/tag/${encPath(r.tagName)}`,
841 id: r.id,
842 author: ghUser(base, r.author),
843 node_id: nodeId("Release", r.id),
844 tag_name: r.tagName,
845 target_commitish: r.targetCommitish,
846 name: r.name || null,
847 draft: r.draft,
848 immutable: false,
849 prerelease: r.prerelease,
850 created_at: isoSeconds(r.createdAt),
851 updated_at: isoSeconds(r.updatedAt),
852 published_at: isoSeconds(r.publishedAt),
853 assets: r.assets.map((a) => ghAsset(base, owner, repo, r.tagName, a)),
854 tarball_url: `${api}/tarball/${encPath(r.tagName)}`,
855 zipball_url: `${api}/zipball/${encPath(r.tagName)}`,
856 body: r.body || null,
857 reactions: ghReactions(`${url}/reactions`, reactions),
858 // Diffinery: whether this is the repository's latest release.
859 latest: !!r.latest,
860 };
861}