Deduplicate skip-query gate logic for setup action guards#43480
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR does not have the 'implementation' label and has ≤100 new lines of code in business logic directories. |
|
✅ Test Quality Sentinel completed test quality analysis. |
|
✅ PR Code Quality Reviewer completed the code quality review. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
There was a problem hiding this comment.
Pull request overview
This PR refactors the setup-action “skip query gate” logic by extracting the shared control flow into a single helper (runSkipQueryGate) and leaving the two entrypoints (skip-if-match / skip-if-no-match) as small policy wrappers. This reduces duplicated validation/search/summary wiring while preserving the existing behavior and output contracts.
Changes:
- Added
runSkipQueryGate(...)to centralize env validation, threshold parsing, query execution, output setting, and denial summary emission. - Updated
check_skip_if_match.cjsandcheck_skip_if_no_match.cjsto call the shared helper with policy-specific comparator + messaging. - Extended unit tests to cover helper behavior (threshold validation errors and “skip” decision/output routing).
Show a summary per file
| File | Description |
|---|---|
| actions/setup/js/check_skip_if_helpers.cjs | Adds runSkipQueryGate and relocates common skip gate logic into the helper module. |
| actions/setup/js/check_skip_if_match.cjs | Replaces duplicated gate logic with a policy wrapper for the “match” comparator and messages. |
| actions/setup/js/check_skip_if_no_match.cjs | Replaces duplicated gate logic with a policy wrapper for the “no match” comparator and messages. |
| actions/setup/js/check_skip_if_helpers.test.cjs | Adds targeted tests for runSkipQueryGate validation and policy-driven skip behavior. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Low
🧪 Test Quality Sentinel Report✅ Test Quality Score: 100/100 — Excellent
📊 Metrics (2 tests)
Verdict
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /tdd and /codebase-design — requesting changes on test coverage gaps and one interface clarity issue.
📋 Key Themes & Highlights
Key Themes
- Test coverage gaps: The new
runSkipQueryGatehelper has 4 untested paths out of 5 branches (success path,skipQuerymissing,workflowNamemissing, search API throws). Only the skip-condition-met path and invalid threshold are covered. - Dead validation:
workflowNameis validated at startup but never read inside the function body — either use it or remove it from the interface. - Test structure:
runSkipQueryGatedescribe block is nested inside thebuildSearchQuerydescribe block, which makes the test hierarchy misleading in reporters. - Long destructure: 13-field single-line destructure makes the only optional field (
thresholdStr) invisible without horizontal scrolling.
Positive Highlights
- ✅ Excellent refactoring: the callback-based parameterization (
shouldSkip,warningMessage, etc.) is a clean way to invert the policy without leaking gate-internal state - ✅ The two wrapper files are now minimal and easy to audit at a glance
- ✅
thresholdEnvVarpassed to the helper to produce actionable error messages — good UX detail - ✅
buildSearchQueryalready had solid test coverage and this PR leaves it intact
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 71.8 AIC · ⌖ 6.88 AIC · ⊞ 6.6K
Comment /matt to run again
| return; | ||
| } | ||
|
|
||
| if (!workflowName) { |
There was a problem hiding this comment.
[/codebase-design] workflowName is validated here but never referenced inside the function — dead validation.
💡 Fix or remove
The extracted helper checks workflowName is present but then never uses it — not in logs, not in the denial summary, nowhere. Either incorporate it (e.g., log the active workflow) or remove the field from the interface and all callers.
// Option A: use it in a log
core.info(`Running ${checkLabel} gate for workflow: ${workflowName}`);
// Option B: remove from interface and callers
// — eliminates the misleading contractA required option that is validated but silently discarded makes the interface harder to reason about.
@copilot please address this.
| * }} options | ||
| */ | ||
| async function runSkipQueryGate(options) { | ||
| const { skipQuery, workflowName, thresholdStr = "1", thresholdEnvVar, thresholdLabel, checkLabel, outputName, skipScope, shouldSkip, warningMessage, successMessage, denialSummaryMessage, denialSummaryNextStep } = options; |
There was a problem hiding this comment.
[/codebase-design] The destructure on line 44 is a single 180-character line — the interface is deep but its surface area is not visible at a glance.
💡 Consider breaking up the destructure
With 13 fields, the one-liner is hard to scan for an optional field vs a required one:
const {
skipQuery,
workflowName,
thresholdStr = "1",
thresholdEnvVar,
thresholdLabel,
checkLabel,
outputName,
skipScope,
shouldSkip,
warningMessage,
successMessage,
denialSummaryMessage,
denialSummaryNextStep,
} = options;This makes the default value for thresholdStr visible without having to scroll and clearly separates optional from required fields.
@copilot please address this.
| }); | ||
| }); | ||
|
|
||
| describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => { |
There was a problem hiding this comment.
[/tdd] The runSkipQueryGate describe block is nested inside the buildSearchQuery describe block — this looks like a scoping accident.
💡 Move to the top-level describe
The runSkipQueryGate describe group opens at line 72 inside the describe('check_skip_if_helpers.cjs - buildSearchQuery', ...) block (line 41) and closes at line 132, but the scope: default (repo-scoped) group that follows at line 134 is a child of buildSearchQuery, not a sibling of runSkipQueryGate. This makes the nesting hierarchy misleading and may cause test reporters to nest runSkipQueryGate under buildSearchQuery in their output.
Move runSkipQueryGate to the top-level, e.g.:
describe("check_skip_if_helpers.cjs - buildSearchQuery", () => { ... });
describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => { ... });@copilot please address this.
| expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("applies policy decision and output name when skip condition matches", async () => { |
There was a problem hiding this comment.
[/tdd] No test covers the success path (skip condition NOT met) — only the skip/denial branch is verified.
💡 Add a success-path test
The new runSkipQueryGate tests cover:
- Invalid threshold —
setFailedcalled, search never reached - Skip condition met —
setOutputwith"false", warning emitted
Missing: skip condition not met (totalCount below threshold). This is the most common production path and should assert setOutput(outputName, "true") and successMessage invoked:
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({
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: (count, t) => count >= t,
warningMessage: () => '',
successMessage,
denialSummaryMessage: () => '',
denialSummaryNextStep: '',
});
expect(mockCore.setOutput).toHaveBeenCalledWith('skip_check_ok', 'true');
expect(successMessage).toHaveBeenCalledWith(0, 1);
});@copilot please address this.
| }; | ||
| }); | ||
|
|
||
| it("uses thresholdEnvVar in validation errors", async () => { |
There was a problem hiding this comment.
[/tdd] No test covers the skipQuery missing or workflowName missing early-exit paths in the new runSkipQueryGate.
💡 Add early-exit guard tests
The function has three early-exit guards (missing skipQuery, missing workflowName, bad threshold). Only the third is tested. Specs for the first two document the contract and prevent regressions if the guards are ever reordered:
it('fails with config error when skipQuery is undefined', async () => {
const { runSkipQueryGate } = await loadModule();
await runSkipQueryGate({ skipQuery: undefined, workflowName: 'wf', ... });
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('GH_AW_SKIP_QUERY not specified'));
});
it('fails with config error when workflowName is undefined', async () => {
const { runSkipQueryGate } = await loadModule();
await runSkipQueryGate({ skipQuery: 'is:issue', workflowName: undefined, ... });
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('GH_AW_WORKFLOW_NAME not specified'));
});@copilot please address this.
| expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("applies policy decision and output name when skip condition matches", async () => { |
There was a problem hiding this comment.
[/tdd] No test covers the API error path (search throws) — the catch block in runSkipQueryGate is untested.
💡 Add an error-path test
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({
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: () => false,
warningMessage: () => '',
successMessage: () => '',
denialSummaryMessage: () => '',
denialSummaryNextStep: '',
});
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('ERR_API'));
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('rate limited'));
});Without this, a future refactor of the catch block could silently lose the error code.
@copilot please address this.
There was a problem hiding this comment.
Review: Deduplicate skip-query gate logic
The extraction of runSkipQueryGate into a shared helper is a clean, correct refactor — the policy wrappers are now minimal and symmetric, and the helper correctly centralises env validation, threshold parsing, search execution, and output/summary wiring. Logic and security look correct.
Two actionable issues found:
🔴 Test suite nesting bug (correctness)
The runSkipQueryGate describe block is inadvertently nested inside the outer buildSearchQuery describe block. This means Vitest/Jest reporters will emit results under a misleading path (buildSearchQuery > runSkipQueryGate > ...), and future contributors may not realize the two suites are unrelated. The scope: default describe that follows is also inside the outer block, making the existing structure more confusing after this change.
🟡 Non-blocking suggestions
- Duplicate
loadModulehelper — declared once per describe; a single shared helper at the file top would reduce duplication. - Missing success-path test — the new tests only cover the skip branch; a test for the non-skip branch (
shouldSkip = false → setOutput(outputName, "true")) would give full coverage. - Long destructure line — the options bag is unpacked in a single 170+ char line; multi-line formatting would improve diff readability.
- Implicit global ambient annotation —
core,github,contextare used as globals in the helper without annotation; the existing/// <reference types="@actions/github-script" />covers it, but a brief comment in the new function noting the runtime globals dependency would aid discoverability.
🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 69.8 AIC · ⌖ 5.35 AIC · ⊞ 4.8K
| }); | ||
| }); | ||
|
|
||
| describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => { |
There was a problem hiding this comment.
The runSkipQueryGate describe block is nested inside the outer buildSearchQuery describe block (opened at line 41), so test reports show these as buildSearchQuery > runSkipQueryGate > .... Move it to a top-level sibling to keep the two test suites independent and make failure output unambiguous.
// Close the buildSearchQuery describe block, then open a sibling:
describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => {
// ...
});@copilot please address this.
| }; | ||
| }); | ||
|
|
||
| it("uses thresholdEnvVar in validation errors", async () => { |
There was a problem hiding this comment.
The loadModule inside the runSkipQueryGate describe is declared identically to the one in the buildSearchQuery describe (line 43), but returns a different type annotation. Since CJS modules are cached, calling import() multiple times within the same test run returns the same instance — this is fine for correctness but the duplicate loadModule helper adds confusion. Consider hoisting a single shared loadModule to the file's top level, or at least document that the module cache makes repeated imports safe.
@copilot please address this.
| const { skipQuery, workflowName, thresholdStr = "1", 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.`); |
There was a problem hiding this comment.
The runSkipQueryGate function uses core, github, and context as globals — consistent with how the callers work under @actions/github-script. However, those globals are not declared or imported in this helper module, so static analysis (and IDE tooling) cannot verify them. Consider adding a brief comment referencing the expected ambient globals, or — if the module is ever tested in isolation without a global-script harness — accept them as implicit. The test file's beforeEach does set them up, so tests are fine; this is a documentation/discoverability concern only.
@copilot please address this.
| const { skipQuery, workflowName, thresholdStr = "1", 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.`); |
There was a problem hiding this comment.
The destructuring line is very long (170+ chars) — the entire options bag unpacked on one line. Breaking it into a multi-line destructure would aid readability and make diffs easier to review if individual parameters change later.
const {
skipQuery, workflowName, thresholdStr = "1",
thresholdEnvVar, thresholdLabel, checkLabel, outputName, skipScope,
shouldSkip, warningMessage, successMessage,
denialSummaryMessage, denialSummaryNextStep,
} = options;@copilot please address this.
| expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("applies policy decision and output name when skip condition matches", async () => { |
There was a problem hiding this comment.
The test "applies policy decision and output name when skip condition matches" verifies the skip path but there is no corresponding test for the success (non-skip) path — i.e., when shouldSkip returns false. Consider adding a test that asserts setOutput is called with "true" and warning is not called, to fully cover the happy path.
@copilot please address this.
There was a problem hiding this comment.
REQUEST_CHANGES — 3 issues must be addressed before merge
The refactor direction is sound, but the implementation has a correctness bug, a test structure bug, and a test coverage gap that need fixing.
### Blocking findings (3)
1. validated but never used ()
The field is required, destructured, checked for existence — then completely ignored. Either it belongs in a log line or the denial summary, or it should be removed from the options contract and stop being required from callers. As-is, the refactor has entrenched a pre-existing dead-validation bug across both call sites.
2. describe block is wrongly nested inside describe block ()
All new tests run under the wrong test suite label in CI output. The duplicate inside the nested block is a direct symptom of copy-paste under the wrong parent.
3. Skip-path test doesn't assert was called ()
/ mocks are wired but unasserted. Dropping the denial summary call in a future change will pass this test silently.
🔎 Code quality review by PR Code Quality Reviewer · 156.8 AIC · ⌖ 5.99 AIC · ⊞ 5.4K
Comment /review to run again
| return; | ||
| } | ||
|
|
||
| if (!workflowName) { |
There was a problem hiding this comment.
workflowName is validated but never used — dead validation or a latent bug from the pre-refactor code.
💡 Details
workflowName is destructured, checked with if (!workflowName), and then never referenced again anywhere in the function body. It doesn't appear in the search query, the log output, the denial summary message, or any other call.
Two possible interpretations:
- The validation is intentional but wrong: callers must supply
GH_AW_WORKFLOW_NAMEfor no functional reason — the helper ignores it entirely. - The field was meant to be used: it was likely supposed to appear in the denial summary or scoped query and was silently dropped during the refactor.
The pre-refactor code had the same pattern, but this PR cements it into a shared helper used by both call sites. Either use workflowName (e.g., in the denial summary or log line) or remove it from the options schema and eliminate the GH_AW_WORKFLOW_NAME requirement entirely.
| }); | ||
| }); | ||
|
|
||
| describe("check_skip_if_helpers.cjs - runSkipQueryGate", () => { |
There was a problem hiding this comment.
The runSkipQueryGate describe block is incorrectly nested inside the buildSearchQuery describe block, corrupting test output structure.
💡 Details
The describe('check_skip_if_helpers.cjs - runSkipQueryGate', ...) block opens at line 72 inside the describe('check_skip_if_helpers.cjs - buildSearchQuery', ...) block that opened at line 41. As a result:
- All
runSkipQueryGatetests are reported in CI under thebuildSearchQuerytest suite label, making failures misleading. - The inner
const loadModule = () => import('./check_skip_if_helpers.cjs')at line 74 is an exact duplicate of the one at line 43, shadowing it pointlessly — a clear artifact of copy-paste inside the wrong nesting level. - The
describe('scope: default (repo-scoped)', ...)block at line 134 is also still inside the outerbuildSearchQuerydescribe (correct by coincidence, but confusing).
Fix: close the outer buildSearchQuery describe before opening runSkipQueryGate, and remove the duplicate loadModule declaration:
| expect(global.github.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("applies policy decision and output name when skip condition matches", async () => { |
There was a problem hiding this comment.
The skip-path test does not assert writeDenialSummary was called — a critical side effect goes unverified.
💡 Details
The test at line 109 ('applies policy decision and output name when skip condition matches') checks core.warning and core.setOutput, but never asserts that the denial summary was written. The writeDenialSummary call at helper line 80 is a significant side effect: it writes to the GitHub Actions job summary and is the operator-visible explanation for why a workflow was blocked.
The mocks for core.summary.addRaw and core.summary.write are already in place via mockCore.summary — but they are never asserted on.
Add assertions such as:
expect(mockCore.summary.addRaw).toHaveBeenCalled();
expect(mockCore.summary.write).toHaveBeenCalled();Without this, a future regression that silently drops the writeDenialSummary call will not be caught by this test suite.
| * }} options | ||
| */ | ||
| async function runSkipQueryGate(options) { | ||
| const { skipQuery, workflowName, thresholdStr = "1", thresholdEnvVar, thresholdLabel, checkLabel, outputName, skipScope, shouldSkip, warningMessage, successMessage, denialSummaryMessage, denialSummaryNextStep } = options; |
There was a problem hiding this comment.
Double-defaulting thresholdStr creates ambiguous ownership and swallows missing-config errors silently.
💡 Details
The helper destructures thresholdStr = '1' as a default at line 44, and the callers also apply their own defaults at the env-var layer (e.g., GH_AW_SKIP_MAX_MATCHES: maxMatchesStr = '1'). This double-default means:
- If a future caller forgets to set a default (or passes
undefinedexplicitly), the threshold silently becomes1instead of triggering the config-validation error at line 57-59. - The validation gate that says "must be a positive integer" becomes unreachable for the undefined case — the most likely misconfiguration scenario.
The default policy belongs in one place. Since the threshold default is env-var-specific ("what does a missing GH_AW_SKIP_MAX_MATCHES mean?"), it should live at the caller's env-destructuring layer. Remove the default from the helper's options destructuring and let an undefined thresholdStr fall through to the isNaN check so it fails loudly:
🤖 PR Triage — Run #7
Score breakdown: impact 18 + urgency 12 + quality 14 Extracts shared
|
|
@copilot run pr-finisher skill |
- Break single-line destructure to multi-line (Prettier at printWidth:240 normalizes it; semantically correct) - Use workflowName in log line — eliminates dead validation - Remove thresholdStr default from helper (callers own their defaults) - Move runSkipQueryGate describe to top-level sibling of buildSearchQuery - Remove duplicate loadModule inside runSkipQueryGate describe - Add missing test cases: skipQuery/workflowName early-exit guards, success path (skip condition not met), API error path - Assert writeDenialSummary side effects in skip-path test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done. Changes pushed in commit helpers.cjs
test.cjs
All 16 unit tests pass locally ( |
check_skip_if_match.cjsandcheck_skip_if_no_match.cjscarried mirrored implementations of the same skip-query gate (config validation, search execution, threshold evaluation, output/summary wiring). This change extracts the shared gate flow into one helper and leaves each file as a policy wrapper.Shared gate extraction
runSkipQueryGate(...)inactions/setup/js/check_skip_if_helpers.cjs.GH_AW_SKIP_QUERY,GH_AW_WORKFLOW_NAME)Policy-specific wrappers retained
check_skip_if_match.cjsnow passes:totalCount >= thresholdskip_check_okcheck_skip_if_no_match.cjsnow passes:totalCount < thresholdskip_no_match_check_okFocused helper coverage
check_skip_if_helpers.test.cjswithrunSkipQueryGatetests for: