Skip to content

Commit b41d9f0

Browse files
committed
Merge remote-tracking branch 'origin/main' into distinguish-wcag-vs-best-practice
# Conflicts: # .github/actions/file/src/index.ts
2 parents 1024857 + 26c8030 commit b41d9f0

14 files changed

Lines changed: 315 additions & 28 deletions

File tree

.github/actions/file/src/index.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {closeIssue} from './closeIssue.js'
1010
import {isNewFiling} from './isNewFiling.js'
1111
import {isRepeatedFiling} from './isRepeatedFiling.js'
1212
import {isResolvedFiling} from './isResolvedFiling.js'
13+
import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from './shouldReopenIssue.js'
1314
import {openIssue} from './openIssue.js'
1415
import {reopenIssue} from './reopenIssue.js'
1516
import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js'
@@ -73,6 +74,17 @@ export default async function () {
7374
// Suppressed new filings are kept out of the cache
7475
const suppressedFilings = new Set<Filing>()
7576

77+
// Fetch closed wontfix issues once up front; a failed fetch reopens as usual
78+
let wontfixIssueNumbers = new Set<number>()
79+
if (!dryRun) {
80+
try {
81+
const [owner, repository] = repoWithOwner.split('/')
82+
wontfixIssueNumbers = await getWontfixIssueNumbers(octokit, {owner, repository})
83+
} catch (error) {
84+
core.warning(`Could not fetch '${WONTFIX_LABEL}' issues; proceeding with reopen as usual: ${error}`)
85+
}
86+
}
87+
7688
// Track new issues for grouping
7789
const newIssuesByProblemShort: Record<string, FindingGroupIssue[]> = {}
7890
const trackingIssueUrls: Record<string, string> = {}
@@ -134,15 +146,15 @@ export default async function () {
134146
})
135147
}
136148
} else if (isRepeatedFiling(filing)) {
137-
// Reopen the filing's issue (if necessary) and update the body with the latest finding
138-
response = await reopenIssue(
139-
octokit,
140-
new Issue(filing.issue),
141-
filing.findings[0],
142-
repoWithOwner,
143-
screenshotRepo,
144-
)
145-
filing.issue.state = 'reopened'
149+
const issue = new Issue(filing.issue)
150+
if (!shouldReopenIssue(issue, wontfixIssueNumbers)) {
151+
// The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed
152+
core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`)
153+
} else {
154+
// Reopen the filing's issue and update the body with the latest finding
155+
response = await reopenIssue(octokit, issue, filing.findings[0], repoWithOwner, screenshotRepo)
156+
filing.issue.state = 'reopened'
157+
}
146158
}
147159
if (response?.data && filing.issue) {
148160
// Update the filing with the latest issue data
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {Octokit} from '@octokit/core'
2+
import type {Issue} from './Issue.js'
3+
4+
/** Issues with this label are intentionally closed and should not be reopened. */
5+
export const WONTFIX_LABEL = 'wontfix'
6+
7+
// Fetch every closed wontfix issue once so the per-filing check is a set lookup
8+
export async function getWontfixIssueNumbers(
9+
octokit: Octokit,
10+
{owner, repository}: {owner: string; repository: string},
11+
): Promise<Set<number>> {
12+
const wontfixIssueNumbers = new Set<number>()
13+
const perPage = 100
14+
for (let page = 1; ; page++) {
15+
const response = await octokit.request(`GET /repos/${owner}/${repository}/issues`, {
16+
owner,
17+
repo: repository,
18+
state: 'closed',
19+
labels: WONTFIX_LABEL,
20+
per_page: perPage,
21+
page,
22+
})
23+
const issues = (response.data as Array<{number: number; pull_request?: unknown}>) ?? []
24+
for (const issue of issues) {
25+
// The issues endpoint also returns pull requests; skip them
26+
if (!issue.pull_request) {
27+
wontfixIssueNumbers.add(issue.number)
28+
}
29+
}
30+
if (issues.length < perPage) {
31+
break
32+
}
33+
}
34+
return wontfixIssueNumbers
35+
}
36+
37+
// The single place to decide whether a repeated filing's issue should reopen
38+
export function shouldReopenIssue(issue: Issue, wontfixIssueNumbers: Set<number>): boolean {
39+
return !wontfixIssueNumbers.has(issue.issueNumber)
40+
}

.github/actions/file/tests/dryRun.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ describe('file action — dry_run', () => {
185185
openIssue.mockResolvedValue(resp)
186186
reopenIssue.mockResolvedValue(resp)
187187
closeIssue.mockResolvedValue(resp)
188+
// the wontfix-label check issues a GET before reopening; return no labels so the reopen proceeds
189+
octokitRequest.mockResolvedValue({data: {labels: []}})
188190

189191
await runFileAction()
190192

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {describe, it, expect, vi, beforeEach} from 'vitest'
2+
3+
import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from '../src/shouldReopenIssue.ts'
4+
import {Issue} from '../src/Issue.ts'
5+
6+
function issueAt(issueNumber: number): Issue {
7+
return new Issue({
8+
id: issueNumber,
9+
nodeId: `node-${issueNumber}`,
10+
url: `https://github.com/org/filing-repo/issues/${issueNumber}`,
11+
title: `Accessibility issue ${issueNumber}`,
12+
state: 'closed',
13+
})
14+
}
15+
16+
// `pages` is consumed one response per request call, in order.
17+
function mockOctokit(pages: Array<Array<{number: number; pull_request?: unknown}>>) {
18+
const request = vi.fn()
19+
for (const page of pages) {
20+
request.mockResolvedValueOnce({data: page})
21+
}
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
return {request} as any
24+
}
25+
26+
describe('getWontfixIssueNumbers', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
})
30+
31+
it('returns the numbers of closed wontfix issues as a set', async () => {
32+
const octokit = mockOctokit([[{number: 1}, {number: 5}, {number: 9}]])
33+
34+
const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'})
35+
36+
expect(result).toEqual(new Set([1, 5, 9]))
37+
})
38+
39+
it('requests closed issues filtered by the wontfix label', async () => {
40+
const octokit = mockOctokit([[]])
41+
42+
await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'})
43+
44+
expect(octokit.request).toHaveBeenCalledWith(
45+
'GET /repos/org/repo/issues',
46+
expect.objectContaining({state: 'closed', labels: WONTFIX_LABEL}),
47+
)
48+
})
49+
50+
it('returns an empty set when no issues are labeled wontfix', async () => {
51+
const octokit = mockOctokit([[]])
52+
53+
const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'})
54+
55+
expect(result.size).toBe(0)
56+
})
57+
58+
it('paginates until a short page is returned', async () => {
59+
const firstPage = Array.from({length: 100}, (_, i) => ({number: i + 1}))
60+
const octokit = mockOctokit([firstPage, [{number: 101}]])
61+
62+
const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'})
63+
64+
expect(octokit.request).toHaveBeenCalledTimes(2)
65+
expect(result.has(1)).toBe(true)
66+
expect(result.has(101)).toBe(true)
67+
})
68+
69+
it('ignores pull requests returned by the issues endpoint', async () => {
70+
const octokit = mockOctokit([[{number: 2}, {number: 3, pull_request: {url: 'https://example.com/pull/3'}}]])
71+
72+
const result = await getWontfixIssueNumbers(octokit, {owner: 'org', repository: 'repo'})
73+
74+
expect(result).toEqual(new Set([2]))
75+
})
76+
})
77+
78+
describe('shouldReopenIssue', () => {
79+
it('returns false when the issue is in the wontfix set', () => {
80+
expect(shouldReopenIssue(issueAt(7), new Set([7]))).toBe(false)
81+
})
82+
83+
it('returns true when the issue is not in the wontfix set', () => {
84+
expect(shouldReopenIssue(issueAt(7), new Set([1, 2, 3]))).toBe(true)
85+
})
86+
87+
it('returns true when the wontfix set is empty', () => {
88+
expect(shouldReopenIssue(issueAt(7), new Set())).toBe(true)
89+
})
90+
})
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
2+
3+
const openIssue = vi.fn()
4+
const reopenIssue = vi.fn()
5+
const closeIssue = vi.fn()
6+
vi.mock('../src/openIssue.js', () => ({openIssue: (...args: unknown[]) => openIssue(...args)}))
7+
vi.mock('../src/reopenIssue.js', () => ({reopenIssue: (...args: unknown[]) => reopenIssue(...args)}))
8+
vi.mock('../src/closeIssue.js', () => ({closeIssue: (...args: unknown[]) => closeIssue(...args)}))
9+
10+
const inputs: Record<string, string> = {}
11+
const infoLines: string[] = []
12+
const warnLines: string[] = []
13+
const outputs: Record<string, string> = {}
14+
vi.mock('@actions/core', () => ({
15+
getInput: (name: string) => inputs[name] ?? '',
16+
getBooleanInput: (name: string) => (inputs[name] ?? 'false') === 'true',
17+
setOutput: (name: string, value: string) => {
18+
outputs[name] = value
19+
},
20+
info: (msg: string) => {
21+
infoLines.push(msg)
22+
},
23+
debug: () => {},
24+
warning: (msg: string) => {
25+
warnLines.push(msg)
26+
},
27+
setFailed: () => {},
28+
}))
29+
30+
// Feed findings/cached filings in
31+
const files: Record<string, string> = {}
32+
vi.mock('node:fs', () => ({
33+
default: {
34+
readFileSync: (p: string) => files[p],
35+
writeFileSync: (p: string, data: string) => {
36+
files[p] = data
37+
},
38+
},
39+
}))
40+
41+
// Stub Octokit: `request` serves the list of closed `wontfix` issues that
42+
// getWontfixIssueNumbers fetches once up front.
43+
const octokitRequest = vi.fn()
44+
vi.mock('@octokit/core', () => ({
45+
Octokit: {
46+
plugin: () =>
47+
class {
48+
request = octokitRequest
49+
},
50+
},
51+
}))
52+
vi.mock('@octokit/plugin-throttling', () => ({throttling: {}}))
53+
54+
import runFileAction from '../src/index.ts'
55+
56+
const wontfixFinding = {
57+
scannerType: 'axe',
58+
ruleId: 'color-contrast',
59+
url: 'https://example.com/page',
60+
html: '<span>Low contrast</span>',
61+
problemShort: 'elements must meet minimum color contrast ratio thresholds',
62+
problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast',
63+
solutionShort: 'ensure sufficient contrast',
64+
}
65+
const normalFinding = {...wontfixFinding, ruleId: 'heading-order', html: '<h3>Skipped</h3>'}
66+
67+
// Both cached filings' findings reappear this run, so both are repeated
68+
const wontfixCached = {
69+
issue: {id: 1, nodeId: 'N1', url: 'https://github.com/org/repo/issues/1', title: 'wontfix'},
70+
findings: [wontfixFinding],
71+
}
72+
const normalCached = {
73+
issue: {id: 3, nodeId: 'N3', url: 'https://github.com/org/repo/issues/3', title: 'normal'},
74+
findings: [normalFinding],
75+
}
76+
77+
function setup() {
78+
files['/tmp/findings.json'] = JSON.stringify([wontfixFinding, normalFinding])
79+
files['/tmp/cached.json'] = JSON.stringify([wontfixCached, normalCached])
80+
inputs.findings_file = '/tmp/findings.json'
81+
inputs.cached_filings_file = '/tmp/cached.json'
82+
inputs.repository = 'org/repo'
83+
inputs.token = 'fake-token'
84+
// Single up-front fetch: only issue 1 is closed and labeled wontfix
85+
octokitRequest.mockImplementation((route: string) =>
86+
route.includes('GET /repos/org/repo/issues') ? Promise.resolve({data: [{number: 1}]}) : Promise.resolve({data: {}}),
87+
)
88+
}
89+
90+
describe('file action — wontfix label', () => {
91+
beforeEach(() => {
92+
vi.clearAllMocks()
93+
infoLines.length = 0
94+
warnLines.length = 0
95+
for (const k of Object.keys(inputs)) delete inputs[k]
96+
for (const k of Object.keys(outputs)) delete outputs[k]
97+
})
98+
afterEach(() => {
99+
vi.restoreAllMocks()
100+
})
101+
102+
it('reopens the unlabeled issue but not the one labeled wontfix', async () => {
103+
setup()
104+
105+
await runFileAction()
106+
107+
expect(reopenIssue).toHaveBeenCalledTimes(1)
108+
const reopenedIssue = reopenIssue.mock.calls[0][1] as {url: string}
109+
expect(reopenedIssue.url).toBe('https://github.com/org/repo/issues/3')
110+
expect(openIssue).not.toHaveBeenCalled()
111+
expect(closeIssue).not.toHaveBeenCalled()
112+
})
113+
114+
it('logs that it skipped the wontfix issue', async () => {
115+
setup()
116+
117+
await runFileAction()
118+
119+
expect(infoLines.join('\n')).toContain(
120+
"Skipping reopen of issue labeled 'wontfix': https://github.com/org/repo/issues/1",
121+
)
122+
})
123+
124+
it('reopens as usual (and warns) when the label check fails', async () => {
125+
setup()
126+
// The up-front wontfix fetch fails (e.g. transient API error)
127+
octokitRequest.mockRejectedValue(new Error('boom'))
128+
129+
await runFileAction()
130+
131+
// Both repeated filings should still be reopened rather than aborting the run
132+
expect(reopenIssue).toHaveBeenCalledTimes(2)
133+
expect(warnLines.join('\n')).toContain("Could not fetch 'wontfix' issues")
134+
})
135+
})

