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/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
+ }