diff --git a/.github/actions/file/action.yml b/.github/actions/file/action.yml index 005d3b0..4bc8654 100644 --- a/.github/actions/file/action.yml +++ b/.github/actions/file/action.yml @@ -14,6 +14,10 @@ inputs: cached_filings: description: "Cached filings from previous runs, as stringified JSON. Without this, duplicate issues may be filed." required: false + open_grouped_issues: + description: "In the 'file' step, also open grouped issues which link to all issues with the same root cause" + required: false + default: "false" outputs: filings: diff --git a/.github/actions/file/src/index.ts b/.github/actions/file/src/index.ts index 0c1304b..d6362ec 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -1,4 +1,10 @@ -import type { Finding, ResolvedFiling, RepeatedFiling } from "./types.d.js"; +import type { + Finding, + ResolvedFiling, + RepeatedFiling, + FindingGroupIssue, + Filing, +} from "./types.d.js"; import process from "node:process"; import core from "@actions/core"; import { Octokit } from "@octokit/core"; @@ -11,18 +17,20 @@ import { isResolvedFiling } from "./isResolvedFiling.js"; import { openIssue } from "./openIssue.js"; import { reopenIssue } from "./reopenIssue.js"; import { updateFilingsWithNewFindings } from "./updateFilingsWithNewFindings.js"; +import type { OctokitResponse } from "@octokit/types"; const OctokitWithThrottling = Octokit.plugin(throttling); export default async function () { core.info("Started 'file' action"); const findings: Finding[] = JSON.parse( - core.getInput("findings", { required: true }) + core.getInput("findings", { required: true }), ); const repoWithOwner = core.getInput("repository", { required: true }); const token = core.getInput("token", { required: true }); const cachedFilings: (ResolvedFiling | RepeatedFiling)[] = JSON.parse( - core.getInput("cached_filings", { required: false }) || "[]" + core.getInput("cached_filings", { required: false }) || "[]", ); + const shouldOpenGroupedIssues = core.getBooleanInput("open_grouped_issues"); core.debug(`Input: 'findings: ${JSON.stringify(findings)}'`); core.debug(`Input: 'repository: ${repoWithOwner}'`); core.debug(`Input: 'cached_filings: ${JSON.stringify(cachedFilings)}'`); @@ -32,7 +40,7 @@ export default async function () { throttle: { onRateLimit: (retryAfter, options, octokit, retryCount) => { octokit.log.warn( - `Request quota exhausted for request ${options.method} ${options.url}` + `Request quota exhausted for request ${options.method} ${options.url}`, ); if (retryCount < 3) { octokit.log.info(`Retrying after ${retryAfter} seconds!`); @@ -41,7 +49,7 @@ export default async function () { }, onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => { octokit.log.warn( - `Secondary rate limit hit for request ${options.method} ${options.url}` + `Secondary rate limit hit for request ${options.method} ${options.url}`, ); if (retryCount < 3) { octokit.log.info(`Retrying after ${retryAfter} seconds!`); @@ -52,8 +60,12 @@ export default async function () { }); const filings = updateFilingsWithNewFindings(cachedFilings, findings); + // Track new issues for grouping + const newIssuesByProblemShort: Record = {}; + const trackingIssueUrls: Record = {}; + for (const filing of filings) { - let response; + let response: OctokitResponse | undefined; try { if (isResolvedFiling(filing)) { // Close the filing’s issue (if necessary) @@ -62,7 +74,18 @@ export default async function () { } else if (isNewFiling(filing)) { // Open a new issue for the filing response = await openIssue(octokit, repoWithOwner, filing.findings[0]); - (filing as any).issue = { state: "open" } as Issue; + (filing as Filing).issue = { state: "open" } as Issue; + // Track for grouping + if (shouldOpenGroupedIssues) { + const problemShort: string = filing.findings[0].problemShort; + if (!newIssuesByProblemShort[problemShort]) { + newIssuesByProblemShort[problemShort] = []; + } + newIssuesByProblemShort[problemShort].push({ + url: response.data.html_url, + id: response.data.number, + }); + } } else if (isRepeatedFiling(filing)) { // Reopen the filing’s issue (if necessary) response = await reopenIssue(octokit, new Issue(filing.issue)); @@ -75,7 +98,7 @@ export default async function () { filing.issue.url = response.data.html_url; filing.issue.title = response.data.title; core.info( - `Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}` + `Set issue ${response.data.title} (${repoWithOwner}#${response.data.number}) state to ${filing.issue.state}`, ); } } catch (error) { @@ -84,6 +107,40 @@ export default async function () { } } + // Open tracking issues for each root cause and link back from each newly-created issue + if (shouldOpenGroupedIssues) { + for (const [problemShort, issues] of Object.entries( + newIssuesByProblemShort, + )) { + if (issues.length > 1) { + const title: string = `Accessibility tracking issue for all ${problemShort} issues`; + const body: string = + `# ${problemShort} issues\n\n` + + issues.map((issue) => `- [ ] ${issue.url}`).join("\n"); + try { + const trackingResponse = await octokit.request( + `POST /repos/${repoWithOwner}/issues`, + { + owner: repoWithOwner.split("/")[0], + repo: repoWithOwner.split("/")[1], + title, + body, + }, + ); + const trackingUrl: string = trackingResponse.data.html_url; + trackingIssueUrls[problemShort] = trackingUrl; + core.info( + `Opened tracking issue for '${problemShort}': ${issues.length} issues.`, + ); + } catch (error) { + core.warning( + `Failed to open grouped issue for '${problemShort}': ${error}`, + ); + } + } + } + } + core.setOutput("filings", JSON.stringify(filings)); core.debug(`Output: 'filings: ${JSON.stringify(filings)}'`); core.info("Finished 'file' action"); diff --git a/.github/actions/file/src/types.d.ts b/.github/actions/file/src/types.d.ts index bcc52ea..a42463a 100644 --- a/.github/actions/file/src/types.d.ts +++ b/.github/actions/file/src/types.d.ts @@ -33,3 +33,8 @@ export type RepeatedFiling = { }; export type Filing = ResolvedFiling | NewFiling | RepeatedFiling; + +export type FindingGroupIssue = { + url: string; + id: number; +}; diff --git a/action.yml b/action.yml index 3e3ee60..2bc88dd 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,10 @@ inputs: description: "Whether to skip assigning filed issues to Copilot" required: false default: "false" + open_grouped_issues: + description: "In the 'file' step, also open grouped issues which link to all issues with the same problem" + required: false + default: "false" outputs: results: @@ -88,6 +92,7 @@ runs: repository: ${{ inputs.repository }} token: ${{ inputs.token }} cached_filings: ${{ steps.normalize_cache.outputs.value }} + open_grouped_issues: ${{ inputs.open_grouped_issues }} - if: ${{ steps.file.outputs.filings }} name: Get issues from filings id: get_issues_from_filings