From 06e558d86b6ea03d52306a6b3722c2a8397eb199 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 19 Jun 2026 17:02:42 -0700 Subject: [PATCH 1/6] Skip reopening issues labeled wontfix --- .github/actions/file/src/index.ts | 20 +-- .github/actions/file/src/isWontfixIssue.ts | 17 +++ .github/actions/file/tests/dryRun.test.ts | 2 + .../actions/file/tests/isWontfixIssue.test.ts | 60 +++++++++ .../actions/file/tests/wontfixReopen.test.ts | 120 ++++++++++++++++++ 5 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 .github/actions/file/src/isWontfixIssue.ts create mode 100644 .github/actions/file/tests/isWontfixIssue.test.ts create mode 100644 .github/actions/file/tests/wontfixReopen.test.ts diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index fd16b68..15714db 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -10,6 +10,7 @@ import {closeIssue} from './closeIssue.js' import {isNewFiling} from './isNewFiling.js' import {isRepeatedFiling} from './isRepeatedFiling.js' import {isResolvedFiling} from './isResolvedFiling.js' +import {isWontfixIssue, WONTFIX_LABEL} from './isWontfixIssue.js' import {openIssue} from './openIssue.js' import {reopenIssue} from './reopenIssue.js' import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js' @@ -106,15 +107,16 @@ export default async function () { }) } } else if (isRepeatedFiling(filing)) { - // Reopen the filing's issue (if necessary) and update the body with the latest finding - response = await reopenIssue( - octokit, - new Issue(filing.issue), - filing.findings[0], - repoWithOwner, - screenshotRepo, - ) - filing.issue.state = 'reopened' + const issue = new Issue(filing.issue) + if (await isWontfixIssue(octokit, issue)) { + // The developer intentionally closed this issue and labeled it + // wontfix, so leave it closed instead of reopening it. + core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`) + } else { + // Reopen the filing's issue (if necessary) and update the body with the latest finding + response = await reopenIssue(octokit, issue, filing.findings[0], repoWithOwner, screenshotRepo) + filing.issue.state = 'reopened' + } } if (response?.data && filing.issue) { // Update the filing with the latest issue data diff --git a/.github/actions/file/src/isWontfixIssue.ts b/.github/actions/file/src/isWontfixIssue.ts new file mode 100644 index 0000000..bb1f770 --- /dev/null +++ b/.github/actions/file/src/isWontfixIssue.ts @@ -0,0 +1,17 @@ +import type {Octokit} from '@octokit/core' +import type {Issue} from './Issue.js' + +/** Issues with this label are intentionally closed and should not be reopened. */ +export const WONTFIX_LABEL = 'wontfix' + +type IssueLabel = string | {name?: string} + +export async function isWontfixIssue(octokit: Octokit, {owner, repository, issueNumber}: Issue): Promise { + const response = await octokit.request(`GET /repos/${owner}/${repository}/issues/${issueNumber}`, { + owner, + repository, + issue_number: issueNumber, + }) + const labels = ((response.data as {labels?: IssueLabel[]}).labels ?? []) as IssueLabel[] + return labels.some(label => (typeof label === 'string' ? label : label.name) === WONTFIX_LABEL) +} diff --git a/.github/actions/file/tests/dryRun.test.ts b/.github/actions/file/tests/dryRun.test.ts index ce8470f..f5fdf2b 100644 --- a/.github/actions/file/tests/dryRun.test.ts +++ b/.github/actions/file/tests/dryRun.test.ts @@ -185,6 +185,8 @@ describe('file action — dry_run', () => { openIssue.mockResolvedValue(resp) reopenIssue.mockResolvedValue(resp) closeIssue.mockResolvedValue(resp) + // the wontfix-label check issues a GET before reopening; return no labels so the reopen proceeds + octokitRequest.mockResolvedValue({data: {labels: []}}) await runFileAction() diff --git a/.github/actions/file/tests/isWontfixIssue.test.ts b/.github/actions/file/tests/isWontfixIssue.test.ts new file mode 100644 index 0000000..d943aae --- /dev/null +++ b/.github/actions/file/tests/isWontfixIssue.test.ts @@ -0,0 +1,60 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import {isWontfixIssue, WONTFIX_LABEL} from '../src/isWontfixIssue.ts' +import {Issue} from '../src/Issue.ts' + +const testIssue = new Issue({ + id: 42, + nodeId: 'MDU6SXNzdWU0Mg==', + url: 'https://github.com/org/filing-repo/issues/7', + title: 'Accessibility issue: test', + state: 'closed', +}) + +function mockOctokit(labels: unknown) { + return { + request: vi.fn().mockResolvedValue({data: {labels}}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any +} + +describe('isWontfixIssue', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns true when the issue has the wontfix label (object form)', async () => { + const octokit = mockOctokit([{name: 'bug'}, {name: WONTFIX_LABEL}]) + + expect(await isWontfixIssue(octokit, testIssue)).toBe(true) + }) + + it('returns true when the issue has the wontfix label (string form)', async () => { + const octokit = mockOctokit(['bug', WONTFIX_LABEL]) + + expect(await isWontfixIssue(octokit, testIssue)).toBe(true) + }) + + it('returns false when the issue has no wontfix label', async () => { + const octokit = mockOctokit([{name: 'bug'}]) + + expect(await isWontfixIssue(octokit, testIssue)).toBe(false) + }) + + it('returns false when the issue has no labels', async () => { + const octokit = mockOctokit(undefined) + + expect(await isWontfixIssue(octokit, testIssue)).toBe(false) + }) + + it('requests the issue at the correct URL', async () => { + const octokit = mockOctokit([]) + + await isWontfixIssue(octokit, testIssue) + + expect(octokit.request).toHaveBeenCalledWith( + 'GET /repos/org/filing-repo/issues/7', + expect.objectContaining({issue_number: 7}), + ) + }) +}) diff --git a/.github/actions/file/tests/wontfixReopen.test.ts b/.github/actions/file/tests/wontfixReopen.test.ts new file mode 100644 index 0000000..065e5ba --- /dev/null +++ b/.github/actions/file/tests/wontfixReopen.test.ts @@ -0,0 +1,120 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' + +const openIssue = vi.fn() +const reopenIssue = vi.fn() +const closeIssue = vi.fn() +vi.mock('../src/openIssue.js', () => ({openIssue: (...args: unknown[]) => openIssue(...args)})) +vi.mock('../src/reopenIssue.js', () => ({reopenIssue: (...args: unknown[]) => reopenIssue(...args)})) +vi.mock('../src/closeIssue.js', () => ({closeIssue: (...args: unknown[]) => closeIssue(...args)})) + +const inputs: Record = {} +const infoLines: string[] = [] +const outputs: Record = {} +vi.mock('@actions/core', () => ({ + getInput: (name: string) => inputs[name] ?? '', + getBooleanInput: (name: string) => (inputs[name] ?? 'false') === 'true', + setOutput: (name: string, value: string) => { + outputs[name] = value + }, + info: (msg: string) => { + infoLines.push(msg) + }, + debug: () => {}, + warning: () => {}, + setFailed: () => {}, +})) + +// Feed findings/cached filings in +const files: Record = {} +vi.mock('node:fs', () => ({ + default: { + readFileSync: (p: string) => files[p], + writeFileSync: (p: string, data: string) => { + files[p] = data + }, + }, +})) + +// Stub Octokit: `request` serves the GET that isWontfixIssue makes +const octokitRequest = vi.fn() +vi.mock('@octokit/core', () => ({ + Octokit: { + plugin: () => + class { + request = octokitRequest + }, + }, +})) +vi.mock('@octokit/plugin-throttling', () => ({throttling: {}})) + +import runFileAction from '../src/index.ts' + +const wontfixFinding = { + scannerType: 'axe', + ruleId: 'color-contrast', + url: 'https://example.com/page', + html: 'Low contrast', + problemShort: 'elements must meet minimum color contrast ratio thresholds', + problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast', + solutionShort: 'ensure sufficient contrast', +} +const normalFinding = {...wontfixFinding, ruleId: 'heading-order', html: '

Skipped

'} + +// Both cached filings' findings reappear this run, so both are repeated +const wontfixCached = { + issue: {id: 1, nodeId: 'N1', url: 'https://github.com/org/repo/issues/1', title: 'wontfix'}, + findings: [wontfixFinding], +} +const normalCached = { + issue: {id: 3, nodeId: 'N3', url: 'https://github.com/org/repo/issues/3', title: 'normal'}, + findings: [normalFinding], +} + +function setup() { + files['/tmp/findings.json'] = JSON.stringify([wontfixFinding, normalFinding]) + files['/tmp/cached.json'] = JSON.stringify([wontfixCached, normalCached]) + inputs.findings_file = '/tmp/findings.json' + inputs.cached_filings_file = '/tmp/cached.json' + inputs.repository = 'org/repo' + inputs.token = 'fake-token' + // GET issue: issue 1 is labeled wontfix, issue 3 is not + octokitRequest.mockImplementation((route: string) => + route.includes('/issues/1') + ? Promise.resolve({data: {labels: [{name: 'wontfix'}]}}) + : Promise.resolve({data: {labels: []}}), + ) +} + +describe('file action — wontfix label', () => { + beforeEach(() => { + vi.clearAllMocks() + infoLines.length = 0 + for (const k of Object.keys(inputs)) delete inputs[k] + for (const k of Object.keys(outputs)) delete outputs[k] + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reopens the unlabeled issue but not the one labeled wontfix', async () => { + setup() + + await runFileAction() + + expect(reopenIssue).toHaveBeenCalledTimes(1) + const reopenedIssue = reopenIssue.mock.calls[0][1] as {url: string} + expect(reopenedIssue.url).toBe('https://github.com/org/repo/issues/3') + expect(openIssue).not.toHaveBeenCalled() + expect(closeIssue).not.toHaveBeenCalled() + }) + + it('logs that it skipped the wontfix issue', async () => { + setup() + + await runFileAction() + + expect(infoLines.join('\n')).toContain( + "Skipping reopen of issue labeled 'wontfix': https://github.com/org/repo/issues/1", + ) + }) +}) From fb48e69612ce678cf14be591944c9c63fa8876bd Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 19 Jun 2026 17:02:42 -0700 Subject: [PATCH 2/6] Document wontfix label in README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index a20261e..27c4d89 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,17 @@ If your login flow is more complex—if it requires two-factor authentication, s --- +## Keeping an issue closed with `wontfix` + +When the scanner files an issue for an accessibility finding and that same finding turns up again on a later run, it reopens the issue (if you'd closed it) so the barrier doesn't get lost. Sometimes, though, you may want a closed issue to _stay_ closed—for example, if you've decided not to act on a particular finding, or you're tracking the work somewhere else. + +To stop the scanner from reopening a closed issue, add the **`wontfix`** label to it. On its next run, the scanner sees the label and skips reopening the issue, leaving it closed. + +> [!NOTE] +> The `wontfix` label only affects _reopening_. If you remove the label later, the scanner resumes its normal behavior and will reopen the issue on the next run if the finding is still present. + +--- + ## Configuring GitHub Copilot The a11y scanner leverages GitHub Copilot coding agent, which can be configured with custom instructions: From 52d65a74f40c95208e28697c5273069f9133f459 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 19 Jun 2026 17:10:10 -0700 Subject: [PATCH 3/6] Trim verbose comments --- .github/actions/file/src/index.ts | 5 ++--- README.md | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index 15714db..9c446af 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -109,11 +109,10 @@ export default async function () { } else if (isRepeatedFiling(filing)) { const issue = new Issue(filing.issue) if (await isWontfixIssue(octokit, issue)) { - // The developer intentionally closed this issue and labeled it - // wontfix, so leave it closed instead of reopening it. + // The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`) } else { - // Reopen the filing's issue (if necessary) and update the body with the latest finding + // Reopen the filing's issue and update the body with the latest finding response = await reopenIssue(octokit, issue, filing.findings[0], repoWithOwner, screenshotRepo) filing.issue.state = 'reopened' } diff --git a/README.md b/README.md index 27c4d89..a734c37 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,6 @@ When the scanner files an issue for an accessibility finding and that same findi To stop the scanner from reopening a closed issue, add the **`wontfix`** label to it. On its next run, the scanner sees the label and skips reopening the issue, leaving it closed. -> [!NOTE] -> The `wontfix` label only affects _reopening_. If you remove the label later, the scanner resumes its normal behavior and will reopen the issue on the next run if the finding is still present. - --- ## Configuring GitHub Copilot From 2feeb9d4a4cbd48629e4626970c2bb963c36b352 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Fri, 19 Jun 2026 17:14:58 -0700 Subject: [PATCH 4/6] Proceed with reopen when wontfix label check fails --- .github/actions/file/src/index.ts | 9 ++++++++- .../actions/file/tests/wontfixReopen.test.ts | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index 9c446af..fc1b8d3 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -108,7 +108,14 @@ export default async function () { } } else if (isRepeatedFiling(filing)) { const issue = new Issue(filing.issue) - if (await isWontfixIssue(octokit, issue)) { + let isWontfix = false + try { + isWontfix = await isWontfixIssue(octokit, issue) + } catch (error) { + // A failed label check shouldn't abort the run, so reopen as usual + core.warning(`Could not check labels for ${filing.issue.url}; proceeding with reopen: ${error}`) + } + if (isWontfix) { // The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`) } else { diff --git a/.github/actions/file/tests/wontfixReopen.test.ts b/.github/actions/file/tests/wontfixReopen.test.ts index 065e5ba..ee26871 100644 --- a/.github/actions/file/tests/wontfixReopen.test.ts +++ b/.github/actions/file/tests/wontfixReopen.test.ts @@ -9,6 +9,7 @@ vi.mock('../src/closeIssue.js', () => ({closeIssue: (...args: unknown[]) => clos const inputs: Record = {} const infoLines: string[] = [] +const warnLines: string[] = [] const outputs: Record = {} vi.mock('@actions/core', () => ({ getInput: (name: string) => inputs[name] ?? '', @@ -20,7 +21,9 @@ vi.mock('@actions/core', () => ({ infoLines.push(msg) }, debug: () => {}, - warning: () => {}, + warning: (msg: string) => { + warnLines.push(msg) + }, setFailed: () => {}, })) @@ -89,6 +92,7 @@ describe('file action — wontfix label', () => { beforeEach(() => { vi.clearAllMocks() infoLines.length = 0 + warnLines.length = 0 for (const k of Object.keys(inputs)) delete inputs[k] for (const k of Object.keys(outputs)) delete outputs[k] }) @@ -117,4 +121,16 @@ describe('file action — wontfix label', () => { "Skipping reopen of issue labeled 'wontfix': https://github.com/org/repo/issues/1", ) }) + + it('reopens as usual (and warns) when the label check fails', async () => { + setup() + // The label-check GET fails for every issue (e.g. transient API error) + octokitRequest.mockRejectedValue(new Error('boom')) + + await runFileAction() + + // Both repeated filings should still be reopened rather than aborting the run + expect(reopenIssue).toHaveBeenCalledTimes(2) + expect(warnLines.join('\n')).toContain('Could not check labels for') + }) }) From 37b9db96725c1c54c76277ba5584fe7d269f2afa Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 00:44:55 -0700 Subject: [PATCH 5/6] Batch wontfix lookups into a single set check --- .github/actions/file/src/index.ts | 22 +++-- .github/actions/file/src/isWontfixIssue.ts | 17 ---- .github/actions/file/src/shouldReopenIssue.ts | 40 +++++++++ .../actions/file/tests/isWontfixIssue.test.ts | 60 ------------- .../file/tests/shouldReopenIssue.test.ts | 90 +++++++++++++++++++ .../actions/file/tests/wontfixReopen.test.ts | 13 ++- 6 files changed, 149 insertions(+), 93 deletions(-) delete mode 100644 .github/actions/file/src/isWontfixIssue.ts create mode 100644 .github/actions/file/src/shouldReopenIssue.ts delete mode 100644 .github/actions/file/tests/isWontfixIssue.test.ts create mode 100644 .github/actions/file/tests/shouldReopenIssue.test.ts diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index fc1b8d3..832a701 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -10,7 +10,7 @@ import {closeIssue} from './closeIssue.js' import {isNewFiling} from './isNewFiling.js' import {isRepeatedFiling} from './isRepeatedFiling.js' import {isResolvedFiling} from './isResolvedFiling.js' -import {isWontfixIssue, WONTFIX_LABEL} from './isWontfixIssue.js' +import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from './shouldReopenIssue.js' import {openIssue} from './openIssue.js' import {reopenIssue} from './reopenIssue.js' import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js' @@ -61,6 +61,17 @@ export default async function () { }) const filings = updateFilingsWithNewFindings(cachedFilings, findings) + // Fetch closed wontfix issues once up front; a failed fetch reopens as usual + let wontfixIssueNumbers = new Set() + if (!dryRun) { + try { + const [owner, repository] = repoWithOwner.split('/') + wontfixIssueNumbers = await getWontfixIssueNumbers(octokit, {owner, repository}) + } catch (error) { + core.warning(`Could not fetch '${WONTFIX_LABEL}' issues; proceeding with reopen as usual: ${error}`) + } + } + // Track new issues for grouping const newIssuesByProblemShort: Record = {} const trackingIssueUrls: Record = {} @@ -108,14 +119,7 @@ export default async function () { } } else if (isRepeatedFiling(filing)) { const issue = new Issue(filing.issue) - let isWontfix = false - try { - isWontfix = await isWontfixIssue(octokit, issue) - } catch (error) { - // A failed label check shouldn't abort the run, so reopen as usual - core.warning(`Could not check labels for ${filing.issue.url}; proceeding with reopen: ${error}`) - } - if (isWontfix) { + if (!shouldReopenIssue(issue, wontfixIssueNumbers)) { // The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`) } else { diff --git a/.github/actions/file/src/isWontfixIssue.ts b/.github/actions/file/src/isWontfixIssue.ts deleted file mode 100644 index bb1f770..0000000 --- a/.github/actions/file/src/isWontfixIssue.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type {Octokit} from '@octokit/core' -import type {Issue} from './Issue.js' - -/** Issues with this label are intentionally closed and should not be reopened. */ -export const WONTFIX_LABEL = 'wontfix' - -type IssueLabel = string | {name?: string} - -export async function isWontfixIssue(octokit: Octokit, {owner, repository, issueNumber}: Issue): Promise { - const response = await octokit.request(`GET /repos/${owner}/${repository}/issues/${issueNumber}`, { - owner, - repository, - issue_number: issueNumber, - }) - const labels = ((response.data as {labels?: IssueLabel[]}).labels ?? []) as IssueLabel[] - return labels.some(label => (typeof label === 'string' ? label : label.name) === WONTFIX_LABEL) -} diff --git a/.github/actions/file/src/shouldReopenIssue.ts b/.github/actions/file/src/shouldReopenIssue.ts new file mode 100644 index 0000000..d431ed9 --- /dev/null +++ b/.github/actions/file/src/shouldReopenIssue.ts @@ -0,0 +1,40 @@ +import type {Octokit} from '@octokit/core' +import type {Issue} from './Issue.js' + +/** Issues with this label are intentionally closed and should not be reopened. */ +export const WONTFIX_LABEL = 'wontfix' + +// Fetch every closed wontfix issue once so the per-filing check is a set lookup +export async function getWontfixIssueNumbers( + octokit: Octokit, + {owner, repository}: {owner: string; repository: string}, +): Promise> { + const wontfixIssueNumbers = new Set() + const perPage = 100 + for (let page = 1; ; page++) { + const response = await octokit.request(`GET /repos/${owner}/${repository}/issues`, { + owner, + repo: repository, + state: 'closed', + labels: WONTFIX_LABEL, + per_page: perPage, + page, + }) + const issues = (response.data as Array<{number: number; pull_request?: unknown}>) ?? [] + for (const issue of issues) { + // The issues endpoint also returns pull requests; skip them + if (!issue.pull_request) { + wontfixIssueNumbers.add(issue.number) + } + } + if (issues.length < perPage) { + break + } + } + return wontfixIssueNumbers +} + +// The single place to decide whether a repeated filing's issue should reopen +export function shouldReopenIssue(issue: Issue, wontfixIssueNumbers: Set): boolean { + return !wontfixIssueNumbers.has(issue.issueNumber) +} diff --git a/.github/actions/file/tests/isWontfixIssue.test.ts b/.github/actions/file/tests/isWontfixIssue.test.ts deleted file mode 100644 index d943aae..0000000 --- a/.github/actions/file/tests/isWontfixIssue.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {describe, it, expect, vi, beforeEach} from 'vitest' - -import {isWontfixIssue, WONTFIX_LABEL} from '../src/isWontfixIssue.ts' -import {Issue} from '../src/Issue.ts' - -const testIssue = new Issue({ - id: 42, - nodeId: 'MDU6SXNzdWU0Mg==', - url: 'https://github.com/org/filing-repo/issues/7', - title: 'Accessibility issue: test', - state: 'closed', -}) - -function mockOctokit(labels: unknown) { - return { - request: vi.fn().mockResolvedValue({data: {labels}}), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any -} - -describe('isWontfixIssue', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns true when the issue has the wontfix label (object form)', async () => { - const octokit = mockOctokit([{name: 'bug'}, {name: WONTFIX_LABEL}]) - - expect(await isWontfixIssue(octokit, testIssue)).toBe(true) - }) - - it('returns true when the issue has the wontfix label (string form)', async () => { - const octokit = mockOctokit(['bug', WONTFIX_LABEL]) - - expect(await isWontfixIssue(octokit, testIssue)).toBe(true) - }) - - it('returns false when the issue has no wontfix label', async () => { - const octokit = mockOctokit([{name: 'bug'}]) - - expect(await isWontfixIssue(octokit, testIssue)).toBe(false) - }) - - it('returns false when the issue has no labels', async () => { - const octokit = mockOctokit(undefined) - - expect(await isWontfixIssue(octokit, testIssue)).toBe(false) - }) - - it('requests the issue at the correct URL', async () => { - const octokit = mockOctokit([]) - - await isWontfixIssue(octokit, testIssue) - - expect(octokit.request).toHaveBeenCalledWith( - 'GET /repos/org/filing-repo/issues/7', - expect.objectContaining({issue_number: 7}), - ) - }) -}) diff --git a/.github/actions/file/tests/shouldReopenIssue.test.ts b/.github/actions/file/tests/shouldReopenIssue.test.ts new file mode 100644 index 0000000..7eedc13 --- /dev/null +++ b/.github/actions/file/tests/shouldReopenIssue.test.ts @@ -0,0 +1,90 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from '../src/shouldReopenIssue.ts' +import {Issue} from '../src/Issue.ts' + +function issueAt(issueNumber: number): Issue { + return new Issue({ + id: issueNumber, + nodeId: `node-${issueNumber}`, + url: `https://github.com/org/filing-repo/issues/${issueNumber}`, + title: `Accessibility issue ${issueNumber}`, + state: 'closed', + }) +} + +// `pages` is consumed one response per request call, in order. +function mockOctokit(pages: Array>) { + const request = vi.fn() + for (const page of pages) { + request.mockResolvedValueOnce({data: page}) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {request} as any +} + +describe('getWontfixIssueNumbers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the numbers of closed wontfix issues as a set', async () => { + const octokit = mockOctokit([[{number: 1}, {number: 5}, {number: 9}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result).toEqual(new Set([1, 5, 9])) + }) + + it('requests closed issues filtered by the wontfix label', async () => { + const octokit = mockOctokit([[]]) + + await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(octokit.request).toHaveBeenCalledWith( + 'GET /repos/org/repo/issues', + expect.objectContaining({state: 'closed', labels: WONTFIX_LABEL}), + ) + }) + + it('returns an empty set when no issues are labeled wontfix', async () => { + const octokit = mockOctokit([[]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result.size).toBe(0) + }) + + it('paginates until a short page is returned', async () => { + const firstPage = Array.from({length: 100}, (_, i) => ({number: i + 1})) + const octokit = mockOctokit([firstPage, [{number: 101}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(octokit.request).toHaveBeenCalledTimes(2) + expect(result.has(1)).toBe(true) + expect(result.has(101)).toBe(true) + }) + + it('ignores pull requests returned by the issues endpoint', async () => { + const octokit = mockOctokit([[{number: 2}, {number: 3, pull_request: {url: 'https://example.com/pull/3'}}]]) + + const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'}) + + expect(result).toEqual(new Set([2])) + }) +}) + +describe('shouldReopenIssue', () => { + it('returns false when the issue is in the wontfix set', () => { + expect(shouldReopenIssue(issueAt(7), new Set([7]))).toBe(false) + }) + + it('returns true when the issue is not in the wontfix set', () => { + expect(shouldReopenIssue(issueAt(7), new Set([1, 2, 3]))).toBe(true) + }) + + it('returns true when the wontfix set is empty', () => { + expect(shouldReopenIssue(issueAt(7), new Set())).toBe(true) + }) +}) diff --git a/.github/actions/file/tests/wontfixReopen.test.ts b/.github/actions/file/tests/wontfixReopen.test.ts index ee26871..b938d2b 100644 --- a/.github/actions/file/tests/wontfixReopen.test.ts +++ b/.github/actions/file/tests/wontfixReopen.test.ts @@ -38,7 +38,8 @@ vi.mock('node:fs', () => ({ }, })) -// Stub Octokit: `request` serves the GET that isWontfixIssue makes +// Stub Octokit: `request` serves the list of closed `wontfix` issues that +// getWontfixIssueNumbers fetches once up front. const octokitRequest = vi.fn() vi.mock('@octokit/core', () => ({ Octokit: { @@ -80,11 +81,9 @@ function setup() { inputs.cached_filings_file = '/tmp/cached.json' inputs.repository = 'org/repo' inputs.token = 'fake-token' - // GET issue: issue 1 is labeled wontfix, issue 3 is not + // Single up-front fetch: only issue 1 is closed and labeled wontfix octokitRequest.mockImplementation((route: string) => - route.includes('/issues/1') - ? Promise.resolve({data: {labels: [{name: 'wontfix'}]}}) - : Promise.resolve({data: {labels: []}}), + route.includes('GET /repos/org/repo/issues') ? Promise.resolve({data: [{number: 1}]}) : Promise.resolve({data: {}}), ) } @@ -124,13 +123,13 @@ describe('file action — wontfix label', () => { it('reopens as usual (and warns) when the label check fails', async () => { setup() - // The label-check GET fails for every issue (e.g. transient API error) + // The up-front wontfix fetch fails (e.g. transient API error) octokitRequest.mockRejectedValue(new Error('boom')) await runFileAction() // Both repeated filings should still be reopened rather than aborting the run expect(reopenIssue).toHaveBeenCalledTimes(2) - expect(warnLines.join('\n')).toContain('Could not check labels for') + expect(warnLines.join('\n')).toContain("Could not fetch 'wontfix' issues") }) }) From 33d72ad6ce22a2f4b70edb7f669439a818301941 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 12:00:53 -0700 Subject: [PATCH 6/6] Update README.md Co-authored-by: Joyce Zhu --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a734c37..2814517 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ If your login flow is more complex—if it requires two-factor authentication, s ## Keeping an issue closed with `wontfix` -When the scanner files an issue for an accessibility finding and that same finding turns up again on a later run, it reopens the issue (if you'd closed it) so the barrier doesn't get lost. Sometimes, though, you may want a closed issue to _stay_ closed—for example, if you've decided not to act on a particular finding, or you're tracking the work somewhere else. +When the scanner files an issue for an accessibility finding and that same finding turns up again on a later run, it reopens closed issues so the problem doesn't get lost. Sometimes, though, you may want a closed issue to _stay_ closed -- for example, if you've decided not to act on a particular finding, or if you're already tracking the work outside of GitHub issues. To stop the scanner from reopening a closed issue, add the **`wontfix`** label to it. On its next run, the scanner sees the label and skips reopening the issue, leaving it closed.