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/inputs.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a GitHub Action input from its normalized environment variable name.
|
|
3
|
+
* @param {string} name Action input name from `action.yml`.
|
|
4
|
+
* @param {string} [fallback=""] Value returned when the input is absent.
|
|
5
|
+
* @returns {string} Trimmed input value or fallback.
|
|
6
|
+
*/
|
|
7
|
+
export function getInput(name, fallback = "") {
|
|
8
|
+
const candidates = [
|
|
9
|
+
`INPUT_${name.replace(/ /g, "_").toUpperCase()}`,
|
|
10
|
+
`INPUT_${name.replace(/[\s-]+/g, "_").toUpperCase()}`
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
for (const key of candidates) {
|
|
14
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
|
|
15
|
+
return process.env[key]?.trim() ?? "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses common boolean spellings used by action inputs and CLI flags.
|
|
24
|
+
* @param {unknown} value Raw value.
|
|
25
|
+
* @param {boolean} [fallback=false] Value returned for empty input.
|
|
26
|
+
* @returns {boolean} Parsed boolean.
|
|
27
|
+
*/
|
|
28
|
+
export function parseBoolean(value, fallback = false) {
|
|
29
|
+
if (value === undefined || value === null || value === "") {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const normalized = String(value).trim().toLowerCase();
|
|
34
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`Expected a boolean, got "${value}".`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parses and validates a string enum input.
|
|
47
|
+
* @param {unknown} value Raw value.
|
|
48
|
+
* @param {string[]} choices Allowed lowercase values.
|
|
49
|
+
* @param {string} fallback Value returned for empty input.
|
|
50
|
+
* @param {string} [name="value"] Name used in validation errors.
|
|
51
|
+
* @returns {string} Parsed choice.
|
|
52
|
+
*/
|
|
53
|
+
export function parseChoice(value, choices, fallback, name = "value") {
|
|
54
|
+
if (value === undefined || value === null || value === "") {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalized = String(value).trim().toLowerCase();
|
|
59
|
+
if (choices.includes(normalized)) {
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw new Error(`${name} must be one of: ${choices.join(", ")}.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parses a finite number.
|
|
68
|
+
* @param {unknown} value Raw value.
|
|
69
|
+
* @param {number} [fallback] Value returned for empty input.
|
|
70
|
+
* @returns {number|undefined} Parsed number or fallback.
|
|
71
|
+
*/
|
|
72
|
+
export function parseNumber(value, fallback = undefined) {
|
|
73
|
+
if (value === undefined || value === null || value === "") {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = Number(value);
|
|
78
|
+
if (!Number.isFinite(parsed)) {
|
|
79
|
+
throw new Error(`Expected a number, got "${value}".`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parses a finite non-negative number.
|
|
87
|
+
* @param {unknown} value Raw value.
|
|
88
|
+
* @param {number|undefined} [fallback] Value returned for empty input.
|
|
89
|
+
* @param {string} [name="value"] Name used in validation errors.
|
|
90
|
+
* @returns {number|undefined} Parsed non-negative number or fallback.
|
|
91
|
+
*/
|
|
92
|
+
export function parseNonNegativeNumber(value, fallback = undefined, name = "value") {
|
|
93
|
+
const parsed = parseNumber(value, fallback);
|
|
94
|
+
if (parsed !== undefined && parsed < 0) {
|
|
95
|
+
throw new Error(`${name} must be a non-negative number.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parses an integer.
|
|
103
|
+
* @param {unknown} value Raw value.
|
|
104
|
+
* @param {number} fallback Value returned for empty input.
|
|
105
|
+
* @returns {number} Parsed integer.
|
|
106
|
+
*/
|
|
107
|
+
export function parseInteger(value, fallback) {
|
|
108
|
+
const parsed = parseNumber(value, fallback);
|
|
109
|
+
if (!Number.isInteger(parsed)) {
|
|
110
|
+
throw new Error(`Expected an integer, got "${value}".`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parses a positive integer.
|
|
118
|
+
* @param {unknown} value Raw value.
|
|
119
|
+
* @param {number} fallback Value returned for empty input.
|
|
120
|
+
* @param {string} [name="value"] Name used in validation errors.
|
|
121
|
+
* @returns {number} Parsed positive integer.
|
|
122
|
+
*/
|
|
123
|
+
export function parsePositiveInteger(value, fallback, name = "value") {
|
|
124
|
+
const parsed = parseInteger(value, fallback);
|
|
125
|
+
if (parsed < 1) {
|
|
126
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parses a JSON object input.
|
|
134
|
+
* @param {unknown} value Raw JSON string.
|
|
135
|
+
* @param {Record<string, unknown>} [fallback] Value returned for empty input.
|
|
136
|
+
* @param {string} [name="value"] Name used in validation errors.
|
|
137
|
+
* @returns {Record<string, unknown>} Parsed object.
|
|
138
|
+
*/
|
|
139
|
+
export function parseJsonObject(value, fallback = {}, name = "value") {
|
|
140
|
+
if (!value) {
|
|
141
|
+
return fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const parsed = parseJson(value, name);
|
|
145
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
146
|
+
throw new Error(`${name} must be a JSON object.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return parsed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseJson(value, name) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(value);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new Error(`${name} must be a valid JSON object: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parses comma- or newline-separated input into non-empty strings.
|
|
162
|
+
* @param {unknown} value Raw list value.
|
|
163
|
+
* @returns {string[]} Trimmed list entries.
|
|
164
|
+
*/
|
|
165
|
+
export function parseList(value) {
|
|
166
|
+
if (!value) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return String(value)
|
|
171
|
+
.split(/[\n,]/)
|
|
172
|
+
.map((item) => item.trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Drops unevaluated workflow expressions so optional inputs can be copied verbatim.
|
|
178
|
+
* @param {string} value Raw input value.
|
|
179
|
+
* @returns {string} Empty string for unresolved expressions, otherwise the original value.
|
|
180
|
+
*/
|
|
181
|
+
export function resolvedInput(value) {
|
|
182
|
+
if (!value || value.includes("${{")) {
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return value;
|
|
187
|
+
}
|
package/src/jobs.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds a job by either its workflow job id or related check run id.
|
|
3
|
+
* @param {import("./cost.js").GitHubJob[]} jobs
|
|
4
|
+
* @param {string|number} id
|
|
5
|
+
* @returns {import("./cost.js").GitHubJob|null}
|
|
6
|
+
*/
|
|
7
|
+
export function findJobByWorkflowOrCheckRunId(jobs, id) {
|
|
8
|
+
return jobs.find((job) => jobMatchesWorkflowOrCheckRunId(job, id)) ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GitHub's jobs API exposes workflow job ids as `id`, while workflows expose
|
|
13
|
+
* `${{ job.check_run_id }}` as the related check run id in `check_run_url`.
|
|
14
|
+
* @param {import("./cost.js").GitHubJob} job
|
|
15
|
+
* @param {string|number} id
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function jobMatchesWorkflowOrCheckRunId(job, id) {
|
|
19
|
+
const expectedId = String(id ?? "");
|
|
20
|
+
if (!expectedId) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return String(job.id ?? "") === expectedId || checkRunIdFromUrl(job.check_run_url) === expectedId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extracts a check run id from a GitHub Checks API URL.
|
|
29
|
+
* @param {unknown} value URL-like value.
|
|
30
|
+
* @returns {string} Check run id, or an empty string when absent.
|
|
31
|
+
*/
|
|
32
|
+
export function checkRunIdFromUrl(value) {
|
|
33
|
+
try {
|
|
34
|
+
const segments = new URL(String(value ?? "")).pathname.split("/").filter(Boolean);
|
|
35
|
+
const index = segments.lastIndexOf("check-runs");
|
|
36
|
+
return index === -1 ? "" : segments[index + 1] ?? "";
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|