|
| 1 | +// GitHub API access for the scheduled-lane health job (#1430). |
| 2 | +// |
| 3 | +// Only the two calls the job needs: recent `schedule`-triggered runs of a workflow, and upsert of |
| 4 | +// the single alert issue. Kept behind one module so the health logic stays pure and testable. |
| 5 | + |
| 6 | +import type { LaneHistory, LaneRun } from './health-model.ts'; |
| 7 | + |
| 8 | +const API = process.env.GITHUB_API_URL ?? 'https://api.github.com'; |
| 9 | + |
| 10 | +type RunsResponse = { |
| 11 | + workflow_runs?: { conclusion: string | null; created_at: string; html_url: string }[]; |
| 12 | +}; |
| 13 | + |
| 14 | +type IssuesResponse = { number: number; title: string }[]; |
| 15 | + |
| 16 | +function headers(token: string): Record<string, string> { |
| 17 | + return { |
| 18 | + accept: 'application/vnd.github+json', |
| 19 | + authorization: `Bearer ${token}`, |
| 20 | + 'x-github-api-version': '2022-11-28', |
| 21 | + }; |
| 22 | +} |
| 23 | + |
| 24 | +/** Plain field, not a constructor parameter property: Node's type stripping rejects those. */ |
| 25 | +class HttpError extends Error { |
| 26 | + status: number; |
| 27 | + |
| 28 | + constructor(status: number, message: string) { |
| 29 | + super(message); |
| 30 | + this.status = status; |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +async function request<T>(token: string, url: string, init?: RequestInit): Promise<T> { |
| 35 | + const response = await fetch(url, { ...init, headers: headers(token) }); |
| 36 | + if (!response.ok) { |
| 37 | + const method = init?.method ?? 'GET'; |
| 38 | + throw new HttpError(response.status, `${method} ${url} failed: ${response.status}`); |
| 39 | + } |
| 40 | + return (await response.json()) as T; |
| 41 | +} |
| 42 | + |
| 43 | +/** `null` for 404 only; every other status stays an error. */ |
| 44 | +async function requestOrNullOn404<T>(token: string, url: string): Promise<T | null> { |
| 45 | + try { |
| 46 | + return await request<T>(token, url); |
| 47 | + } catch (error) { |
| 48 | + if (error instanceof HttpError && error.status === 404) return null; |
| 49 | + throw error; |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +function toLaneRun(run: { conclusion: string | null; created_at: string; html_url: string }) { |
| 54 | + return { |
| 55 | + conclusion: (run.conclusion ?? null) as LaneRun['conclusion'], |
| 56 | + createdAt: run.created_at, |
| 57 | + url: run.html_url, |
| 58 | + }; |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Newest-first scheduled runs. A 404 means the workflow is not on the default branch yet (a lane |
| 63 | + * being born, as in this PR), which is unknown history rather than a dark lane. |
| 64 | + */ |
| 65 | +export async function fetchScheduledRuns(input: { |
| 66 | + token: string; |
| 67 | + repo: string; |
| 68 | + workflow: string; |
| 69 | + perPage: number; |
| 70 | +}): Promise<LaneHistory> { |
| 71 | + const url = `${API}/repos/${input.repo}/actions/workflows/${input.workflow}/runs?event=schedule&per_page=${input.perPage}`; |
| 72 | + const body = await requestOrNullOn404<RunsResponse>(input.token, url); |
| 73 | + if (body === null) { |
| 74 | + return { known: false, reason: 'workflow not on the default branch yet', runs: [] }; |
| 75 | + } |
| 76 | + return { known: true, runs: (body.workflow_runs ?? []).map(toLaneRun) }; |
| 77 | +} |
| 78 | + |
| 79 | +/** Creates the alert issue, or comments on it when it is already open. */ |
| 80 | +export async function upsertAlertIssue(input: { |
| 81 | + token: string; |
| 82 | + repo: string; |
| 83 | + title: string; |
| 84 | + body: string; |
| 85 | +}): Promise<{ action: 'created' | 'commented'; number: number }> { |
| 86 | + const open = await request<IssuesResponse>( |
| 87 | + input.token, |
| 88 | + `${API}/repos/${input.repo}/issues?state=open&per_page=100`, |
| 89 | + ); |
| 90 | + const existing = open.find((issue) => issue.title === input.title); |
| 91 | + if (!existing) { |
| 92 | + const created = await request<{ number: number }>( |
| 93 | + input.token, |
| 94 | + `${API}/repos/${input.repo}/issues`, |
| 95 | + { method: 'POST', body: JSON.stringify({ title: input.title, body: input.body }) }, |
| 96 | + ); |
| 97 | + return { action: 'created', number: created.number }; |
| 98 | + } |
| 99 | + await request(input.token, `${API}/repos/${input.repo}/issues/${existing.number}/comments`, { |
| 100 | + method: 'POST', |
| 101 | + body: JSON.stringify({ body: input.body }), |
| 102 | + }); |
| 103 | + return { action: 'commented', number: existing.number }; |
| 104 | +} |
0 commit comments