Skip to content

Commit 1872c4f

Browse files
committed
feat: Avoid creating duplicate issues
1 parent 35d0f86 commit 1872c4f

File tree

10 files changed

+159
-46
lines changed

10 files changed

+159
-46
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: `'[123, 124, 126, 127]'`.
42+
43+
#### `opened_issue_urls`
44+
45+
List of URLs for newly-opened issues, as stringified JSON. For example: `'[123, 124, 126, 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: `'[123, 124, 126, 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"
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 await 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: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,51 @@
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 () {
79
const findings: Finding[] = JSON.parse(core.getInput('findings', { required: true }));
810
const repoWithOwner = core.getInput('repository', { required: true });
911
const token = core.getInput('token', { required: true });
12+
const cachedFindings: Finding[] = JSON.parse(core.getInput('cached_findings', { required: false }) || "[]");
13+
14+
const findingsMap = toFindingsMap(findings);
15+
const cachedFindingsMap = toFindingsMap(cachedFindings);
1016

11-
const issueNumbers = [];
1217
const octokit = new Octokit({ auth: token });
18+
19+
const closedIssueUrls = [];
20+
for (const cachedFinding of cachedFindings) {
21+
if (!findingsMap.has(`${cachedFinding.url};${cachedFinding.problemShort};${cachedFinding.html}`)) {
22+
// Finding was not found in the latest run, so close its issue (if necessary)
23+
const response = await closeIssueForFinding(octokit, repoWithOwner, cachedFinding);
24+
closedIssueUrls.push(response.data.html_url);
25+
console.log(`Closed issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
26+
}
27+
}
28+
29+
const openedIssueUrls = [];
30+
const repeatIssueUrls = [];
1331
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})`);
32+
const cachedIssueUrl = cachedFindingsMap.get(`${finding.url};${finding.problemShort};${finding.html}`)?.issueUrl
33+
finding.issueUrl = cachedIssueUrl;
34+
const response = await openIssueForFinding(octokit, repoWithOwner, finding);
35+
finding.issueUrl = response.data.html_url;
36+
if (response.data.html_url === cachedIssueUrl) {
37+
// Finding was found in previous and latest runs, so reopen its issue (if necessary)
38+
repeatIssueUrls.push(response.data.html_url);
39+
console.log(`Repeated issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
40+
} else {
41+
// New finding was found in the latest run, so create its issue
42+
openedIssueUrls.push(response.data.html_url);
43+
console.log(`Created issue: ${response.data.title} (${repoWithOwner}#${response.data.number})`);
44+
}
1745
}
1846

19-
core.setOutput("issue_numbers", JSON.stringify(issueNumbers));
20-
}
47+
core.setOutput("closed_issue_urls", JSON.stringify(closedIssueUrls));
48+
core.setOutput("opened_issue_urls", JSON.stringify(openedIssueUrls));
49+
core.setOutput("repeated_issue_urls", JSON.stringify(repeatIssueUrls));
50+
core.setOutput("findings", JSON.stringify(findings));
51+
}
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 await 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 await 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
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ https://primer.style/octicons/
2121

2222
#### `token`
2323

24-
**Required** Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'.
24+
**Required** Personal access token (PAT) with fine-grained permissions 'contents: write', 'issues: write', and 'pull_requests: write'.
2525

2626
### Example workflow
2727

action.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ inputs:
1010
description: "Repository (with owner) to file issues in"
1111
required: true
1212
token:
13-
description: "Personal access token (PAT) with fine-grained permissions 'issues: write' and 'pull_requests: write'"
13+
description: "Personal access token (PAT) with fine-grained permissions 'contents: write', 'issues: write', and 'pull_requests: write'"
1414
required: true
1515

1616
runs:
1717
using: "composite"
1818
steps:
19+
- name: Restore cached_findings
20+
id: restore
21+
uses: github/continuous-accessibility-scanner/.github/actions/gh-cache/cache@main
22+
with:
23+
key: cached_findings-${{ github.ref_name }}
24+
token: ${{ inputs.token }}
1925
- name: Find
2026
id: find
2127
uses: github/continuous-accessibility-scanner/.github/actions/find@main
@@ -28,13 +34,20 @@ runs:
2834
findings: ${{ steps.find.outputs.findings }}
2935
repository: ${{ inputs.repository }}
3036
token: ${{ inputs.token }}
37+
cached_findings: ${{ steps.restore.outputs.value }}
3138
- name: Fix
3239
id: fix
3340
uses: github/continuous-accessibility-scanner/.github/actions/fix@main
3441
with:
3542
issue_numbers: ${{ steps.file.outputs.issue_numbers }}
3643
repository: ${{ inputs.repository }}
3744
token: ${{ inputs.token }}
45+
- name: Save cached_findings
46+
uses: github/continuous-accessibility-scanner/.github/actions/gh-cache/cache@main
47+
with:
48+
key: cached_findings-${{ github.ref_name }}
49+
value: ${{ steps.file.outputs.findings }}
50+
token: ${{ inputs.token }}
3851

3952
branding:
4053
icon: "compass"

0 commit comments

Comments
 (0)