| 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 | */ |
| 10 | import type { Context } from "hono"; |
| 11 | import { b64 } from "../lib/util.js"; |
| 12 | import { atLeast, type Role } from "../repo/types.js"; |
| 13 | |
| 14 | export const GITHUB_DOCS = "https://docs.github.com/rest"; |
| 15 | |
| 16 | /** PUBLIC_URL without a trailing slash. */ |
| 17 | export const baseOf = (c: { env: { PUBLIC_URL: string } }): string => c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 18 | export const apiRoot = (base: string): string => `${base}/api/v3`; |
| 19 | export const repoApi = (base: string, owner: string, repo: string): string => `${base}/api/v3/repos/${owner}/${repo}`; |
| 20 | export const repoHtml = (base: string, owner: string, repo: string): string => `${base}/${owner}/${repo}`; |
| 21 | export const encPath = (p: string): string => p.split("/").map(encodeURIComponent).join("/"); |
| 22 | |
| 23 | /** Opaque GraphQL-style node id, stable for a type and id. */ |
| 24 | export const nodeId = (type: string, id: string | number): string => b64(new TextEncoder().encode(`${type}:${id}`)).replace(/=+$/, ""); |
| 25 | |
| 26 | /** ISO 8601 without milliseconds, like GitHub. */ |
| 27 | export const isoSeconds = (iso: string | null | undefined): string | null => (iso ? iso.replace(/\.\d{3}Z$/, "Z") : null); |
| 28 | export 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 | */ |
| 37 | export 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 | export type Who = { id: string; login: string; kind?: string | null }; |
| 50 | |
| 51 | export const userType = (kind?: string | null): "User" | "Organization" | "Bot" => (kind === "org" ? "Organization" : kind === "agent" || kind === "system" ? "Bot" : "User"); |
| 52 | |
| 53 | export 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). */ |
| 90 | export 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 | |
| 107 | export 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 | export 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 | |
| 120 | export 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 | |
| 191 | export type LabelRow = { id: number; name: string; color: string; description: string }; |
| 192 | |
| 193 | export 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 | |
| 205 | export const REACTIONS = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"] as const; |
| 206 | export type ReactionContent = (typeof REACTIONS)[number]; |
| 207 | export type ReactionCounts = Partial<Record<ReactionContent, number>>; |
| 208 | |
| 209 | export 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`). */ |
| 221 | export 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. */ |
| 254 | export 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). */ |
| 257 | export 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). */ |
| 263 | export 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 | |
| 274 | const 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. */ |
| 277 | function 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 | } |
| 281 | const association = (x: IssueCtx, login: string) => (login.toLowerCase() === x.owner.toLowerCase() ? "OWNER" : "NONE"); |
| 282 | |
| 283 | export 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`. */ |
| 329 | export 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 | |
| 393 | export type CommentLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; body: string; createdAt: string; updatedAt: string }; |
| 394 | |
| 395 | export 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 | |
| 415 | export 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 | |
| 417 | export 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 | |
| 455 | export type ReviewLike = { id: string; seq?: number; author: { id: string; login: string; kind: string }; state: string; body: string; commitId: string; submittedAt: string }; |
| 456 | |
| 457 | export 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 | |
| 477 | export type Sig = { name: string; email: string; timestamp: number; timezone: string }; |
| 478 | export type CommitLike = { sha: string; tree: string; parents: string[]; author: Sig; committer: Sig; message: string; signed: boolean }; |
| 479 | |
| 480 | export const sigJson = (s: Sig) => ({ name: s.name, email: s.email, date: unixIso(s.timestamp) }); |
| 481 | |
| 482 | /** Verification block; Diffinery does not verify signatures yet. */ |
| 483 | export const verification = (signed: boolean) => ({ verified: false, reason: signed ? "unknown_key" : "unsigned", signature: null, payload: null, verified_at: null }); |
| 484 | |
| 485 | export 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. */ |
| 511 | export 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). */ |
| 518 | export 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 | |
| 535 | export type FileLike = { path: string; status: string; oldOid?: string; newOid?: string; additions: number; deletions: number; patch?: string; binary?: boolean }; |
| 536 | |
| 537 | export 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 | |
| 556 | export 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 | |
| 585 | export 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`. */ |
| 606 | export 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 | |
| 646 | export 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. */ |
| 658 | export 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 | |
| 681 | export interface Page { |
| 682 | page: number; |
| 683 | perPage: number; |
| 684 | offset: number; |
| 685 | } |
| 686 | |
| 687 | export 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 | */ |
| 699 | export 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. */ |
| 716 | export 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 | |
| 724 | export type AssetLike = { id: number; name: string; label: string; contentType: string; size: number; state: string; downloadCount: number; uploader: Who | null; createdAt: string; updatedAt: string }; |
| 725 | export 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 | |
| 741 | export 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 | |
| 760 | export 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 | } |