.github/actions/gh-cache/delete/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ runs:
4040
fi
4141
4242
- name: Checkout repository to temporary directory
43-
uses: actions/checkout@v6
43+
uses: actions/checkout@v7
4444
with:
4545
token: ${{ inputs.token }}
4646
fetch-depth: 0

.github/actions/gh-cache/restore/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ runs:
4444
fi
4545
4646
- name: Checkout repository to temporary directory
47-
uses: actions/checkout@v6
47+
uses: actions/checkout@v7
4848
with:
4949
token: ${{ inputs.token }}
5050
fetch-depth: 0

.github/actions/gh-cache/save/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ runs:
4040
fi
4141
4242
- name: Checkout repository to temporary directory
43-
uses: actions/checkout@v6
43+
uses: actions/checkout@v7
4444
with:
4545
token: ${{ inputs.token }}
4646
fetch-depth: 0

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
runs-on: ubuntu-latest
2424
steps:
2525
- name: Checkout
26-
uses: actions/checkout@v6
26+
uses: actions/checkout@v7
2727

2828
- name: Setup Node
2929
uses: actions/setup-node@v6

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ jobs:
3131
site: ['sites/site-with-errors']
3232
steps:
3333
- name: Checkout
34-
uses: actions/checkout@v6
34+
uses: actions/checkout@v7
3535

3636
- name: Setup Ruby
37-
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af
37+
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74
3838
with:
3939
ruby-version: '3.4'
4040
bundler-cache: true

0 commit comments

Comments
 (0)