ci-cost-diff-action 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/SECURITY.md +25 -0
- package/action.yml +100 -0
- package/bin/ci-cost-diff.js +297 -0
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/RATE_MODEL.md +103 -0
- package/examples/baseline-jobs.json +22 -0
- package/examples/current-jobs.json +22 -0
- package/package.json +54 -0
- package/src/action.js +533 -0
- package/src/comments.js +78 -0
- package/src/cost.js +603 -0
- package/src/github.js +670 -0
- package/src/inputs.js +187 -0
- package/src/jobs.js +40 -0
- package/src/rates.js +841 -0
- package/src/report.js +258 -0
package/src/github.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
const API_VERSION = "2022-11-28";
|
|
2
|
+
const MAX_PAGE_SIZE = 100;
|
|
3
|
+
const MAX_PAGINATION_PAGES = 1000;
|
|
4
|
+
const MAX_FILTERED_WORKFLOW_RUNS = 1000;
|
|
5
|
+
const MAX_REQUEST_ATTEMPTS = 3;
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
7
|
+
const DEFAULT_RETRY_DELAY_MS = 250;
|
|
8
|
+
const MAX_FALLBACK_RETRY_DELAY_MS = 30000;
|
|
9
|
+
const MAX_SERVER_RETRY_DELAY_MS = 60000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generic GitHub REST request options.
|
|
13
|
+
* @typedef {object} ApiRequestOptions
|
|
14
|
+
* @property {string} token GitHub token.
|
|
15
|
+
* @property {string} path Absolute API URL or path under `GITHUB_API_URL`.
|
|
16
|
+
* @property {string} [method="GET"] HTTP method.
|
|
17
|
+
* @property {unknown} [body] JSON-serializable request body.
|
|
18
|
+
* @property {boolean} [allow404=false] Return null instead of throwing on 404.
|
|
19
|
+
* @property {number} [timeoutMs=30000] Fetch timeout in milliseconds.
|
|
20
|
+
* @property {number} [retryDelayMs=250] Base retry delay in milliseconds.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Workflow run fields used by baseline lookup and report links.
|
|
25
|
+
* @typedef {object} GitHubWorkflowRun
|
|
26
|
+
* @property {string|number} id Workflow run id.
|
|
27
|
+
* @property {string|number} [run_number] Human-readable run number.
|
|
28
|
+
* @property {string} [head_branch] Branch name associated with the run.
|
|
29
|
+
* @property {string} [html_url] Browser URL for the run.
|
|
30
|
+
* @property {string|number} [workflow_id] Workflow id used for baseline lookup.
|
|
31
|
+
* @property {string} [created_at] Run creation timestamp.
|
|
32
|
+
* @property {string} [run_started_at] Run start timestamp.
|
|
33
|
+
* @property {string} [conclusion] Run conclusion.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Authenticated GitHub user fields used for comment ownership checks.
|
|
38
|
+
* @typedef {object} GitHubUser
|
|
39
|
+
* @property {string} [login] GitHub login.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GitHub App fields used for Actions-authored comments.
|
|
44
|
+
* @typedef {object} GitHubApp
|
|
45
|
+
* @property {string} [slug] GitHub App slug.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Minimal issue comment shape returned by the GitHub API calls in this module.
|
|
50
|
+
* @typedef {object} GitHubIssueComment
|
|
51
|
+
* @property {string|number} id Issue comment id.
|
|
52
|
+
* @property {string} [body] Comment body.
|
|
53
|
+
* @property {GitHubUser} [user] Comment author.
|
|
54
|
+
* @property {GitHubApp} [performed_via_github_app] App that created the comment.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Options for finding the nearest older successful baseline run.
|
|
59
|
+
* @typedef {object} FindBaselineRunOptions
|
|
60
|
+
* @property {string} token GitHub token.
|
|
61
|
+
* @property {string} owner Repository owner.
|
|
62
|
+
* @property {string} repo Repository name.
|
|
63
|
+
* @property {string|number} workflowId Workflow id or workflow file name.
|
|
64
|
+
* @property {string} branch Baseline branch.
|
|
65
|
+
* @property {string} [event] Optional workflow event filter.
|
|
66
|
+
* @property {number} [limit] Total number of successful runs to inspect.
|
|
67
|
+
* @property {string|number} [currentRunId] Current workflow run id to exclude.
|
|
68
|
+
* @property {string} [currentRunCreatedAt] Current workflow run creation timestamp.
|
|
69
|
+
* @property {string} [currentRunStartedAt] Current workflow run start timestamp.
|
|
70
|
+
* @property {string|number} [currentRunNumber] Current workflow run number.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
function apiBaseUrl() {
|
|
74
|
+
return (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Performs a GitHub REST API request with action-standard headers.
|
|
79
|
+
* @param {ApiRequestOptions} options Request options.
|
|
80
|
+
* @returns {Promise<unknown|null>} Parsed JSON response, or null for 204 responses.
|
|
81
|
+
*/
|
|
82
|
+
export async function apiRequest({ token, path, method = "GET", body, allow404 = false, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, retryDelayMs = DEFAULT_RETRY_DELAY_MS }) {
|
|
83
|
+
const request = apiRequestContext({ token, path, method, body, allow404, timeoutMs, retryDelayMs });
|
|
84
|
+
|
|
85
|
+
for (let attempt = 1; attempt <= MAX_REQUEST_ATTEMPTS; attempt += 1) {
|
|
86
|
+
const outcome = await apiRequestAttempt(request, attempt);
|
|
87
|
+
if (outcome.done) {
|
|
88
|
+
return outcome.value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new Error(`GitHub API ${request.method} ${request.url} failed after ${MAX_REQUEST_ATTEMPTS} attempts.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function apiRequestContext({ token, path, method, body, allow404, timeoutMs, retryDelayMs }) {
|
|
96
|
+
return {
|
|
97
|
+
token,
|
|
98
|
+
method,
|
|
99
|
+
body,
|
|
100
|
+
allow404,
|
|
101
|
+
timeoutMs,
|
|
102
|
+
retryDelayMs,
|
|
103
|
+
url: path.startsWith("http") ? path : `${apiBaseUrl()}${path}`
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function apiRequestAttempt(request, attempt) {
|
|
108
|
+
const response = await fetchApiResponseOrRetry(request.url, request, attempt, request.retryDelayMs);
|
|
109
|
+
if (!response) {
|
|
110
|
+
return { done: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await apiResultFromResponse(response, request);
|
|
114
|
+
return handleApiResult(result, response, request, attempt);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function handleApiResult(result, response, request, attempt) {
|
|
118
|
+
if (shouldRetryResult(result, attempt)) {
|
|
119
|
+
await sleep(retryDelay(response, attempt, request.retryDelayMs));
|
|
120
|
+
return { done: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result.error) {
|
|
124
|
+
throw result.error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { done: true, value: result.value };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shouldRetryResult(result, attempt) {
|
|
131
|
+
return result.retry && attempt < MAX_REQUEST_ATTEMPTS;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchApiResponseOrRetry(url, request, attempt, retryDelayMs) {
|
|
135
|
+
try {
|
|
136
|
+
return await fetchApiResponse(url, request);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (attempt >= MAX_REQUEST_ATTEMPTS) {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await sleep(retryDelay(null, attempt, retryDelayMs));
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchApiResponse(url, request) {
|
|
148
|
+
return fetch(url, {
|
|
149
|
+
method: request.method,
|
|
150
|
+
headers: apiHeaders(request.token),
|
|
151
|
+
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
152
|
+
signal: requestSignal(request.timeoutMs)
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function apiHeaders(token) {
|
|
157
|
+
return {
|
|
158
|
+
Accept: "application/vnd.github+json",
|
|
159
|
+
Authorization: `Bearer ${token}`,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"X-GitHub-Api-Version": API_VERSION,
|
|
162
|
+
"User-Agent": "ci-cost-diff-action"
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function requestSignal(timeoutMs) {
|
|
167
|
+
return timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function apiResultFromResponse(response, { method, url, allow404 }) {
|
|
171
|
+
if (allow404 && response.status === 404) {
|
|
172
|
+
return { value: null };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (response.ok) {
|
|
176
|
+
return { value: await apiSuccessValue(response, method, url) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const message = await response.text();
|
|
180
|
+
return {
|
|
181
|
+
error: new Error(`GitHub API ${method} ${url} failed with ${response.status}: ${message}`),
|
|
182
|
+
retry: isRetryableResponse(response, message)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function apiSuccessValue(response, method, url) {
|
|
187
|
+
if (response.status === 204) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
return await response.json();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new Error(`GitHub API ${method} ${url} returned invalid JSON: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isRetryableResponse(response, message) {
|
|
199
|
+
return response.status === 429
|
|
200
|
+
|| response.status >= 500
|
|
201
|
+
|| response.headers.has("retry-after")
|
|
202
|
+
|| response.headers.get("x-ratelimit-remaining") === "0"
|
|
203
|
+
|| isSecondaryRateLimitResponse(response, message);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isSecondaryRateLimitResponse(response, message) {
|
|
207
|
+
return response.status === 403 && /secondary rate limit|abuse detection/i.test(message);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function retryDelay(response, attempt, retryDelayMs) {
|
|
211
|
+
const headerDelay = response ? retryAfterDelay(response) ?? rateLimitResetDelay(response) : null;
|
|
212
|
+
const fallbackDelay = retryDelayMs * 2 ** (attempt - 1);
|
|
213
|
+
return headerDelay === null
|
|
214
|
+
? Math.min(fallbackDelay, MAX_FALLBACK_RETRY_DELAY_MS)
|
|
215
|
+
: Math.min(headerDelay, MAX_SERVER_RETRY_DELAY_MS);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function retryAfterDelay(response) {
|
|
219
|
+
const header = response.headers.get("retry-after");
|
|
220
|
+
if (header === null) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const seconds = Number(header);
|
|
225
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
226
|
+
return seconds * 1000;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const delayMs = Date.parse(header) - Date.now();
|
|
230
|
+
return Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function rateLimitResetDelay(response) {
|
|
234
|
+
const header = response.headers.get("x-ratelimit-reset");
|
|
235
|
+
if (header === null) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const seconds = Number(header);
|
|
240
|
+
const delayMs = seconds * 1000 - Date.now();
|
|
241
|
+
return Number.isFinite(delayMs) && delayMs >= 0 ? delayMs : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sleep(ms) {
|
|
245
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fetches one workflow run.
|
|
250
|
+
* @param {object} options
|
|
251
|
+
* @param {string} options.token GitHub token.
|
|
252
|
+
* @param {string} options.owner Repository owner.
|
|
253
|
+
* @param {string} options.repo Repository name.
|
|
254
|
+
* @param {string|number} options.runId Workflow run id.
|
|
255
|
+
* @returns {Promise<GitHubWorkflowRun>} Workflow run response.
|
|
256
|
+
*/
|
|
257
|
+
export async function getWorkflowRun({ token, owner, repo, runId }) {
|
|
258
|
+
const data = await apiRequest({
|
|
259
|
+
token,
|
|
260
|
+
path: `/repos/${repoPath(owner, repo)}/actions/runs/${numericPathSegment(runId, "run-id")}`
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return workflowRunResponse(data, "workflow run");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Fetches the authenticated token user.
|
|
268
|
+
* @param {object} options
|
|
269
|
+
* @param {string} options.token GitHub token.
|
|
270
|
+
* @returns {Promise<GitHubUser>} Authenticated user response.
|
|
271
|
+
*/
|
|
272
|
+
export async function getAuthenticatedUser({ token }) {
|
|
273
|
+
return apiRequest({
|
|
274
|
+
token,
|
|
275
|
+
path: "/user"
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Lists all jobs for a workflow run, following pagination.
|
|
281
|
+
* @param {object} options
|
|
282
|
+
* @param {string} options.token GitHub token.
|
|
283
|
+
* @param {string} options.owner Repository owner.
|
|
284
|
+
* @param {string} options.repo Repository name.
|
|
285
|
+
* @param {string|number} options.runId Workflow run id.
|
|
286
|
+
* @param {"all"|"latest"} [options.filter="all"] GitHub job attempt filter.
|
|
287
|
+
* @returns {Promise<import("./cost.js").GitHubJob[]>} Workflow jobs.
|
|
288
|
+
*/
|
|
289
|
+
export async function listJobsForRun({ token, owner, repo, runId, filter = "all" }) {
|
|
290
|
+
const jobs = [];
|
|
291
|
+
|
|
292
|
+
for (let page = 1; ; page += 1) {
|
|
293
|
+
assertPaginationPage(page, "workflow jobs");
|
|
294
|
+
const params = new URLSearchParams({
|
|
295
|
+
per_page: String(MAX_PAGE_SIZE),
|
|
296
|
+
page: String(page),
|
|
297
|
+
filter
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const data = await apiRequest({
|
|
301
|
+
token,
|
|
302
|
+
path: `/repos/${repoPath(owner, repo)}/actions/runs/${numericPathSegment(runId, "run-id")}/jobs?${params.toString()}`
|
|
303
|
+
});
|
|
304
|
+
const pageJobs = workflowJobArrayField(data, "jobs", "workflow jobs");
|
|
305
|
+
|
|
306
|
+
jobs.push(...pageJobs);
|
|
307
|
+
if (pageJobs.length < MAX_PAGE_SIZE) {
|
|
308
|
+
return jobs;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function assertPaginationPage(page, resource) {
|
|
314
|
+
if (page > MAX_PAGINATION_PAGES) {
|
|
315
|
+
throw new Error(`GitHub ${resource} pagination exceeded ${MAX_PAGINATION_PAGES} pages; refusing to continue after ${MAX_PAGINATION_PAGES * MAX_PAGE_SIZE} items.`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Lists successful workflow runs for a workflow, branch, and optional event.
|
|
321
|
+
* @param {object} options
|
|
322
|
+
* @param {string} options.token GitHub token.
|
|
323
|
+
* @param {string} options.owner Repository owner.
|
|
324
|
+
* @param {string} options.repo Repository name.
|
|
325
|
+
* @param {string|number} options.workflowId Workflow id or workflow file name.
|
|
326
|
+
* @param {string} options.branch Branch to search.
|
|
327
|
+
* @param {string} [options.event] Optional event filter.
|
|
328
|
+
* @param {number} [options.limit=20] Compatibility default for page size.
|
|
329
|
+
* @param {number} [options.page=1] Page number to fetch.
|
|
330
|
+
* @param {number} [options.perPage] Page size before GitHub's 100 item cap.
|
|
331
|
+
* @returns {Promise<GitHubWorkflowRun[]>} Successful workflow runs.
|
|
332
|
+
*/
|
|
333
|
+
export async function listSuccessfulWorkflowRuns({ token, owner, repo, workflowId, branch, event, limit = 20, page = 1, perPage = limit }) {
|
|
334
|
+
const params = new URLSearchParams({
|
|
335
|
+
status: "success"
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (branch) {
|
|
339
|
+
params.set("branch", branch);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
params.set("per_page", String(Math.min(Math.max(perPage, 1), MAX_PAGE_SIZE)));
|
|
343
|
+
params.set("page", String(page));
|
|
344
|
+
|
|
345
|
+
if (event) {
|
|
346
|
+
params.set("event", event);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const data = await apiRequest({
|
|
350
|
+
token,
|
|
351
|
+
path: `/repos/${repoPath(owner, repo)}/actions/workflows/${pathSegment(workflowId, "workflow-id")}/runs?${params.toString()}`,
|
|
352
|
+
allow404: true
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return data === null ? [] : workflowRunArrayField(data, "workflow_runs", "workflow runs");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Finds the nearest older successful run to use as the cost baseline.
|
|
360
|
+
* @param {FindBaselineRunOptions} options Baseline lookup options.
|
|
361
|
+
* @returns {Promise<GitHubWorkflowRun|null>} Baseline run, or null when none is found.
|
|
362
|
+
*/
|
|
363
|
+
export async function findBaselineRun(options) {
|
|
364
|
+
const search = baselineSearchContext(options);
|
|
365
|
+
|
|
366
|
+
for (let page = 1, inspected = 0; shouldScanBaselinePage(inspected, search); page += 1) {
|
|
367
|
+
assertPaginationPage(page, "workflow runs");
|
|
368
|
+
const result = await inspectBaselinePage(options, page, inspected, search);
|
|
369
|
+
|
|
370
|
+
if (result.done) {
|
|
371
|
+
return result.baselineRun;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
inspected = result.inspected;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function baselineSearchContext(options) {
|
|
381
|
+
const requestedLookbackRuns = Math.max(parseRunNumber(options.limit) ?? 20, 1);
|
|
382
|
+
const lookbackRuns = Math.min(requestedLookbackRuns, MAX_FILTERED_WORKFLOW_RUNS);
|
|
383
|
+
return {
|
|
384
|
+
currentRunId: String(options.currentRunId ?? ""),
|
|
385
|
+
currentRunCreatedAt: parseTimestamp(options.currentRunStartedAt ?? options.currentRunCreatedAt),
|
|
386
|
+
currentRunNumber: parseRunNumber(options.currentRunNumber),
|
|
387
|
+
lookbackRuns,
|
|
388
|
+
perPage: Math.min(lookbackRuns, MAX_PAGE_SIZE)
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function shouldScanBaselinePage(inspected, search) {
|
|
393
|
+
return inspected < search.lookbackRuns;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function inspectBaselinePage(options, page, inspected, search) {
|
|
397
|
+
const runs = await baselinePageRuns(options, page, search);
|
|
398
|
+
const inspectedRuns = runs.slice(0, search.lookbackRuns - inspected);
|
|
399
|
+
const baselineRun = firstBaselineRun(inspectedRuns, search);
|
|
400
|
+
return baselineRun
|
|
401
|
+
? { done: true, baselineRun }
|
|
402
|
+
: baselinePageResult(runs, inspected, inspectedRuns.length, search);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function baselinePageRuns(options, page, search) {
|
|
406
|
+
return listSuccessfulWorkflowRuns({ ...options, page, perPage: search.perPage });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function baselinePageResult(runs, inspected, inspectedCount, search) {
|
|
410
|
+
const nextInspected = inspected + inspectedCount;
|
|
411
|
+
return runs.length < search.perPage || nextInspected >= search.lookbackRuns
|
|
412
|
+
? { done: true, baselineRun: null }
|
|
413
|
+
: { done: false, inspected: nextInspected };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function firstBaselineRun(runs, search) {
|
|
417
|
+
return runs.reduce((bestRun, run) => (
|
|
418
|
+
isBaselineCandidate(run, search) && isNearerBaselineRun(run, bestRun) ? run : bestRun
|
|
419
|
+
), null);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isBaselineCandidate(run, search) {
|
|
423
|
+
return (
|
|
424
|
+
String(run.id) !== search.currentRunId
|
|
425
|
+
&& run.conclusion === "success"
|
|
426
|
+
&& isOlderThanCurrentRun(run, search)
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isNearerBaselineRun(run, bestRun) {
|
|
431
|
+
return !bestRun || compareBaselineRunOrder(run, bestRun) > 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function compareBaselineRunOrder(left, right) {
|
|
435
|
+
const timestampOrder = compareNullableNumber(
|
|
436
|
+
parseTimestamp(left.run_started_at ?? left.created_at),
|
|
437
|
+
parseTimestamp(right.run_started_at ?? right.created_at)
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
return timestampOrder === 0
|
|
441
|
+
? compareNullableNumber(parseRunNumber(left.run_number), parseRunNumber(right.run_number))
|
|
442
|
+
: timestampOrder;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function compareNullableNumber(left, right) {
|
|
446
|
+
if (left === null && right === null) {
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (left === null) {
|
|
451
|
+
return -1;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return right === null ? 1 : Math.sign(left - right);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isOlderThanCurrentRun(run, { currentRunCreatedAt, currentRunNumber }) {
|
|
458
|
+
const runCreatedAt = parseTimestamp(run.run_started_at ?? run.created_at);
|
|
459
|
+
if (currentRunCreatedAt !== null && runCreatedAt !== null && runCreatedAt !== currentRunCreatedAt) {
|
|
460
|
+
return runCreatedAt < currentRunCreatedAt;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const runNumber = parseRunNumber(run.run_number);
|
|
464
|
+
if (currentRunNumber !== null && runNumber !== null) {
|
|
465
|
+
return runNumber < currentRunNumber;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function parseTimestamp(value) {
|
|
472
|
+
const parsed = new Date(value ?? "").getTime();
|
|
473
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseRunNumber(value) {
|
|
477
|
+
const rawValue = String(value ?? "").trim();
|
|
478
|
+
if (!rawValue) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const parsed = Number(rawValue);
|
|
483
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Lists all issue comments, following pagination.
|
|
488
|
+
* @param {object} options
|
|
489
|
+
* @param {string} options.token GitHub token.
|
|
490
|
+
* @param {string} options.owner Repository owner.
|
|
491
|
+
* @param {string} options.repo Repository name.
|
|
492
|
+
* @param {string|number} options.issueNumber Issue or pull request number.
|
|
493
|
+
* @returns {Promise<GitHubIssueComment[]>} Issue comments.
|
|
494
|
+
*/
|
|
495
|
+
export async function listIssueComments({ token, owner, repo, issueNumber }) {
|
|
496
|
+
const comments = [];
|
|
497
|
+
|
|
498
|
+
for (let page = 1; ; page += 1) {
|
|
499
|
+
assertPaginationPage(page, "issue comments");
|
|
500
|
+
const data = await apiRequest({
|
|
501
|
+
token,
|
|
502
|
+
path: `/repos/${repoPath(owner, repo)}/issues/${numericPathSegment(issueNumber, "issue-number")}/comments?per_page=${MAX_PAGE_SIZE}&page=${page}`
|
|
503
|
+
});
|
|
504
|
+
const pageComments = objectResponseArray(data, "issue comments");
|
|
505
|
+
|
|
506
|
+
comments.push(...pageComments);
|
|
507
|
+
if (pageComments.length < MAX_PAGE_SIZE) {
|
|
508
|
+
return comments;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function objectArrayField(data, field, resource) {
|
|
514
|
+
if (data && Array.isArray(data[field])) {
|
|
515
|
+
return validateObjectArray(data[field], resource);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected ${field} array.`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function workflowRunArrayField(data, field, resource) {
|
|
522
|
+
const runs = objectArrayField(data, field, resource);
|
|
523
|
+
for (const [index, run] of runs.entries()) {
|
|
524
|
+
const itemResource = `${resource} item ${index}`;
|
|
525
|
+
assertWorkflowRunField(run, "id", itemResource);
|
|
526
|
+
assertWorkflowRunField(run, "conclusion", itemResource);
|
|
527
|
+
assertWorkflowRunOrderField(run, itemResource);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return runs;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function workflowJobArrayField(data, field, resource) {
|
|
534
|
+
const jobs = objectArrayField(data, field, resource);
|
|
535
|
+
for (const [index, job] of jobs.entries()) {
|
|
536
|
+
assertWorkflowRunField(job, "id", `${resource} item ${index}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return jobs;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function objectResponseArray(data, resource) {
|
|
543
|
+
if (Array.isArray(data)) {
|
|
544
|
+
return validateObjectArray(data, resource);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected an array.`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function workflowRunResponse(data, resource) {
|
|
551
|
+
const run = objectResponse(data, resource);
|
|
552
|
+
assertWorkflowRunField(run, "id", resource);
|
|
553
|
+
assertWorkflowRunField(run, "workflow_id", resource);
|
|
554
|
+
assertWorkflowRunOrderField(run, resource);
|
|
555
|
+
|
|
556
|
+
return run;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function objectResponse(data, resource) {
|
|
560
|
+
if (data && !Array.isArray(data) && typeof data === "object") {
|
|
561
|
+
return data;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected an object.`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function assertWorkflowRunField(run, field, resource) {
|
|
568
|
+
if (!hasNonEmptyScalar(run[field])) {
|
|
569
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected ${field}.`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function assertWorkflowRunOrderField(run, resource) {
|
|
574
|
+
if (parseTimestamp(run.run_started_at ?? run.created_at) !== null || parseRunNumber(run.run_number) !== null) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected run_started_at, created_at, or run_number.`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function hasNonEmptyScalar(value) {
|
|
582
|
+
return (typeof value === "string" && value.trim() !== "") || typeof value === "number";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function validateObjectArray(items, resource) {
|
|
586
|
+
for (const [index, item] of items.entries()) {
|
|
587
|
+
if (!item || Array.isArray(item) || typeof item !== "object") {
|
|
588
|
+
throw new Error(`GitHub API returned an invalid ${resource} response: expected item ${index} to be an object.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return items;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Creates a pull request issue comment.
|
|
597
|
+
* @param {object} options
|
|
598
|
+
* @param {string} options.token GitHub token.
|
|
599
|
+
* @param {string} options.owner Repository owner.
|
|
600
|
+
* @param {string} options.repo Repository name.
|
|
601
|
+
* @param {string|number} options.issueNumber Issue or pull request number.
|
|
602
|
+
* @param {string} options.body Comment body.
|
|
603
|
+
* @returns {Promise<GitHubIssueComment>} Created comment.
|
|
604
|
+
*/
|
|
605
|
+
export async function createIssueComment({ token, owner, repo, issueNumber, body }) {
|
|
606
|
+
return apiRequest({
|
|
607
|
+
token,
|
|
608
|
+
path: `/repos/${repoPath(owner, repo)}/issues/${numericPathSegment(issueNumber, "issue-number")}/comments`,
|
|
609
|
+
method: "POST",
|
|
610
|
+
body: { body }
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Updates an existing issue comment.
|
|
616
|
+
* @param {object} options
|
|
617
|
+
* @param {string} options.token GitHub token.
|
|
618
|
+
* @param {string} options.owner Repository owner.
|
|
619
|
+
* @param {string} options.repo Repository name.
|
|
620
|
+
* @param {string|number} options.commentId Issue comment id.
|
|
621
|
+
* @param {string} options.body Replacement comment body.
|
|
622
|
+
* @returns {Promise<GitHubIssueComment>} Updated comment.
|
|
623
|
+
*/
|
|
624
|
+
export async function updateIssueComment({ token, owner, repo, commentId, body }) {
|
|
625
|
+
return apiRequest({
|
|
626
|
+
token,
|
|
627
|
+
path: `/repos/${repoPath(owner, repo)}/issues/comments/${numericPathSegment(commentId, "comment-id")}`,
|
|
628
|
+
method: "PATCH",
|
|
629
|
+
body: { body }
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Deletes an existing issue comment.
|
|
635
|
+
* @param {object} options
|
|
636
|
+
* @param {string} options.token GitHub token.
|
|
637
|
+
* @param {string} options.owner Repository owner.
|
|
638
|
+
* @param {string} options.repo Repository name.
|
|
639
|
+
* @param {string|number} options.commentId Issue comment id.
|
|
640
|
+
* @returns {Promise<null>} Null on successful deletion.
|
|
641
|
+
*/
|
|
642
|
+
export async function deleteIssueComment({ token, owner, repo, commentId }) {
|
|
643
|
+
return apiRequest({
|
|
644
|
+
token,
|
|
645
|
+
path: `/repos/${repoPath(owner, repo)}/issues/comments/${numericPathSegment(commentId, "comment-id")}`,
|
|
646
|
+
method: "DELETE"
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function repoPath(owner, repo) {
|
|
651
|
+
return `${pathSegment(owner, "owner")}/${pathSegment(repo, "repo")}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function pathSegment(value, name) {
|
|
655
|
+
const segment = String(value ?? "").trim();
|
|
656
|
+
if (!segment) {
|
|
657
|
+
throw new Error(`${name} must be a non-empty path segment.`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return encodeURIComponent(segment);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function numericPathSegment(value, name) {
|
|
664
|
+
const segment = String(value ?? "").trim();
|
|
665
|
+
if (!/^[0-9]+$/.test(segment)) {
|
|
666
|
+
throw new Error(`${name} must be a numeric id.`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return segment;
|
|
670
|
+
}
|