diff --git a/actions/setup/js/check_skip_if_helpers.cjs b/actions/setup/js/check_skip_if_helpers.cjs index 1ede805da65..ed8813d5168 100644 --- a/actions/setup/js/check_skip_if_helpers.cjs +++ b/actions/setup/js/check_skip_if_helpers.cjs @@ -1,6 +1,10 @@ // @ts-check /// +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); +const { writeDenialSummary } = require("./pre_activation_summary.cjs"); + /** * Builds the GitHub search query, optionally scoping it to the current repository. * @param {string} skipQuery - The base query string @@ -18,4 +22,79 @@ function buildSearchQuery(skipQuery, skipScope) { return searchQuery; } -module.exports = { buildSearchQuery }; +/** + * Shared runner for skip-if query gates. + * @param {{ + * skipQuery: string | undefined; + * workflowName: string | undefined; + * thresholdStr: string | undefined; + * thresholdEnvVar: string; + * thresholdLabel: string; + * checkLabel: string; + * outputName: string; + * skipScope: string | undefined; + * shouldSkip: (totalCount: number, threshold: number) => boolean; + * warningMessage: (totalCount: number, threshold: number) => string; + * successMessage: (totalCount: number, threshold: number) => string; + * denialSummaryMessage: (totalCount: number, threshold: number) => string; + * denialSummaryNextStep: string; + * }} options + */ +// Ambient globals provided by @actions/github-script: core, github, context +async function runSkipQueryGate(options) { + // prettier-ignore + const { + skipQuery, workflowName, thresholdStr, + thresholdEnvVar, thresholdLabel, checkLabel, outputName, skipScope, + shouldSkip, warningMessage, successMessage, + denialSummaryMessage, denialSummaryNextStep, + } = options; + + if (!skipQuery) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`); + return; + } + + if (!workflowName) { + core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`); + return; + } + + core.info(`Running ${checkLabel} gate for workflow: ${workflowName}`); + + const threshold = parseInt(thresholdStr ?? "", 10); + if (isNaN(threshold) || threshold < 1) { + core.setFailed(`${ERR_CONFIG}: Configuration error: ${thresholdEnvVar} must be a positive integer, got "${thresholdStr}".`); + return; + } + + core.info(`Checking ${checkLabel} query: ${skipQuery}`); + core.info(`${thresholdLabel}: ${threshold}`); + + const searchQuery = buildSearchQuery(skipQuery, skipScope); + + try { + const { + data: { total_count: totalCount }, + } = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + + core.info(`Search found ${totalCount} matching items`); + + if (shouldSkip(totalCount, threshold)) { + core.warning(warningMessage(totalCount, threshold)); + core.setOutput(outputName, "false"); + await writeDenialSummary(denialSummaryMessage(totalCount, threshold), denialSummaryNextStep); + return; + } + + core.info(successMessage(totalCount, threshold)); + core.setOutput(outputName, "true"); + } catch (error) { + core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`); + } +} + +module.exports = { buildSearchQuery, runSkipQueryGate }; diff --git a/actions/setup/js/check_skip_if_helpers.test.cjs b/actions/setup/js/check_skip_if_helpers.test.cjs index 54ac24a41b1..4e9970658d6 100644 --- a/actions/setup/js/check_skip_if_helpers.test.cjs +++ b/actions/setup/js/check_skip_if_helpers.test.cjs @@ -109,3 +109,90 @@ describe("check_skip_if_helpers.cjs - buildSearchQuery", () => { }); }); }); + +describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => { + /** @returns {Promise<{runSkipQueryGate: (options: any) => Promise}>} */ + const loadModule = () => import("./check_skip_if_helpers.cjs"); + + /** @type {Record} */ + const baseOptions = { + skipQuery: "is:issue is:open", + workflowName: "test-workflow", + thresholdStr: "1", + thresholdEnvVar: "GH_AW_SKIP_MAX_MATCHES", + thresholdLabel: "Maximum matches threshold", + checkLabel: "skip-if-match", + outputName: "skip_check_ok", + skipScope: undefined, + shouldSkip: (/** @type {number} */ totalCount, /** @type {number} */ threshold) => totalCount >= threshold, + warningMessage: () => "skip warning", + successMessage: () => "success message", + denialSummaryMessage: () => "summary message", + denialSummaryNextStep: "summary next step", + }; + + beforeEach(() => { + global.github = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + }, + }; + }); + + it("fails with config error when skipQuery is undefined", async () => { + const { runSkipQueryGate } = await loadModule(); + await runSkipQueryGate({ ...baseOptions, skipQuery: undefined }); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_SKIP_QUERY not specified")); + expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("fails with config error when workflowName is undefined", async () => { + const { runSkipQueryGate } = await loadModule(); + await runSkipQueryGate({ ...baseOptions, workflowName: undefined }); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_WORKFLOW_NAME not specified")); + expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("uses thresholdEnvVar in validation errors", async () => { + const { runSkipQueryGate } = await loadModule(); + await runSkipQueryGate({ ...baseOptions, thresholdStr: "invalid" }); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_SKIP_MAX_MATCHES must be a positive integer")); + expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("applies policy decision and output name when skip condition matches", async () => { + global.github.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { total_count: 2 } }); + const { runSkipQueryGate } = await loadModule(); + + await runSkipQueryGate({ ...baseOptions }); + + expect(mockCore.warning).toHaveBeenCalledWith("skip warning"); + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "false"); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("sets output to true and logs success when skip condition is not met", async () => { + global.github.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { total_count: 0 } }); + const { runSkipQueryGate } = await loadModule(); + const successMessage = vi.fn(() => "all clear"); + + await runSkipQueryGate({ ...baseOptions, successMessage }); + + expect(mockCore.setOutput).toHaveBeenCalledWith("skip_check_ok", "true"); + expect(successMessage).toHaveBeenCalledWith(0, 1); + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + + it("calls setFailed with ERR_API prefix when search throws", async () => { + global.github.rest.search.issuesAndPullRequests.mockRejectedValue(new Error("rate limited")); + const { runSkipQueryGate } = await loadModule(); + + await runSkipQueryGate({ ...baseOptions }); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("ERR_API")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("rate limited")); + }); +}); diff --git a/actions/setup/js/check_skip_if_match.cjs b/actions/setup/js/check_skip_if_match.cjs index fc379c91e7a..a4dc5c23810 100644 --- a/actions/setup/js/check_skip_if_match.cjs +++ b/actions/setup/js/check_skip_if_match.cjs @@ -1,57 +1,26 @@ // @ts-check /// -const { getErrorMessage } = require("./error_helpers.cjs"); -const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); -const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); -const { writeDenialSummary } = require("./pre_activation_summary.cjs"); +const { runSkipQueryGate } = require("./check_skip_if_helpers.cjs"); async function main() { 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; - if (!skipQuery) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`); - return; - } - - if (!workflowName) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`); - return; - } - - const maxMatches = parseInt(maxMatchesStr, 10); - if (isNaN(maxMatches) || maxMatches < 1) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_MAX_MATCHES must be a positive integer, got "${maxMatchesStr}".`); - return; - } - - core.info(`Checking skip-if-match query: ${skipQuery}`); - core.info(`Maximum matches threshold: ${maxMatches}`); - - const searchQuery = buildSearchQuery(skipQuery, skipScope); - - try { - const { - data: { total_count: totalCount }, - } = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - core.info(`Search found ${totalCount} matching items`); - - if (totalCount >= maxMatches) { - core.warning(`🔍 Skip condition matched (${totalCount} items found, threshold: ${maxMatches}). Workflow execution will be prevented by activation job.`); - core.setOutput("skip_check_ok", "false"); - 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."); - return; - } - - core.info(`✓ Found ${totalCount} matches (below threshold of ${maxMatches}), workflow can proceed`); - core.setOutput("skip_check_ok", "true"); - } catch (error) { - core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`); - } + await runSkipQueryGate({ + skipQuery, + workflowName, + thresholdStr: maxMatchesStr, + thresholdEnvVar: "GH_AW_SKIP_MAX_MATCHES", + thresholdLabel: "Maximum matches threshold", + checkLabel: "skip-if-match", + outputName: "skip_check_ok", + skipScope, + shouldSkip: (totalCount, threshold) => totalCount >= threshold, + warningMessage: (totalCount, threshold) => `🔍 Skip condition matched (${totalCount} items found, threshold: ${threshold}). Workflow execution will be prevented by activation job.`, + successMessage: (totalCount, threshold) => `✓ Found ${totalCount} matches (below threshold of ${threshold}), workflow can proceed`, + denialSummaryMessage: (totalCount, threshold) => `Skip-if-match query matched: ${totalCount} item(s) found (threshold: ${threshold}).`, + denialSummaryNextStep: "Update `on.skip-if-match:` in the workflow frontmatter if this skip was unexpected.", + }); } module.exports = { main }; diff --git a/actions/setup/js/check_skip_if_no_match.cjs b/actions/setup/js/check_skip_if_no_match.cjs index b3edfef7803..6d67cf8f032 100644 --- a/actions/setup/js/check_skip_if_no_match.cjs +++ b/actions/setup/js/check_skip_if_no_match.cjs @@ -1,57 +1,26 @@ // @ts-check /// -const { getErrorMessage } = require("./error_helpers.cjs"); -const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); -const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); -const { writeDenialSummary } = require("./pre_activation_summary.cjs"); +const { runSkipQueryGate } = require("./check_skip_if_helpers.cjs"); async function main() { 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; - if (!skipQuery) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`); - return; - } - - if (!workflowName) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_WORKFLOW_NAME not specified.`); - return; - } - - const minMatches = parseInt(minMatchesStr, 10); - if (isNaN(minMatches) || minMatches < 1) { - core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_MIN_MATCHES must be a positive integer, got "${minMatchesStr}".`); - return; - } - - core.info(`Checking skip-if-no-match query: ${skipQuery}`); - core.info(`Minimum matches threshold: ${minMatches}`); - - const searchQuery = buildSearchQuery(skipQuery, skipScope); - - try { - const { - data: { total_count: totalCount }, - } = await github.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 1, - }); - - core.info(`Search found ${totalCount} matching items`); - - if (totalCount < minMatches) { - core.warning(`🔍 Skip condition matched (${totalCount} items found, minimum required: ${minMatches}). Workflow execution will be prevented by activation job.`); - core.setOutput("skip_no_match_check_ok", "false"); - 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."); - return; - } - - core.info(`✓ Found ${totalCount} matches (meets or exceeds minimum of ${minMatches}), workflow can proceed`); - core.setOutput("skip_no_match_check_ok", "true"); - } catch (error) { - core.setFailed(`${ERR_API}: Failed to execute search query: ${getErrorMessage(error)}`); - } + await runSkipQueryGate({ + skipQuery, + workflowName, + thresholdStr: minMatchesStr, + thresholdEnvVar: "GH_AW_SKIP_MIN_MATCHES", + thresholdLabel: "Minimum matches threshold", + checkLabel: "skip-if-no-match", + outputName: "skip_no_match_check_ok", + skipScope, + shouldSkip: (totalCount, threshold) => totalCount < threshold, + warningMessage: (totalCount, threshold) => `🔍 Skip condition matched (${totalCount} items found, minimum required: ${threshold}). Workflow execution will be prevented by activation job.`, + successMessage: (totalCount, threshold) => `✓ Found ${totalCount} matches (meets or exceeds minimum of ${threshold}), workflow can proceed`, + denialSummaryMessage: (totalCount, threshold) => `Skip-if-no-match query returned too few results: ${totalCount} item(s) found (minimum required: ${threshold}).`, + denialSummaryNextStep: "Update `on.skip-if-no-match:` in the workflow frontmatter if this skip was unexpected.", + }); } module.exports = { main };