Skip to content

Commit 37b9db9

Browse files
committed
Batch wontfix lookups into a single set check
1 parent 2feeb9d commit 37b9db9

6 files changed

Lines changed: 149 additions & 93 deletions

File tree

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +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'
13+
import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from './shouldReopenIssue.js'
1414
import {openIssue} from './openIssue.js'
1515
import {reopenIssue} from './reopenIssue.js'
1616
import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js'
@@ -61,6 +61,17 @@ export default async function () {
6161
})
6262
const filings = updateFilingsWithNewFindings(cachedFilings, findings)
6363

64+
// Fetch closed wontfix issues once up front; a failed fetch reopens as usual
65+
let wontfixIssueNumbers = new Set<number>()
66+
if (!dryRun) {
67+
try {
68+
const [owner, repository] = repoWithOwner.split('/')
69+
wontfixIssueNumbers = await getWontfixIssueNumbers(octokit, {owner, repository})
70+
} catch (error) {
71+
core.warning(`Could not fetch '${WONTFIX_LABEL}' issues; proceeding with reopen as usual: ${error}`)
72+
}
73+
}
74+
6475
// Track new issues for grouping
6576
const newIssuesByProblemShort: Record<string, FindingGroupIssue[]> = {}
6677
const trackingIssueUrls: Record<string, string> = {}
@@ -108,14 +119,7 @@ export default async function () {
108119
}
109120
} else if (isRepeatedFiling(filing)) {
110121
const issue = new Issue(filing.issue)
111-
let isWontfix = false
112-
try {
113-
isWontfix = await isWontfixIssue(octokit, issue)
114-
} catch (error) {
115-
// A failed label check shouldn't abort the run, so reopen as usual
116-
core.warning(`Could not check labels for ${filing.issue.url}; proceeding with reopen: ${error}`)
117-
}
118-
if (isWontfix) {
122+
if (!shouldReopenIssue(issue, wontfixIssueNumbers)) {
119123
// The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed
120124
core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`)
121125
} else {

.github/actions/file/src/isWontfixIssue.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
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/isWontfixIssue.test.ts

Lines changed: 0 additions & 60 deletions
This file was deleted.
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+
})

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ vi.mock('node:fs', () => ({
3838
},
3939
}))
4040

41-
// Stub Octokit: `request` serves the GET that isWontfixIssue makes
41+
// Stub Octokit: `request` serves the list of closed `wontfix` issues that
42+
// getWontfixIssueNumbers fetches once up front.
4243
const octokitRequest = vi.fn()
4344
vi.mock('@octokit/core', () => ({
4445
Octokit: {
@@ -80,11 +81,9 @@ function setup() {
8081
inputs.cached_filings_file = '/tmp/cached.json'
8182
inputs.repository = 'org/repo'
8283
inputs.token = 'fake-token'
83-
// GET issue: issue 1 is labeled wontfix, issue 3 is not
84+
// Single up-front fetch: only issue 1 is closed and labeled wontfix
8485
octokitRequest.mockImplementation((route: string) =>
85-
route.includes('/issues/1')
86-
? Promise.resolve({data: {labels: [{name: 'wontfix'}]}})
87-
: Promise.resolve({data: {labels: []}}),
86+
route.includes('GET /repos/org/repo/issues') ? Promise.resolve({data: [{number: 1}]}) : Promise.resolve({data: {}}),
8887
)
8988
}
9089

@@ -124,13 +123,13 @@ describe('file action — wontfix label', () => {
124123

125124
it('reopens as usual (and warns) when the label check fails', async () => {
126125
setup()
127-
// The label-check GET fails for every issue (e.g. transient API error)
126+
// The up-front wontfix fetch fails (e.g. transient API error)
128127
octokitRequest.mockRejectedValue(new Error('boom'))
129128

130129
await runFileAction()
131130

132131
// Both repeated filings should still be reopened rather than aborting the run
133132
expect(reopenIssue).toHaveBeenCalledTimes(2)
134-
expect(warnLines.join('\n')).toContain('Could not check labels for')
133+
expect(warnLines.join('\n')).toContain("Could not fetch 'wontfix' issues")
135134
})
136135
})

0 commit comments

Comments
 (0)