Skip to content

Commit cdb5c22

Browse files
authored
feat: Don’t open duplicate issues for repeat findings (#11)
Fixes github/accessibility#9205 (Hubber access only) This PR prevents duplicate issues from being opened when findings are rediscovered in subsequent runs. This is accomplished by caching the findings (with issue URLs) that are outputted by the ‘file’ action, using a newly-added [`gh-cache/cache`](https://github.com/github/continuous-accessibility-scanner/tree/smockle/cache-findings/.github/actions/gh-cache/cache) action[^1]. I tested these changes by running this branch’s `continuous-accessibility-scanner` action twice: The first run—[github/accessibility-sandbox’s run/16838980870](https://github.com/github/accessibility-sandbox/actions/runs/16838980870/job/47705300589) (Hubber access only)—logged `Created issue: Accessibility issue: Links must have discernible text on /octicons/ (github/accessibility-sandbox#4373)` (on line 312), created [github/accessibility-sandbox#4373](github/accessibility-sandbox#4373) (Hubber access only)), and saved a reference to that issue in [github/accessibility-sandbox’s `gh-cache` branch’s `cached_findings-main`](https://github.com/github/accessibility-sandbox/blob/gh-cache/cached_findings-main) (Hubber access only). The second run—[github/accessibility-sandbox’s run/16839227092](https://github.com/github/accessibility-sandbox/actions/runs/16839227092/job/47706132729) (Hubber access only)—logged `Repeated issue: Accessibility issue: Links must have discernible text on /octicons/ (github/accessibility-sandbox#4373)` (on line 310), and _didn’t_ create another issue. [^1]: `gh-cache/cache` itself uses newly-added [`gh-cache/save`](https://github.com/github/continuous-accessibility-scanner/tree/smockle/cache-findings/.github/actions/gh-cache/save) and [`gh-cache/restore`](https://github.com/github/continuous-accessibility-scanner/tree/smockle/cache-findings/.github/actions/gh-cache/restore) actions.
2 parents 21961ae + 851f437 commit cdb5c22

24 files changed

+489
-70
lines changed

.github/actions/file/README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,28 @@ Files GitHub issues to track potential accessibility gaps.
2222

2323
**Required** Token with fine-grained permission 'issues: write'.
2424

25+
#### `cached_findings`
26+
27+
**Optional** Cached findings from previous runs, as stringified JSON. Without this, duplicate issues may be filed. For example: `'[]'`.
28+
2529
### Outputs
2630

27-
#### `issue_numbers`
31+
#### `findings`
32+
33+
List of potential accessibility gaps (plus issue URLs), as stringified JSON. For example:
34+
35+
```JS
36+
'[]'
37+
```
38+
39+
#### `closed_issue_urls`
40+
41+
List of URLs for closed issues, as stringified JSON. For example: `'["https://github.com/github/docs/issues/123","https://github.com/github/docs/issues/124","https://github.com/github/docs/issues/126","https://github.com/github/docs/issues/127"]'`.
42+
43+
#### `opened_issue_urls`
44+
45+
List of URLs for newly-opened issues, as stringified JSON. For example: `'["https://github.com/github/docs/issues/123","https://github.com/github/docs/issues/124","https://github.com/github/docs/issues/126","https://github.com/github/docs/issues/127"]'`.
46+
47+
#### `repeated_issue_urls`
2848

29-
List of issue numbers for created issues, as stringified JSON. For example: `'[123, 124, 126, 127]'`.
49+
List of URLs for repeated issues, as stringified JSON. For example: `'["https://github.com/github/docs/issues/123","https://github.com/github/docs/issues/124","https://github.com/github/docs/issues/126","https://github.com/github/docs/issues/127"]'`.

.github/actions/file/action.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ inputs:
1111
token:
1212
description: "Token with fine-grained permission 'issues: write'"
1313
required: true
14+
cached_findings:
15+
description: "Cached findings from previous runs, as stringified JSON. Without this, duplicate issues may be filed."
16+
required: false
1417

1518
outputs:
16-
issue_numbers:
17-
description: "List of issue numbers for created issues, as stringified JSON"
19+
findings:
20+
description: "List of potential accessibility gaps (plus issue URLs), as stringified JSON"
21+
closed_issue_urls:
22+
description: "List of URLs for closed issues, as stringified JSON"
23+
opened_issue_urls:
24+
description: "List of URLs for newly-opened issues, as stringified JSON"
25+
repeated_issue_urls:
26+
description: "List of URLs for repeated issues, as stringified JSON"
1827

1928
runs:
2029
using: "node20"

.github/actions/file/bootstrap.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import fs from 'node:fs'
55
import * as url from 'node:url'
66
import { spawn } from 'node:child_process'
77

8-
function spawnPromisified(command, args, options) {
8+
function spawnPromisified(command, args, { quiet = false, ...options } = {}) {
99
return new Promise((resolve, reject) => {
1010
const proc = spawn(command, args, options)
1111
proc.stdout.setEncoding('utf8')
1212
proc.stdout.on('data', (data) => {
13-
console.log(data)
13+
if (!quiet) {
14+
console.log(data)
15+
}
1416
})
1517
proc.stderr.setEncoding('utf8')
1618
proc.stderr.on('data', (data) => {
@@ -36,7 +38,8 @@ await (async () => {
3638
} catch {
3739
try {
3840
await spawnPromisified('npm', ['ci'], {
39-
cwd: url.fileURLToPath(new URL('.', import.meta.url))
41+
cwd: url.fileURLToPath(new URL('.', import.meta.url)),
42+
quiet: true
4043
})
4144
} catch {
4245
process.exit(1)
@@ -45,7 +48,8 @@ await (async () => {
4548
// Compile TypeScript.
4649
try {
4750
await spawnPromisified('npm', ['run', 'build'], {
48-
cwd: url.fileURLToPath(new URL('.', import.meta.url))
51+
cwd: url.fileURLToPath(new URL('.', import.meta.url)),
52+
quiet: true
4953
})
5054
} catch {
5155
process.exit(1)
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 { Finding } from './types.d.js';
3+
4+
export async function closeIssueForFinding(octokit: Octokit, repoWithOwner: string, finding: Finding) {
5+
const owner = repoWithOwner.split('/')[0];
6+
const repo = repoWithOwner.split('/')[1];
7+
const issueNumber = finding.issueUrl?.split('/').pop();
8+
if (!issueNumber) {
9+
throw new Error(`Invalid issue URL: ${finding.issueUrl}`);
10+
}
11+
return octokit.request(`PATCH /repos/${owner}/${repo}/issues/${issueNumber}`, {
12+
owner,
13+
repo,
14+
issue_number: issueNumber,
15+
state: 'closed'
16+
});
17+
}

.github/actions/file/src/fileIssueForFinding.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,68 @@
11
import type { Finding } from "./types.d.js";
22
import core from "@actions/core";
33
import { Octokit } from '@octokit/core';
4-
import { fileIssueForFinding } from "./fileIssueForFinding.js";
4+
import { toFindingsMap } from "./toFindingsMap.js"
5+
import { closeIssueForFinding } from "./closeIssueForFinding.js";
6+
import { openIssueForFinding } from "./openIssueForFinding.js";
57

68
export default async function () {
9+
core.info("Started 'file' action");
710
const findings: Finding[] = JSON.parse(core.getInput('findings', { required: true }));
811
const repoWithOwner = core.getInput('repository', { required: true });
912
const token = core.getInput('token', { required: true });
13+
const cachedFindings: Finding[] = JSON.parse(core.getInput('cached_findings', { required: false }) || "[]");
14+
core.debug(`Input: 'findings: ${JSON.stringify(findings)}'`);
15+
core.debug(`Input: 'repository: ${repoWithOwner}'`);
16+
core.debug(`Input: 'cached_findings: ${JSON.stringify(cachedFindings)}'`);
17+
18+
const findingsMap = toFindingsMap(findings);
19+
const cachedFindingsMap = toFindingsMap(cachedFindings);
1020

11-
const issueNumbers = [];
1221
const octokit = new Octokit({ auth: token });
22+
const closedIssueUrls = [];
23+
const openedIssueUrls = [];
24+
const repeatIssueUrls = [];
25+
26+
for (const cachedFinding of cachedFindings) {
27+
if (!findingsMap.has(`${cachedFinding.url};${cachedFinding.problemShort};${cachedFinding.html}`)) {
28+
try {
29+
// Finding was not found in the latest run, so close its issue (if necessary)
30+
const response = await closeIssueForFinding(octokit, repoWithOwner, cachedFinding);
31+
closedIssueUrls.push(response.data.html_url);
32+
core.info(`Closed issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
33+
} catch (error) {
34+
core.error(`Failed to close issue for finding: ${error}`);
35+
}
36+
}
37+
}
38+
1339
for (const finding of findings) {
14-
const response = await fileIssueForFinding(octokit, repoWithOwner, finding);
15-
issueNumbers.push(response.data.number);
16-
console.log(`Created issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
40+
const cachedIssueUrl = cachedFindingsMap.get(`${finding.url};${finding.problemShort};${finding.html}`)?.issueUrl
41+
finding.issueUrl = cachedIssueUrl;
42+
try {
43+
const response = await openIssueForFinding(octokit, repoWithOwner, finding);
44+
finding.issueUrl = response.data.html_url;
45+
if (response.data.html_url === cachedIssueUrl) {
46+
// Finding was found in previous and latest runs, so reopen its issue (if necessary)
47+
repeatIssueUrls.push(response.data.html_url);
48+
core.info(`Repeated issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
49+
} else {
50+
// New finding was found in the latest run, so create its issue
51+
openedIssueUrls.push(response.data.html_url);
52+
core.info(`Created issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
53+
}
54+
} catch (error) {
55+
core.error(`Failed to open/reopen issue for finding: ${error}`);
56+
}
1757
}
1858

19-
core.setOutput("issue_numbers", JSON.stringify(issueNumbers));
20-
}
59+
core.setOutput("closed_issue_urls", JSON.stringify(closedIssueUrls));
60+
core.setOutput("opened_issue_urls", JSON.stringify(openedIssueUrls));
61+
core.setOutput("repeated_issue_urls", JSON.stringify(repeatIssueUrls));
62+
core.setOutput("findings", JSON.stringify(findings));
63+
core.debug(`Output: 'closed_issue_urls: ${JSON.stringify(closedIssueUrls)}'`);
64+
core.debug(`Output: 'opened_issue_urls: ${JSON.stringify(openedIssueUrls)}'`);
65+
core.debug(`Output: 'repeated_issue_urls: ${JSON.stringify(repeatIssueUrls)}'`);
66+
core.debug(`Output: 'findings: ${JSON.stringify(findings)}'`);
67+
core.info("Finished 'file' action");
68+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Octokit } from '@octokit/core';
2+
import type { Finding } from './types.d.js';
3+
import * as url from 'node:url'
4+
const URL = url.URL;
5+
6+
export async function openIssueForFinding(octokit: Octokit, repoWithOwner: string, finding: Finding) {
7+
const owner = repoWithOwner.split('/')[0];
8+
const repo = repoWithOwner.split('/')[1];
9+
const issueNumber = finding.issueUrl?.split('/').pop();
10+
if (issueNumber) {
11+
// If an issue already exists, ensure it is open
12+
return octokit.request(`PATCH /repos/${owner}/${repo}/issues/${issueNumber}`, {
13+
owner,
14+
repo,
15+
issue_number: issueNumber,
16+
state: 'open'
17+
});
18+
} else {
19+
// Otherwise, create a new issue
20+
const title = `Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`;
21+
const solutionLong = finding.solutionLong
22+
?.split("\n")
23+
.map((line) =>
24+
!line.trim().startsWith("Fix any") &&
25+
!line.trim().startsWith("Fix all") &&
26+
line.trim() !== ""
27+
? `- ${line}`
28+
: line
29+
)
30+
.join("\n");
31+
const body = `
32+
An accessibility scan flagged the element \`${finding.html}\` on ${finding.url} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.
33+
34+
To fix this, ${finding.solutionShort}.
35+
${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''}
36+
`;
37+
38+
return octokit.request(`POST /repos/${owner}/${repo}/issues`, {
39+
owner,
40+
repo,
41+
title,
42+
body
43+
});
44+
}
45+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type {Finding} from "./types.d.js";
2+
3+
export function toFindingsMap(findings: Finding[]): Map<string, Finding> {
4+
const map = new Map<string, Finding>();
5+
for (const finding of findings) {
6+
const key = `${finding.url};${finding.problemShort};${finding.html}`;
7+
map.set(key, finding);
8+
}
9+
return map;
10+
}

.github/actions/file/src/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export type Finding = {
55
problemUrl: string;
66
solutionShort: string;
77
solutionLong?: string;
8+
issueUrl?: string;
89
}

.github/actions/find/bootstrap.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import fs from 'node:fs'
55
import * as url from 'node:url'
66
import { spawn } from 'node:child_process'
77

8-
function spawnPromisified(command, args, options) {
8+
function spawnPromisified(command, args, { quiet = false, ...options } = {}) {
99
return new Promise((resolve, reject) => {
1010
const proc = spawn(command, args, options)
1111
proc.stdout.setEncoding('utf8')
1212
proc.stdout.on('data', (data) => {
13-
console.log(data)
13+
if (!quiet) {
14+
console.log(data)
15+
}
1416
})
1517
proc.stderr.setEncoding('utf8')
1618
proc.stderr.on('data', (data) => {
@@ -36,7 +38,8 @@ await (async () => {
3638
} catch {
3739
try {
3840
await spawnPromisified('npm', ['ci'], {
39-
cwd: url.fileURLToPath(new URL('.', import.meta.url))
41+
cwd: url.fileURLToPath(new URL('.', import.meta.url)),
42+
quiet: true
4043
})
4144
} catch {
4245
process.exit(1)
@@ -45,7 +48,8 @@ await (async () => {
4548
// Compile TypeScript.
4649
try {
4750
await spawnPromisified('npm', ['run', 'build'], {
48-
cwd: url.fileURLToPath(new URL('.', import.meta.url))
51+
cwd: url.fileURLToPath(new URL('.', import.meta.url)),
52+
quiet: true
4953
})
5054
} catch {
5155
process.exit(1)

0 commit comments

Comments
 (0)