Skip to content

Commit 06e558d

Browse files
committed
Skip reopening issues labeled wontfix
1 parent ce2f468 commit 06e558d

5 files changed

Lines changed: 210 additions & 9 deletions

File tree

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

Lines changed: 11 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 {isWontfixIssue, WONTFIX_LABEL} from './isWontfixIssue.js'
1314
import {openIssue} from './openIssue.js'
1415
import {reopenIssue} from './reopenIssue.js'
1516
import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js'
@@ -106,15 +107,16 @@ export default async function () {
106107
})
107108
}
108109
} else if (isRepeatedFiling(filing)) {
109-
// Reopen the filing's issue (if necessary) and update the body with the latest finding
110-
response = await reopenIssue(
111-
octokit,
112-
new Issue(filing.issue),
113-
filing.findings[0],
114-
repoWithOwner,
115-
screenshotRepo,
116-
)
117-
filing.issue.state = 'reopened'
110+
const issue = new Issue(filing.issue)
111+
if (await isWontfixIssue(octokit, issue)) {
112+
// The developer intentionally closed this issue and labeled it
113+
// wontfix, so leave it closed instead of reopening it.
114+
core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`)
115+
} else {
116+
// Reopen the filing's issue (if necessary) and update the body with the latest finding
117+
response = await reopenIssue(octokit, issue, filing.findings[0], repoWithOwner, screenshotRepo)
118+
filing.issue.state = 'reopened'
119+
}
118120
}
119121
if (response?.data && filing.issue) {
120122
// Update the filing with the latest issue data
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
type IssueLabel = string | {name?: string}
8+
9+
export async function isWontfixIssue(octokit: Octokit, {owner, repository, issueNumber}: Issue): Promise<boolean> {
10+
const response = await octokit.request(`GET /repos/${owner}/${repository}/issues/${issueNumber}`, {
11+
owner,
12+
repository,
13+
issue_number: issueNumber,
14+
})
15+
const labels = ((response.data as {labels?: IssueLabel[]}).labels ?? []) as IssueLabel[]
16+
return labels.some(label => (typeof label === 'string' ? label : label.name) === WONTFIX_LABEL)
17+
}

.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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {describe, it, expect, vi, beforeEach} from 'vitest'
2+
3+
import {isWontfixIssue, WONTFIX_LABEL} from '../src/isWontfixIssue.ts'
4+
import {Issue} from '../src/Issue.ts'
5+
6+
const testIssue = new Issue({
7+
id: 42,
8+
nodeId: 'MDU6SXNzdWU0Mg==',
9+
url: 'https://github.com/org/filing-repo/issues/7',
10+
title: 'Accessibility issue: test',
11+
state: 'closed',
12+
})
13+
14+
function mockOctokit(labels: unknown) {
15+
return {
16+
request: vi.fn().mockResolvedValue({data: {labels}}),
17+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18+
} as any
19+
}
20+
21+
describe('isWontfixIssue', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
})
25+
26+
it('returns true when the issue has the wontfix label (object form)', async () => {
27+
const octokit = mockOctokit([{name: 'bug'}, {name: WONTFIX_LABEL}])
28+
29+
expect(await isWontfixIssue(octokit, testIssue)).toBe(true)
30+
})
31+
32+
it('returns true when the issue has the wontfix label (string form)', async () => {
33+
const octokit = mockOctokit(['bug', WONTFIX_LABEL])
34+
35+
expect(await isWontfixIssue(octokit, testIssue)).toBe(true)
36+
})
37+
38+
it('returns false when the issue has no wontfix label', async () => {
39+
const octokit = mockOctokit([{name: 'bug'}])
40+
41+
expect(await isWontfixIssue(octokit, testIssue)).toBe(false)
42+
})
43+
44+
it('returns false when the issue has no labels', async () => {
45+
const octokit = mockOctokit(undefined)
46+
47+
expect(await isWontfixIssue(octokit, testIssue)).toBe(false)
48+
})
49+
50+
it('requests the issue at the correct URL', async () => {
51+
const octokit = mockOctokit([])
52+
53+
await isWontfixIssue(octokit, testIssue)
54+
55+
expect(octokit.request).toHaveBeenCalledWith(
56+
'GET /repos/org/filing-repo/issues/7',
57+
expect.objectContaining({issue_number: 7}),
58+
)
59+
})
60+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 outputs: Record<string, string> = {}
13+
vi.mock('@actions/core', () => ({
14+
getInput: (name: string) => inputs[name] ?? '',
15+
getBooleanInput: (name: string) => (inputs[name] ?? 'false') === 'true',
16+
setOutput: (name: string, value: string) => {
17+
outputs[name] = value
18+
},
19+
info: (msg: string) => {
20+
infoLines.push(msg)
21+
},
22+
debug: () => {},
23+
warning: () => {},
24+
setFailed: () => {},
25+
}))
26+
27+
// Feed findings/cached filings in
28+
const files: Record<string, string> = {}
29+
vi.mock('node:fs', () => ({
30+
default: {
31+
readFileSync: (p: string) => files[p],
32+
writeFileSync: (p: string, data: string) => {
33+
files[p] = data
34+
},
35+
},
36+
}))
37+
38+
// Stub Octokit: `request` serves the GET that isWontfixIssue makes
39+
const octokitRequest = vi.fn()
40+
vi.mock('@octokit/core', () => ({
41+
Octokit: {
42+
plugin: () =>
43+
class {
44+
request = octokitRequest
45+
},
46+
},
47+
}))
48+
vi.mock('@octokit/plugin-throttling', () => ({throttling: {}}))
49+
50+
import runFileAction from '../src/index.ts'
51+
52+
const wontfixFinding = {
53+
scannerType: 'axe',
54+
ruleId: 'color-contrast',
55+
url: 'https://example.com/page',
56+
html: '<span>Low contrast</span>',
57+
problemShort: 'elements must meet minimum color contrast ratio thresholds',
58+
problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast',
59+
solutionShort: 'ensure sufficient contrast',
60+
}
61+
const normalFinding = {...wontfixFinding, ruleId: 'heading-order', html: '<h3>Skipped</h3>'}
62+
63+
// Both cached filings' findings reappear this run, so both are repeated
64+
const wontfixCached = {
65+
issue: {id: 1, nodeId: 'N1', url: 'https://github.com/org/repo/issues/1', title: 'wontfix'},
66+
findings: [wontfixFinding],
67+
}
68+
const normalCached = {
69+
issue: {id: 3, nodeId: 'N3', url: 'https://github.com/org/repo/issues/3', title: 'normal'},
70+
findings: [normalFinding],
71+
}
72+
73+
function setup() {
74+
files['/tmp/findings.json'] = JSON.stringify([wontfixFinding, normalFinding])
75+
files['/tmp/cached.json'] = JSON.stringify([wontfixCached, normalCached])
76+
inputs.findings_file = '/tmp/findings.json'
77+
inputs.cached_filings_file = '/tmp/cached.json'
78+
inputs.repository = 'org/repo'
79+
inputs.token = 'fake-token'
80+
// GET issue: issue 1 is labeled wontfix, issue 3 is not
81+
octokitRequest.mockImplementation((route: string) =>
82+
route.includes('/issues/1')
83+
? Promise.resolve({data: {labels: [{name: 'wontfix'}]}})
84+
: Promise.resolve({data: {labels: []}}),
85+
)
86+
}
87+
88+
describe('file action — wontfix label', () => {
89+
beforeEach(() => {
90+
vi.clearAllMocks()
91+
infoLines.length = 0
92+
for (const k of Object.keys(inputs)) delete inputs[k]
93+
for (const k of Object.keys(outputs)) delete outputs[k]
94+
})
95+
afterEach(() => {
96+
vi.restoreAllMocks()
97+
})
98+
99+
it('reopens the unlabeled issue but not the one labeled wontfix', async () => {
100+
setup()
101+
102+
await runFileAction()
103+
104+
expect(reopenIssue).toHaveBeenCalledTimes(1)
105+
const reopenedIssue = reopenIssue.mock.calls[0][1] as {url: string}
106+
expect(reopenedIssue.url).toBe('https://github.com/org/repo/issues/3')
107+
expect(openIssue).not.toHaveBeenCalled()
108+
expect(closeIssue).not.toHaveBeenCalled()
109+
})
110+
111+
it('logs that it skipped the wontfix issue', async () => {
112+
setup()
113+
114+
await runFileAction()
115+
116+
expect(infoLines.join('\n')).toContain(
117+
"Skipping reopen of issue labeled 'wontfix': https://github.com/org/repo/issues/1",
118+
)
119+
})
120+
})

0 commit comments

Comments
 (0)