Skip to content

Commit fdb9beb

Browse files
authored
Deduplicate skip-query gate logic for setup action guards (#43480)
1 parent b8641b2 commit fdb9beb

4 files changed

Lines changed: 199 additions & 95 deletions

File tree

actions/setup/js/check_skip_if_helpers.cjs

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

4+
const { getErrorMessage } = require("./error_helpers.cjs");
5+
const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
6+
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
7+
48
/**
59
* Builds the GitHub search query, optionally scoping it to the current repository.
610
* @param {string} skipQuery - The base query string
@@ -18,4 +22,79 @@ function buildSearchQuery(skipQuery, skipScope) {
1822
return searchQuery;
1923
}
2024

21-
module.exports = { buildSearchQuery };
25+
/**
26+
* Shared runner for skip-if query gates.
27+
* @param {{
28+
* skipQuery: string | undefined;
29+
* workflowName: string | undefined;
30+
* thresholdStr: string | undefined;
31+
* thresholdEnvVar: string;
32+
* thresholdLabel: string;
33+
* checkLabel: string;
34+
* outputName: string;
35+
* skipScope: string | undefined;
36+
* shouldSkip: (totalCount: number, threshold: number) => boolean;
37+
* warningMessage: (totalCount: number, threshold: number) => string;
38+
* successMessage: (totalCount: number, threshold: number) => string;
39+
* denialSummaryMessage: (totalCount: number, threshold: number) => string;
40+
* denialSummaryNextStep: string;
41+
* }} options
42+
*/
43+
// Ambient globals provided by @actions/github-script: core, github, context
44+
async function runSkipQueryGate(options) {
45+
// prettier-ignore
46+
const {
47+
skipQuery, workflowName, thresholdStr,
48+
thresholdEnvVar, thresholdLabel, checkLabel, outputName, skipScope,
49+
shouldSkip, warningMessage, successMessage,
50+
denialSummaryMessage, denialSummaryNextStep,
51+
} = options;
52+
53+
if (!skipQuery) {
54+
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`);
55+
return;
56+
}
57+
58+
if (!workflowName) {
59+
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`);
60+
return;
61+
}
62+
63+
core.info(`Running ${checkLabel} gate for workflow: ${workflowName}`);
64+
65+
const threshold = parseInt(thresholdStr ?? "", 10);
66+
if (isNaN(threshold) || threshold < 1) {
67+
core.setFailed(`${ERR_CONFIG}: Configuration error: ${thresholdEnvVar} must be a positive integer, got "${thresholdStr}".`);
68+
return;
69+
}
70+
71+
core.info(`Checking ${checkLabel} query: ${skipQuery}`);
72+
core.info(`${thresholdLabel}: ${threshold}`);
73+
74+
const searchQuery = buildSearchQuery(skipQuery, skipScope);
75+
76+
try {
77+
const {
78+
data: { total_count: totalCount },
79+
} = await github.rest.search.issuesAndPullRequests({
80+
q: searchQuery,
81+
per_page: 1,
82+
});
83+
84+
core.info(`Search found ${totalCount} matching items`);
85+
86+
if (shouldSkip(totalCount, threshold)) {
87+
core.warning(warningMessage(totalCount, threshold));
88+
core.setOutput(outputName, "false");
89+
await writeDenialSummary(denialSummaryMessage(totalCount, threshold), denialSummaryNextStep);
90+
return;
91+
}
92+
93+
core.info(successMessage(totalCount, threshold));
94+
core.setOutput(outputName, "true");
95+
} catch (error) {
96+
core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`);
97+
}
98+
}
99+
100+
module.exports = { buildSearchQuery, runSkipQueryGate };

actions/setup/js/check_skip_if_helpers.test.cjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,90 @@ describe("check_skip_if_helpers.cjs - buildSearchQuery", () => {
109109
});
110110
});
111111
});
112+
113+
describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => {
114+
/** @returns {Promise<{runSkipQueryGate: (options: any) => Promise<void>}>} */
115+
const loadModule = () => import("./check_skip_if_helpers.cjs");
116+
117+
/** @type {Record<string, any>} */
118+
const baseOptions = {
119+
skipQuery: "is:issue is:open",
120+
workflowName: "test-workflow",
121+
thresholdStr: "1",
122+
thresholdEnvVar: "GH_AW_SKIP_MAX_MATCHES",
123+
thresholdLabel: "Maximum matches threshold",
124+
checkLabel: "skip-if-match",
125+
outputName: "skip_check_ok",
126+
skipScope: undefined,
127+
shouldSkip: (/** @type {number} */ totalCount, /** @type {number} */ threshold) => totalCount >= threshold,
128+
warningMessage: () => "skip warning",
129+
successMessage: () => "success message",
130+
denialSummaryMessage: () => "summary message",
131+
denialSummaryNextStep: "summary next step",
132+
};
133+
134+
beforeEach(() => {
135+
global.github = {
136+
rest: {
137+
search: {
138+
issuesAndPullRequests: vi.fn(),
139+
},
140+
},
141+
};
142+
});
143+
144+
it("fails with config error when skipQuery is undefined", async () => {
145+
const { runSkipQueryGate } = await loadModule();
146+
await runSkipQueryGate({ ...baseOptions, skipQuery: undefined });
147+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_SKIP_QUERY not specified"));
148+
expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
149+
});
150+
151+
it("fails with config error when workflowName is undefined", async () => {
152+
const { runSkipQueryGate } = await loadModule();
153+
await runSkipQueryGate({ ...baseOptions, workflowName: undefined });
154+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_WORKFLOW_NAME not specified"));
155+
expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
156+
});
157+
158+
it("uses thresholdEnvVar in validation errors", async () => {
159+
const { runSkipQueryGate } = await loadModule();
160+
await runSkipQueryGate({ ...baseOptions, thresholdStr: "invalid" });
161+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_SKIP_MAX_MATCHES must be a positive integer"));
162+
expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
163+
});
164+
165+
it("applies policy decision and output name when skip condition matches", async () => {
166+
global.github.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { total_count: 2 } });
167+
const { runSkipQueryGate } = await loadModule();
168+
169+
await runSkipQueryGate({ ...baseOptions });
170+
171+
expect(mockCore.warning).toHaveBeenCalledWith("skip warning");
172+
expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "false");
173+
expect(mockCore.summary.addRaw).toHaveBeenCalled();
174+
expect(mockCore.summary.write).toHaveBeenCalled();
175+
});
176+
177+
it("sets output to true and logs success when skip condition is not met", async () => {
178+
global.github.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { total_count: 0 } });
179+
const { runSkipQueryGate } = await loadModule();
180+
const successMessage = vi.fn(() => "all clear");
181+
182+
await runSkipQueryGate({ ...baseOptions, successMessage });
183+
184+
expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "true");
185+
expect(successMessage).toHaveBeenCalledWith(0, 1);
186+
expect(mockCore.warning).not.toHaveBeenCalled();
187+
});
188+
189+
it("calls setFailed with ERR_API prefix when search throws", async () => {
190+
global.github.rest.search.issuesAndPullRequests.mockRejectedValue(new Error("rate limited"));
191+
const { runSkipQueryGate } = await loadModule();
192+
193+
await runSkipQueryGate({ ...baseOptions });
194+
195+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("ERR_API"));
196+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("rate limited"));
197+
});
198+
});
Lines changed: 16 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,26 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

4-
const { getErrorMessage } = require("./error_helpers.cjs");
5-
const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
6-
const { buildSearchQuery } = require("./check_skip_if_helpers.cjs");
7-
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
4+
const { runSkipQueryGate } = require("./check_skip_if_helpers.cjs");
85

96
async function main() {
107
const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MAX_MATCHES: maxMatchesStr = "1", GH_AW_SKIP_SCOPE: skipScope } = process.env;
118

12-
if (!skipQuery) {
13-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`);
14-
return;
15-
}
16-
17-
if (!workflowName) {
18-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`);
19-
return;
20-
}
21-
22-
const maxMatches = parseInt(maxMatchesStr, 10);
23-
if (isNaN(maxMatches) || maxMatches < 1) {
24-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_MAX_MATCHES must be a positive integer, got "${maxMatchesStr}".`);
25-
return;
26-
}
27-
28-
core.info(`Checking skip-if-match query: ${skipQuery}`);
29-
core.info(`Maximum matches threshold: ${maxMatches}`);
30-
31-
const searchQuery = buildSearchQuery(skipQuery, skipScope);
32-
33-
try {
34-
const {
35-
data: { total_count: totalCount },
36-
} = await github.rest.search.issuesAndPullRequests({
37-
q: searchQuery,
38-
per_page: 1,
39-
});
40-
41-
core.info(`Search found ${totalCount} matching items`);
42-
43-
if (totalCount >= maxMatches) {
44-
core.warning(`🔍 Skip condition matched (${totalCount} items found, threshold: ${maxMatches}). Workflow execution will be prevented by activation job.`);
45-
core.setOutput("skip_check_ok", "false");
46-
await writeDenialSummary(`Skip-if-match query matched: ${totalCount} item(s) found (threshold: ${maxMatches}).`, "Update `on.skip-if-match:` in the workflow frontmatter if this skip was unexpected.");
47-
return;
48-
}
49-
50-
core.info(`✓ Found ${totalCount} matches (below threshold of ${maxMatches}), workflow can proceed`);
51-
core.setOutput("skip_check_ok", "true");
52-
} catch (error) {
53-
core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`);
54-
}
9+
await runSkipQueryGate({
10+
skipQuery,
11+
workflowName,
12+
thresholdStr: maxMatchesStr,
13+
thresholdEnvVar: "GH_AW_SKIP_MAX_MATCHES",
14+
thresholdLabel: "Maximum matches threshold",
15+
checkLabel: "skip-if-match",
16+
outputName: "skip_check_ok",
17+
skipScope,
18+
shouldSkip: (totalCount, threshold) => totalCount >= threshold,
19+
warningMessage: (totalCount, threshold) => `🔍 Skip condition matched (${totalCount} items found, threshold: ${threshold}). Workflow execution will be prevented by activation job.`,
20+
successMessage: (totalCount, threshold) => `✓ Found ${totalCount} matches (below threshold of ${threshold}), workflow can proceed`,
21+
denialSummaryMessage: (totalCount, threshold) => `Skip-if-match query matched: ${totalCount} item(s) found (threshold: ${threshold}).`,
22+
denialSummaryNextStep: "Update `on.skip-if-match:` in the workflow frontmatter if this skip was unexpected.",
23+
});
5524
}
5625

5726
module.exports = { main };
Lines changed: 16 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,26 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

4-
const { getErrorMessage } = require("./error_helpers.cjs");
5-
const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
6-
const { buildSearchQuery } = require("./check_skip_if_helpers.cjs");
7-
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
4+
const { runSkipQueryGate } = require("./check_skip_if_helpers.cjs");
85

96
async function main() {
107
const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MIN_MATCHES: minMatchesStr = "1", GH_AW_SKIP_SCOPE: skipScope } = process.env;
118

12-
if (!skipQuery) {
13-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`);
14-
return;
15-
}
16-
17-
if (!workflowName) {
18-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`);
19-
return;
20-
}
21-
22-
const minMatches = parseInt(minMatchesStr, 10);
23-
if (isNaN(minMatches) || minMatches < 1) {
24-
core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_MIN_MATCHES must be a positive integer, got "${minMatchesStr}".`);
25-
return;
26-
}
27-
28-
core.info(`Checking skip-if-no-match query: ${skipQuery}`);
29-
core.info(`Minimum matches threshold: ${minMatches}`);
30-
31-
const searchQuery = buildSearchQuery(skipQuery, skipScope);
32-
33-
try {
34-
const {
35-
data: { total_count: totalCount },
36-
} = await github.rest.search.issuesAndPullRequests({
37-
q: searchQuery,
38-
per_page: 1,
39-
});
40-
41-
core.info(`Search found ${totalCount} matching items`);
42-
43-
if (totalCount < minMatches) {
44-
core.warning(`🔍 Skip condition matched (${totalCount} items found, minimum required: ${minMatches}). Workflow execution will be prevented by activation job.`);
45-
core.setOutput("skip_no_match_check_ok", "false");
46-
await writeDenialSummary(`Skip-if-no-match query returned too few results: ${totalCount} item(s) found (minimum required: ${minMatches}).`, "Update `on.skip-if-no-match:` in the workflow frontmatter if this skip was unexpected.");
47-
return;
48-
}
49-
50-
core.info(`✓ Found ${totalCount} matches (meets or exceeds minimum of ${minMatches}), workflow can proceed`);
51-
core.setOutput("skip_no_match_check_ok", "true");
52-
} catch (error) {
53-
core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`);
54-
}
9+
await runSkipQueryGate({
10+
skipQuery,
11+
workflowName,
12+
thresholdStr: minMatchesStr,
13+
thresholdEnvVar: "GH_AW_SKIP_MIN_MATCHES",
14+
thresholdLabel: "Minimum matches threshold",
15+
checkLabel: "skip-if-no-match",
16+
outputName: "skip_no_match_check_ok",
17+
skipScope,
18+
shouldSkip: (totalCount, threshold) => totalCount < threshold,
19+
warningMessage: (totalCount, threshold) => `🔍 Skip condition matched (${totalCount} items found, minimum required: ${threshold}). Workflow execution will be prevented by activation job.`,
20+
successMessage: (totalCount, threshold) => `✓ Found ${totalCount} matches (meets or exceeds minimum of ${threshold}), workflow can proceed`,
21+
denialSummaryMessage: (totalCount, threshold) => `Skip-if-no-match query returned too few results: ${totalCount} item(s) found (minimum required: ${threshold}).`,
22+
denialSummaryNextStep: "Update `on.skip-if-no-match:` in the workflow frontmatter if this skip was unexpected.",
23+
});
5524
}
5625

5726
module.exports = { main };

0 commit comments

Comments
 (0)