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/action.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { findUpdatableComments, validIssueCommentId } from "./comments.js";
|
|
4
|
+
import { analyzeJobs, diffSummaries, evaluateThresholds } from "./cost.js";
|
|
5
|
+
import { findBaselineRun, getAuthenticatedUser, getWorkflowRun, listJobsForRun, createIssueComment, deleteIssueComment, listIssueComments, updateIssueComment } from "./github.js";
|
|
6
|
+
import { getInput, parseBoolean, parseChoice, parseJsonObject, parseList, parseNonNegativeNumber, parsePositiveInteger, resolvedInput } from "./inputs.js";
|
|
7
|
+
import { findJobByWorkflowOrCheckRunId } from "./jobs.js";
|
|
8
|
+
import { commentMarker, renderReport } from "./report.js";
|
|
9
|
+
|
|
10
|
+
const POST_CREATE_CLEANUP_ATTEMPTS = 3;
|
|
11
|
+
const POST_CREATE_CLEANUP_DELAY_MS = 250;
|
|
12
|
+
|
|
13
|
+
export async function main() {
|
|
14
|
+
const token = readRequiredToken();
|
|
15
|
+
const context = await readRunContext(token);
|
|
16
|
+
const inputs = readActionInputs();
|
|
17
|
+
const baselineRun = await readBaselineRun({ token, context, inputs });
|
|
18
|
+
const { currentJobs, baselineJobs } = await readWorkflowJobs({ token, context, inputs, baselineRun });
|
|
19
|
+
const { current, baseline, diff } = analyzeWorkflowJobs({ currentJobs, baselineJobs, inputs });
|
|
20
|
+
const thresholdResult = evaluateThresholds(diff, inputs.thresholds);
|
|
21
|
+
const runnerRateWarnings = collectRunnerRateWarnings({ current, baseline });
|
|
22
|
+
|
|
23
|
+
applyRunnerRateGates({ thresholdResult, runnerRateWarnings, inputs });
|
|
24
|
+
|
|
25
|
+
const marker = commentMarker(commentScope(context, inputs, currentJobs));
|
|
26
|
+
const report = renderReport({
|
|
27
|
+
diff,
|
|
28
|
+
currentRun: context.currentRun,
|
|
29
|
+
baselineRun,
|
|
30
|
+
thresholdResult,
|
|
31
|
+
unknownJobs: runnerRateWarnings.unknownJobs,
|
|
32
|
+
ambiguousRateJobs: runnerRateWarnings.ambiguousRateJobs,
|
|
33
|
+
marker
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
writeSummary(report);
|
|
37
|
+
setOutputs(diff, thresholdResult);
|
|
38
|
+
await maybeWritePrComment({
|
|
39
|
+
token,
|
|
40
|
+
context,
|
|
41
|
+
report,
|
|
42
|
+
marker,
|
|
43
|
+
mode: inputs.commentMode,
|
|
44
|
+
allowForkPrComments: inputs.allowForkPrComments
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (thresholdResult.conclusion === "fail") {
|
|
48
|
+
throw new Error(`CI cost budget failed: ${thresholdResult.failures.join("; ")}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readRequiredToken() {
|
|
53
|
+
const token = getInput("github-token");
|
|
54
|
+
if (!token) {
|
|
55
|
+
throw new Error("Missing github-token input.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return token;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readRunContext(token) {
|
|
62
|
+
const { owner, repo } = parseRepository(process.env.GITHUB_REPOSITORY);
|
|
63
|
+
const event = readEvent();
|
|
64
|
+
const runId = getInput("run-id") || process.env.GITHUB_RUN_ID;
|
|
65
|
+
if (!runId) {
|
|
66
|
+
throw new Error("Missing run id. Set run-id or run inside GitHub Actions.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const currentRun = await getWorkflowRun({ token, owner, repo, runId });
|
|
70
|
+
return {
|
|
71
|
+
owner,
|
|
72
|
+
repo,
|
|
73
|
+
event,
|
|
74
|
+
runId,
|
|
75
|
+
currentRun,
|
|
76
|
+
workflowId: getInput("workflow-id") || String(currentRun.workflow_id),
|
|
77
|
+
baselineBranch: resolveBaselineBranch(event, currentRun)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveBaselineBranch(event, currentRun) {
|
|
82
|
+
return getInput("baseline-branch")
|
|
83
|
+
|| event.pull_request?.base?.ref
|
|
84
|
+
|| event.repository?.default_branch
|
|
85
|
+
|| currentRun.head_branch;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readActionInputs() {
|
|
89
|
+
return {
|
|
90
|
+
runIdOverride: Boolean(getInput("run-id")),
|
|
91
|
+
baselineEvent: getInput("baseline-event"),
|
|
92
|
+
lookbackRuns: parsePositiveInteger(getInput("lookback-runs"), 20, "lookback-runs"),
|
|
93
|
+
jobFilter: parseChoice(getInput("job-filter"), ["all", "latest"], "all", "job-filter"),
|
|
94
|
+
excludePatterns: parseList(getInput("exclude-jobs")),
|
|
95
|
+
currentJobId: resolvedInput(getInput("current-job-id")),
|
|
96
|
+
includeCurrentJob: parseBoolean(getInput("include-current-job"), false),
|
|
97
|
+
runnerRates: parseJsonObject(getInput("runner-rates"), {}, "runner-rates"),
|
|
98
|
+
failOnUnknownRunner: parseBoolean(getInput("fail-on-unknown-runner"), false),
|
|
99
|
+
failOnAmbiguousRunnerRate: parseBoolean(getInput("fail-on-ambiguous-runner-rate"), false),
|
|
100
|
+
commentMode: parseChoice(getInput("comment-mode"), ["off", "update", "always"], "update", "comment-mode"),
|
|
101
|
+
allowForkPrComments: parseBoolean(getInput("allow-fork-pr-comments"), false),
|
|
102
|
+
thresholds: {
|
|
103
|
+
maxIncreasePercent: parseNonNegativeNumber(getInput("fail-on-increase-percent"), undefined, "fail-on-increase-percent"),
|
|
104
|
+
maxIncreaseUsd: parseNonNegativeNumber(getInput("fail-on-increase-usd"), undefined, "fail-on-increase-usd"),
|
|
105
|
+
maxTotalUsd: parseNonNegativeNumber(getInput("fail-on-total-usd"), undefined, "fail-on-total-usd")
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function commentScope(context, inputs = {}, currentJobs = []) {
|
|
111
|
+
return [context.currentRun.workflow_id ?? context.workflowId, reportingJobScope(inputs, currentJobs), process.env.GITHUB_ACTION]
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join("/");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function reportingJobScope(inputs, currentJobs) {
|
|
117
|
+
const currentActionJob = inputs.currentJobId ? findJobByWorkflowOrCheckRunId(currentJobs, inputs.currentJobId) : null;
|
|
118
|
+
return currentActionJob?.name || process.env.GITHUB_JOB;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readBaselineRun({ token, context, inputs }) {
|
|
122
|
+
return findBaselineRun({
|
|
123
|
+
token,
|
|
124
|
+
owner: context.owner,
|
|
125
|
+
repo: context.repo,
|
|
126
|
+
workflowId: context.workflowId,
|
|
127
|
+
branch: context.baselineBranch,
|
|
128
|
+
event: inputs.baselineEvent,
|
|
129
|
+
limit: inputs.lookbackRuns,
|
|
130
|
+
currentRunId: context.runId,
|
|
131
|
+
currentRunCreatedAt: context.currentRun.created_at,
|
|
132
|
+
currentRunStartedAt: context.currentRun.run_started_at,
|
|
133
|
+
currentRunNumber: context.currentRun.run_number
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readWorkflowJobs({ token, context, inputs, baselineRun }) {
|
|
138
|
+
const currentJobs = await listJobsForRun({ token, owner: context.owner, repo: context.repo, runId: context.runId, filter: inputs.jobFilter });
|
|
139
|
+
const baselineJobs = baselineRun
|
|
140
|
+
? await listJobsForRun({ token, owner: context.owner, repo: context.repo, runId: baselineRun.id, filter: inputs.jobFilter })
|
|
141
|
+
: [];
|
|
142
|
+
|
|
143
|
+
return { currentJobs, baselineJobs };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function analyzeWorkflowJobs({ currentJobs, baselineJobs, inputs }) {
|
|
147
|
+
const { currentExclusions, baselineExclusions } = resolveJobExclusions({ currentJobs, inputs });
|
|
148
|
+
const current = analyzeJobs(currentJobs, {
|
|
149
|
+
...currentExclusions,
|
|
150
|
+
rateOverrides: inputs.runnerRates
|
|
151
|
+
});
|
|
152
|
+
const baseline = analyzeJobs(baselineJobs, {
|
|
153
|
+
...baselineExclusions,
|
|
154
|
+
rateOverrides: inputs.runnerRates
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
current,
|
|
159
|
+
baseline,
|
|
160
|
+
diff: diffSummaries(current, baseline)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveJobExclusions({ currentJobs, inputs }) {
|
|
165
|
+
const currentActionJob = inputs.currentJobId ? findJobByWorkflowOrCheckRunId(currentJobs, inputs.currentJobId) : null;
|
|
166
|
+
|
|
167
|
+
if (inputs.includeCurrentJob) {
|
|
168
|
+
return jobExclusionOptions(inputs.excludePatterns, [], [], incompleteReportingJobNames(currentActionJob, currentJobs, inputs));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const excludeJobIds = currentActionJob ? [currentActionJob.id] : [];
|
|
172
|
+
const currentExcludeNames = currentActionJob?.name ? [currentActionJob.name] : [];
|
|
173
|
+
const baselineExcludeNames = baselineReportingJobNames(currentActionJob, currentJobs, inputs);
|
|
174
|
+
|
|
175
|
+
return jobExclusionOptions(inputs.excludePatterns, excludeJobIds, currentExcludeNames, baselineExcludeNames);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function jobExclusionOptions(excludePatterns, excludeJobIds, currentExcludeNames, baselineExcludeNames) {
|
|
179
|
+
const currentExclusions = { excludePatterns, excludeJobIds };
|
|
180
|
+
if (currentExcludeNames.length > 0) {
|
|
181
|
+
currentExclusions.excludeNames = currentExcludeNames;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
currentExclusions,
|
|
186
|
+
baselineExclusions: { excludePatterns, excludeNames: baselineExcludeNames }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function baselineReportingJobNames(currentActionJob, currentJobs, inputs) {
|
|
191
|
+
if (currentActionJob?.name) {
|
|
192
|
+
return [currentActionJob.name];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const incompleteNames = incompleteJobNames(currentJobs);
|
|
196
|
+
if (incompleteNames.length > 0) {
|
|
197
|
+
console.warn("::warning::current-job-id was not provided or did not match a workflow job. Excluding in-progress current job names from the baseline; pass current-job-id for precise self-exclusion.");
|
|
198
|
+
return incompleteNames;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return shouldUseFallbackReportingJobName(inputs) ? fallbackReportingJobNames() : [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function shouldUseFallbackReportingJobName(inputs) {
|
|
205
|
+
return !inputs.runIdOverride;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function incompleteReportingJobNames(currentActionJob, currentJobs, inputs) {
|
|
209
|
+
if (currentActionJob?.name) {
|
|
210
|
+
return isIncompleteJob(currentActionJob) ? [currentActionJob.name] : [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const incompleteNames = incompleteJobNames(currentJobs);
|
|
214
|
+
return incompleteNames.length > 0 || !shouldUseFallbackReportingJobName(inputs)
|
|
215
|
+
? incompleteNames
|
|
216
|
+
: fallbackReportingJobNames();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function incompleteJobNames(jobs) {
|
|
220
|
+
return [...new Set(jobs
|
|
221
|
+
.filter(isIncompleteJob)
|
|
222
|
+
.map((job) => job.name))];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isIncompleteJob(job) {
|
|
226
|
+
return Boolean(job?.started_at && !job?.completed_at && job.name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function fallbackReportingJobNames() {
|
|
230
|
+
if (!process.env.GITHUB_JOB) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.warn("::warning::current-job-id was not provided or did not match a workflow job. Falling back to GITHUB_JOB for baseline self-exclusion; use exclude-jobs if the reporting job has a custom display name.");
|
|
235
|
+
return [process.env.GITHUB_JOB];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function collectRunnerRateWarnings({ current, baseline }) {
|
|
239
|
+
const currentUnknownJobs = current.jobs.filter((job) => job.rate === null);
|
|
240
|
+
const baselineUnknownJobs = baseline.jobs.filter((job) => job.rate === null);
|
|
241
|
+
const currentAmbiguousRateJobs = current.jobs.filter((job) => job.rateWarning);
|
|
242
|
+
const baselineAmbiguousRateJobs = baseline.jobs.filter((job) => job.rateWarning);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
currentUnknownJobs,
|
|
246
|
+
currentAmbiguousRateJobs,
|
|
247
|
+
unknownJobs: [
|
|
248
|
+
...withRunLabel(currentUnknownJobs, "current"),
|
|
249
|
+
...withRunLabel(baselineUnknownJobs, "baseline")
|
|
250
|
+
],
|
|
251
|
+
ambiguousRateJobs: [
|
|
252
|
+
...withRunLabel(currentAmbiguousRateJobs, "current"),
|
|
253
|
+
...withRunLabel(baselineAmbiguousRateJobs, "baseline")
|
|
254
|
+
]
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function applyRunnerRateGates({ thresholdResult, runnerRateWarnings, inputs }) {
|
|
259
|
+
if (inputs.failOnUnknownRunner && runnerRateWarnings.currentUnknownJobs.length > 0) {
|
|
260
|
+
thresholdResult.conclusion = "fail";
|
|
261
|
+
thresholdResult.failures.push(`${runnerRateWarnings.currentUnknownJobs.length} current job(s) used unknown runner rates.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (inputs.failOnAmbiguousRunnerRate && runnerRateWarnings.currentAmbiguousRateJobs.length > 0) {
|
|
265
|
+
thresholdResult.conclusion = "fail";
|
|
266
|
+
thresholdResult.failures.push(`${runnerRateWarnings.currentAmbiguousRateJobs.length} current job(s) used ambiguous runner rates.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function maybeWritePrComment({ token, context, report, marker, mode, allowForkPrComments }) {
|
|
271
|
+
const prNumber = context.event.pull_request?.number;
|
|
272
|
+
if (!prNumber) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (mode === "off") {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (shouldSkipForkPrComment(context.event, allowForkPrComments)) {
|
|
281
|
+
console.warn("::warning::Skipping pull request comment because fork pull_request workflows receive a read-only GITHUB_TOKEN. Pass allow-fork-pr-comments with a write-capable custom token to opt in. The report is still available in the step summary.");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await writePrComment({
|
|
287
|
+
token,
|
|
288
|
+
owner: context.owner,
|
|
289
|
+
repo: context.repo,
|
|
290
|
+
issueNumber: prNumber,
|
|
291
|
+
body: report,
|
|
292
|
+
marker,
|
|
293
|
+
mode
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.warn(`::warning::Could not write pull request comment: ${escapeAnnotation(String(error.message ?? error))}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function shouldSkipForkPrComment(event, allowForkPrComments = false) {
|
|
301
|
+
return !allowForkPrComments && isReadOnlyForkPullRequest(event);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function parseRepository(value) {
|
|
305
|
+
const parts = String(value ?? "").split("/");
|
|
306
|
+
const [owner, repo] = parts;
|
|
307
|
+
if (parts.length !== 2 || !owner || !repo) {
|
|
308
|
+
throw new Error("GITHUB_REPOSITORY must be set as owner/repo.");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { owner, repo };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function readEvent() {
|
|
315
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
316
|
+
if (!eventPath) {
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(readFileSync(eventPath, "utf8"));
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.warn(`::warning::Could not read GitHub event payload: ${escapeAnnotation(String(error.message ?? error))}`);
|
|
324
|
+
return {};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function isReadOnlyForkPullRequest(event) {
|
|
329
|
+
if (isPullRequestTargetEvent()) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return isForkPullRequestEvent(event) && hasReadOnlyForkToken(headRepositoryName(event), baseRepositoryName(event));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isPullRequestTargetEvent() {
|
|
337
|
+
return process.env.GITHUB_EVENT_NAME === "pull_request_target";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function headRepositoryName(event) {
|
|
341
|
+
return event.pull_request?.head?.repo?.full_name ?? "";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function baseRepositoryName(event) {
|
|
345
|
+
return event.repository?.full_name ?? "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isForkPullRequestEvent(event) {
|
|
349
|
+
return Boolean(event.pull_request);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasReadOnlyForkToken(headRepo, baseRepo) {
|
|
353
|
+
return Boolean(baseRepo && (!headRepo || headRepo !== baseRepo));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function writeSummary(markdown) {
|
|
357
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
358
|
+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(markdown);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function setOutputs(diff, thresholdResult) {
|
|
365
|
+
setOutput("current-cost", diff.currentCost.toFixed(4));
|
|
366
|
+
setOutput("baseline-cost", diff.baselineCost.toFixed(4));
|
|
367
|
+
setOutput("delta-cost", diff.deltaCost.toFixed(4));
|
|
368
|
+
setOutput("delta-percent", diff.deltaPercent === null ? "" : diff.deltaPercent.toFixed(2));
|
|
369
|
+
setOutput("current-minutes", String(diff.currentMinutes));
|
|
370
|
+
setOutput("baseline-minutes", String(diff.baselineMinutes));
|
|
371
|
+
setOutput("conclusion", thresholdResult.conclusion);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function setOutput(name, value) {
|
|
375
|
+
if (!process.env.GITHUB_OUTPUT) {
|
|
376
|
+
console.log(`${name}=${value}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const delimiter = `ci_cost_diff_${name}_${Date.now()}`;
|
|
381
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function withRunLabel(jobs, runLabel) {
|
|
385
|
+
return jobs.map((job) => ({ ...job, runLabel }));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function writePrComment({
|
|
389
|
+
token,
|
|
390
|
+
owner,
|
|
391
|
+
repo,
|
|
392
|
+
issueNumber,
|
|
393
|
+
body,
|
|
394
|
+
marker = commentMarker(),
|
|
395
|
+
mode
|
|
396
|
+
}) {
|
|
397
|
+
if (mode === "always") {
|
|
398
|
+
await createIssueComment({ token, owner, repo, issueNumber, body });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await writeUpdateModePrComment({ token, owner, repo, issueNumber, body, marker });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function writeUpdateModePrComment({ token, owner, repo, issueNumber, body, marker }) {
|
|
406
|
+
const comments = await listIssueComments({ token, owner, repo, issueNumber });
|
|
407
|
+
const authenticatedUser = await getAuthenticatedUser({ token }).catch(() => null);
|
|
408
|
+
const matches = findUpdatableComments(comments, marker, authenticatedUser);
|
|
409
|
+
const existing = matches.at(-1) ?? null;
|
|
410
|
+
|
|
411
|
+
if (await updateExistingCommentIfPresent({ token, owner, repo, body, existing, duplicates: matches.slice(0, -1) })) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (shouldSkipDuplicateAppMarkerComment(comments, marker, authenticatedUser)) {
|
|
416
|
+
console.warn("::warning::Could not resolve token identity, so the existing GitHub App marker comment was left unchanged to avoid creating duplicates.");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const created = await createIssueComment({ token, owner, repo, issueNumber, body });
|
|
421
|
+
await cleanupCreatedCommentDuplicates({ token, owner, repo, issueNumber, marker, authenticatedUser, createdCommentId: created?.id });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function updateExistingCommentIfPresent({ token, owner, repo, body, existing, duplicates }) {
|
|
425
|
+
if (!existing || !await updateExistingComment({ token, owner, repo, commentId: existing.id, body })) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await deleteDuplicateComments({ token, owner, repo, comments: duplicates });
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function updateExistingComment({ token, owner, repo, commentId, body }) {
|
|
434
|
+
try {
|
|
435
|
+
await updateIssueComment({ token, owner, repo, commentId, body });
|
|
436
|
+
return true;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (isGitHubNotFoundError(error)) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isGitHubNotFoundError(error) {
|
|
447
|
+
return / failed with 404:/.test(String(error?.message ?? error));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function shouldSkipDuplicateAppMarkerComment(comments, marker, authenticatedUser) {
|
|
451
|
+
return !authenticatedUser?.login && hasAppMarkerComment(comments, marker);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function hasAppMarkerComment(comments, marker) {
|
|
455
|
+
return comments.some((comment) => Boolean(validIssueCommentId(comment.id))
|
|
456
|
+
&& comment.body?.includes(marker)
|
|
457
|
+
&& isAppAuthoredComment(comment));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isAppAuthoredComment(comment) {
|
|
461
|
+
return Boolean(String(comment.performed_via_github_app?.slug ?? "").trim())
|
|
462
|
+
|| comment.user?.login === "github-actions[bot]";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function cleanupCreatedCommentDuplicates({ token, owner, repo, issueNumber, marker, authenticatedUser, createdCommentId }) {
|
|
466
|
+
if (!authenticatedUser?.login) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const createdId = validIssueCommentId(createdCommentId);
|
|
471
|
+
|
|
472
|
+
for (let attempt = 1; attempt <= POST_CREATE_CLEANUP_ATTEMPTS; attempt += 1) {
|
|
473
|
+
const comments = await listIssueComments({ token, owner, repo, issueNumber });
|
|
474
|
+
const matches = findUpdatableComments(comments, marker, authenticatedUser);
|
|
475
|
+
const duplicates = postCreateDuplicateComments(matches, createdId);
|
|
476
|
+
await deleteDuplicateComments({ token, owner, repo, comments: duplicates });
|
|
477
|
+
|
|
478
|
+
if (duplicates.length > 0 || attempt === POST_CREATE_CLEANUP_ATTEMPTS) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
await sleep(POST_CREATE_CLEANUP_DELAY_MS);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function postCreateDuplicateComments(matches, createdId) {
|
|
487
|
+
if (createdId) {
|
|
488
|
+
return matches.filter((comment) => validIssueCommentId(comment.id) !== createdId);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return matches.slice(0, -1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function deleteDuplicateComments({ token, owner, repo, comments }) {
|
|
495
|
+
for (const comment of comments) {
|
|
496
|
+
const commentId = validIssueCommentId(comment.id);
|
|
497
|
+
if (!commentId) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
await deleteIssueComment({ token, owner, repo, commentId });
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.warn(`::warning::Could not delete duplicate pull request comment ${commentId}: ${escapeAnnotation(String(error.message ?? error))}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (isActionEntrypoint()) {
|
|
510
|
+
main().catch(handleMainError);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isActionEntrypoint() {
|
|
514
|
+
return process.argv[1]
|
|
515
|
+
? pathToFileURL(process.argv[1]).href === import.meta.url
|
|
516
|
+
: false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function handleMainError(error) {
|
|
520
|
+
console.error(`::error::${escapeAnnotation(String(error.message ?? error))}`);
|
|
521
|
+
process.exitCode = 1;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function escapeAnnotation(value) {
|
|
525
|
+
return value
|
|
526
|
+
.replace(/%/g, "%25")
|
|
527
|
+
.replace(/\r/g, "%0D")
|
|
528
|
+
.replace(/\n/g, "%0A");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function sleep(ms) {
|
|
532
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
533
|
+
}
|
package/src/comments.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal GitHub user shape used for comment ownership checks.
|
|
3
|
+
* @typedef {object} GitHubUser
|
|
4
|
+
* @property {string} [login] GitHub login.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal GitHub App shape used for Actions-authored comments.
|
|
9
|
+
* @typedef {object} GitHubApp
|
|
10
|
+
* @property {string} [slug] GitHub App slug.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Issue comment fields used when replacing an existing report comment.
|
|
15
|
+
* @typedef {object} IssueComment
|
|
16
|
+
* @property {string|number} [id] Issue comment id.
|
|
17
|
+
* @property {string} [body] Comment body.
|
|
18
|
+
* @property {GitHubUser} [user] Comment author.
|
|
19
|
+
* @property {GitHubApp} [performed_via_github_app] App that created the comment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Finds the newest marker comment that belongs to the authenticated author.
|
|
24
|
+
* When the token identity cannot be resolved, no existing comment is considered
|
|
25
|
+
* safe to update because the public marker alone is not an ownership proof.
|
|
26
|
+
* @param {IssueComment[]} comments Issue comments from GitHub.
|
|
27
|
+
* @param {string} marker Stable marker embedded in report comments.
|
|
28
|
+
* @param {GitHubUser|null} [authenticatedUser=null] Authenticated token user, when available.
|
|
29
|
+
* @returns {IssueComment|null} Newest updatable comment, or null.
|
|
30
|
+
*/
|
|
31
|
+
export function findUpdatableComment(comments, marker, authenticatedUser = null) {
|
|
32
|
+
return findUpdatableComments(comments, marker, authenticatedUser).at(-1) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Finds all marker comments that are safe for this action run to update.
|
|
37
|
+
* @param {IssueComment[]} comments Issue comments from GitHub.
|
|
38
|
+
* @param {string} marker Stable marker embedded in report comments.
|
|
39
|
+
* @param {GitHubUser|null} [authenticatedUser=null] Authenticated token user, when available.
|
|
40
|
+
* @returns {IssueComment[]} Updatable comments in API order.
|
|
41
|
+
*/
|
|
42
|
+
export function findUpdatableComments(comments, marker, authenticatedUser = null) {
|
|
43
|
+
return comments.filter((comment) => isUpdatableComment(comment, marker, authenticatedUser));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalizes a usable GitHub issue comment id.
|
|
48
|
+
* @param {unknown} value Raw comment id.
|
|
49
|
+
* @returns {string} Numeric id string, or an empty string when unusable.
|
|
50
|
+
*/
|
|
51
|
+
export function validIssueCommentId(value) {
|
|
52
|
+
const id = String(value ?? "").trim();
|
|
53
|
+
return /^[0-9]+$/.test(id) ? id : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks whether a comment is safe for this action run to update.
|
|
58
|
+
* @param {IssueComment} comment Issue comment from GitHub.
|
|
59
|
+
* @param {string} marker Stable marker embedded in report comments.
|
|
60
|
+
* @param {GitHubUser|null} [authenticatedUser=null] Authenticated token user, when available.
|
|
61
|
+
* @returns {boolean} True when the comment has the marker and matching author.
|
|
62
|
+
*/
|
|
63
|
+
export function isUpdatableComment(comment, marker, authenticatedUser = null) {
|
|
64
|
+
return Boolean(validIssueCommentId(comment.id))
|
|
65
|
+
&& commentHasMarker(comment, marker)
|
|
66
|
+
&& commentAuthorMatches(comment, authenticatedUser);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function commentHasMarker(comment, marker) {
|
|
70
|
+
return Boolean(comment.body?.includes(marker));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commentAuthorMatches(comment, authenticatedUser) {
|
|
74
|
+
const authorLogin = comment.user?.login;
|
|
75
|
+
const authenticatedLogin = authenticatedUser?.login;
|
|
76
|
+
|
|
77
|
+
return Boolean(authenticatedLogin && authorLogin === authenticatedLogin);
|
|
78
|
+
}
|