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/report.js ADDED
@@ -0,0 +1,258 @@
1
+ import { createHash } from "node:crypto";
2
+ import { formatPercent, formatUsd } from "./cost.js";
3
+
4
+ const COMMENT_MARKER_ID = "ci-cost-diff-action";
5
+ const MARKER_SCOPE_MAX_LENGTH = 120;
6
+ const MARKER_SCOPE_HASH_LENGTH = 12;
7
+ const MAX_REPORT_LENGTH = 60000;
8
+ const MAX_REPORT_FIELD_LENGTH = 500;
9
+ const MAX_LINK_DESTINATION_LENGTH = 1000;
10
+ const REPORT_TRUNCATION_NOTICE = `\n\n> Report truncated because it exceeded ${MAX_REPORT_LENGTH} characters. Reduce job and runner field sizes or exclusions for more detail.\n`;
11
+
12
+ /**
13
+ * Minimal workflow run reference used in Markdown reports.
14
+ * @typedef {object} RunRef
15
+ * @property {string|number} [id] Workflow run id.
16
+ * @property {string|number} [run_number] Human-readable workflow run number.
17
+ * @property {string} [head_branch] Branch name associated with the run.
18
+ * @property {string} [html_url] Browser URL for the run.
19
+ */
20
+
21
+ /**
22
+ * Report rendering inputs.
23
+ * @typedef {object} RenderReportOptions
24
+ * @property {import("./cost.js").CostDiff} diff Current-versus-baseline cost diff.
25
+ * @property {RunRef} currentRun Current workflow run reference.
26
+ * @property {RunRef|null} baselineRun Baseline workflow run reference, or null when absent.
27
+ * @property {import("./cost.js").ThresholdResult} thresholdResult Budget threshold result.
28
+ * @property {import("./cost.js").AnalyzedJob[]} [unknownJobs] Jobs with unknown runner rates.
29
+ * @property {import("./cost.js").AnalyzedJob[]} [ambiguousRateJobs] Jobs with ambiguous default-rate fallback.
30
+ * @property {string} [marker] Stable HTML marker embedded for PR comment updates.
31
+ */
32
+
33
+ /**
34
+ * Returns the stable marker used to find existing PR comments.
35
+ * @param {string} [scope=""] Optional stable scope for one workflow/action instance.
36
+ * @returns {string} HTML comment marker.
37
+ */
38
+ export function commentMarker(scope = "") {
39
+ const normalizedScope = markerScope(scope);
40
+ return normalizedScope
41
+ ? `<!-- ${COMMENT_MARKER_ID}:${normalizedScope} -->`
42
+ : `<!-- ${COMMENT_MARKER_ID} -->`;
43
+ }
44
+
45
+ function markerScope(scope) {
46
+ const rawScope = String(scope ?? "").trim();
47
+ if (!rawScope) {
48
+ return "";
49
+ }
50
+
51
+ const digest = createHash("sha256").update(rawScope).digest("hex").slice(0, MARKER_SCOPE_HASH_LENGTH);
52
+ const maxPrefixLength = MARKER_SCOPE_MAX_LENGTH - MARKER_SCOPE_HASH_LENGTH - 1;
53
+ const prefix = rawScope
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_.-]+/g, "-")
56
+ .replace(/^-+|-+$/g, "")
57
+ .slice(0, maxPrefixLength)
58
+ .replace(/-+$/g, "");
59
+
60
+ return prefix ? `${prefix}-${digest}` : digest;
61
+ }
62
+
63
+ /**
64
+ * Renders the Markdown report written to the step summary and PR comments.
65
+ * @param {RenderReportOptions} options Report rendering inputs.
66
+ * @returns {string} Markdown report with trailing newline.
67
+ */
68
+ export function renderReport({ diff, currentRun, baselineRun, thresholdResult, unknownJobs = [], ambiguousRateJobs = [], marker = commentMarker() }) {
69
+ const lines = [
70
+ marker,
71
+ "# CI Cost Diff",
72
+ "",
73
+ renderConclusion(thresholdResult),
74
+ "",
75
+ "## Summary",
76
+ "",
77
+ "| Metric | Current | Baseline | Delta |",
78
+ "| --- | ---: | ---: | ---: |",
79
+ `| Estimated cost | ${formatUsd(diff.currentCost)} | ${formatUsd(diff.baselineCost)} | ${formatUsd(diff.deltaCost)} (${formatPercent(diff.deltaPercent)}) |`,
80
+ `| Rounded billable minutes | ${diff.currentMinutes} | ${diff.baselineMinutes} | ${signedNumber(diff.deltaMinutes)} (${formatPercent(diff.deltaMinutesPercent)}) |`,
81
+ "",
82
+ "## Compared Runs",
83
+ "",
84
+ `- Current: ${formatRun(currentRun)}`,
85
+ `- Baseline: ${baselineRun ? formatRun(baselineRun) : "not found"}`,
86
+ "",
87
+ "## Largest Job Deltas",
88
+ "",
89
+ "| Job | Current | Baseline | Delta | Minutes |",
90
+ "| --- | ---: | ---: | ---: | ---: |"
91
+ ];
92
+
93
+ lines.push(
94
+ ...renderJobDeltaRows(diff.jobs),
95
+ ...renderUnknownRunnerRates(unknownJobs),
96
+ ...renderAmbiguousRunnerRates(ambiguousRateJobs),
97
+ ...renderBudgetFailures(thresholdResult.failures),
98
+ "",
99
+ "> Estimates use rounded job minutes and runner list prices. Public repositories, included minutes, custom runner contracts, and larger-runner labels can affect real billing."
100
+ );
101
+
102
+ return capReportLength(`${lines.join("\n")}\n`);
103
+ }
104
+
105
+ function renderJobDeltaRows(jobs) {
106
+ if (jobs.length === 0) {
107
+ return ["| No comparable jobs | $0.00 | $0.00 | $0.00 | 0/0 |"];
108
+ }
109
+
110
+ return limitedRows(jobs, 12, (job) => `| ${escapeTableCell(job.name)} | ${formatUsd(job.currentCost)} | ${formatUsd(job.baselineCost)} | ${formatUsd(job.deltaCost)} (${formatPercent(job.deltaPercent)}) | ${job.currentMinutes}/${job.baselineMinutes} |`);
111
+ }
112
+
113
+ function renderUnknownRunnerRates(jobs) {
114
+ return renderRunnerRateJobs({
115
+ jobs,
116
+ heading: "Unknown Runner Rates",
117
+ description: "These jobs were counted as `$0.00` because their runner could not be mapped to a rate. Add `runner-rates` overrides for custom or larger runners.",
118
+ header: "| Run | Job | Labels | Runner |",
119
+ divider: "| --- | --- | --- | --- |",
120
+ row: (job) => `| ${runLabel(job)} | ${escapeTableCell(job.name)} | ${escapeTableCell(job.labels.join(", "))} | ${escapeTableCell(job.runnerName || "unknown")} |`
121
+ });
122
+ }
123
+
124
+ function renderAmbiguousRunnerRates(jobs) {
125
+ return renderRunnerRateJobs({
126
+ jobs,
127
+ heading: "Ambiguous Runner Rates",
128
+ description: "These jobs used a standard-runner fallback or broad class override, but their labels look custom or larger-runner-like. Add specific `runner-rates` overrides for accurate billing.",
129
+ header: "| Run | Job | SKU Used | Labels |",
130
+ divider: "| --- | --- | --- | --- |",
131
+ row: (job) => `| ${runLabel(job)} | ${escapeTableCell(job.name)} | ${escapeTableCell(job.sku)} | ${escapeTableCell(job.labels.join(", "))} |`
132
+ });
133
+ }
134
+
135
+ function renderRunnerRateJobs({ jobs, heading, description, header, divider, row }) {
136
+ if (jobs.length === 0) {
137
+ return [];
138
+ }
139
+
140
+ return [
141
+ "",
142
+ `## ${heading}`,
143
+ "",
144
+ description,
145
+ "",
146
+ header,
147
+ divider,
148
+ ...limitedRows(jobs, 20, row)
149
+ ];
150
+ }
151
+
152
+ function limitedRows(items, limit, row) {
153
+ const rows = items.slice(0, limit).map(row);
154
+ return items.length > limit
155
+ ? [...rows, `> ... and ${items.length - limit} more job(s) not shown.`]
156
+ : rows;
157
+ }
158
+
159
+ function renderBudgetFailures(failures) {
160
+ if (failures.length === 0) {
161
+ return [];
162
+ }
163
+
164
+ return ["", "## Budget Failures", "", ...failures.map((failure) => `- ${failure}`)];
165
+ }
166
+
167
+ function capReportLength(report) {
168
+ if (report.length <= MAX_REPORT_LENGTH) {
169
+ return report;
170
+ }
171
+
172
+ const bodyLength = MAX_REPORT_LENGTH - REPORT_TRUNCATION_NOTICE.length;
173
+ return `${report.slice(0, bodyLength).trimEnd()}${REPORT_TRUNCATION_NOTICE}`;
174
+ }
175
+
176
+ function runLabel(job) {
177
+ return escapeTableCell(job.runLabel ?? "current");
178
+ }
179
+
180
+ function renderConclusion(thresholdResult) {
181
+ if (thresholdResult.conclusion === "fail") {
182
+ return "**Conclusion:** budget check failed.";
183
+ }
184
+
185
+ return "**Conclusion:** budget check passed.";
186
+ }
187
+
188
+ function formatRun(run) {
189
+ if (!run) {
190
+ return "not found";
191
+ }
192
+
193
+ const label = `#${escapeInlineMarkdown(run.run_number ?? run.id)}`;
194
+ const branch = run.head_branch ? ` on ${codeSpan(run.head_branch)}` : "";
195
+ const link = run.html_url ? ` ([open](${linkDestination(run.html_url)}))` : "";
196
+ return `${label}${branch}${link}`;
197
+ }
198
+
199
+ function linkDestination(value) {
200
+ const url = truncateText(value, MAX_LINK_DESTINATION_LENGTH).replace(/\r?\n/g, "").replace(/</g, "%3C").replace(/>/g, "%3E");
201
+ return `<${url}>`;
202
+ }
203
+
204
+ function signedNumber(value) {
205
+ return value > 0 ? `+${value}` : String(value);
206
+ }
207
+
208
+ function escapeTableCell(value) {
209
+ return truncateText(value)
210
+ .replace(/&/g, "&amp;")
211
+ .replace(/\r?\n/g, " ")
212
+ .replace(/\|/g, "&#124;")
213
+ .replace(/</g, "&lt;")
214
+ .replace(/>/g, "&gt;")
215
+ .replace(/\\/g, "&#92;")
216
+ .replace(/`/g, "&#96;")
217
+ .replace(/!/g, "&#33;")
218
+ .replace(/\*/g, "&#42;")
219
+ .replace(/_/g, "&#95;")
220
+ .replace(/~/g, "&#126;")
221
+ .replace(/\[/g, "&#91;")
222
+ .replace(/\]/g, "&#93;")
223
+ .replace(/\(/g, "&#40;")
224
+ .replace(/\)/g, "&#41;");
225
+ }
226
+
227
+ function escapeInlineMarkdown(value) {
228
+ return truncateText(value)
229
+ .replace(/\r?\n/g, " ")
230
+ .replace(/&/g, "&amp;")
231
+ .replace(/</g, "&lt;")
232
+ .replace(/>/g, "&gt;")
233
+ .replace(/\\/g, "&#92;")
234
+ .replace(/`/g, "&#96;")
235
+ .replace(/!/g, "&#33;")
236
+ .replace(/\*/g, "&#42;")
237
+ .replace(/_/g, "&#95;")
238
+ .replace(/~/g, "&#126;")
239
+ .replace(/\[/g, "&#91;")
240
+ .replace(/\]/g, "&#93;")
241
+ .replace(/\(/g, "&#40;")
242
+ .replace(/\)/g, "&#41;");
243
+ }
244
+
245
+ function codeSpan(value) {
246
+ const text = truncateText(value).replace(/\r?\n/g, " ");
247
+ const backtickRuns = text.match(/`+/g) ?? [];
248
+ const delimiterLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
249
+ const delimiter = "`".repeat(delimiterLength);
250
+ const padding = text.startsWith("`") || text.endsWith("`") || text.startsWith(" ") || text.endsWith(" ") ? " " : "";
251
+
252
+ return `${delimiter}${padding}${text}${padding}${delimiter}`;
253
+ }
254
+
255
+ function truncateText(value, limit = MAX_REPORT_FIELD_LENGTH) {
256
+ const text = String(value ?? "");
257
+ return text.length > limit ? `${text.slice(0, limit)}...` : text;
258
+ }