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.

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