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.

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