diff --git a/.github/actions/file/action.yml b/.github/actions/file/action.yml index c5db9d5f..40c63947 100644 --- a/.github/actions/file/action.yml +++ b/.github/actions/file/action.yml @@ -17,6 +17,10 @@ inputs: screenshot_repository: description: "Repository (with owner) where screenshots are stored on the gh-cache branch. Defaults to the 'repository' input if not set. Required if issues are open in a different repo to construct proper screenshot URLs." 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 edaf0196..b3e35df0 100644 --- a/.github/actions/file/src/index.ts +++ b/.github/actions/file/src/index.ts @@ -1,4 +1,4 @@ -import type {Finding, ResolvedFiling, RepeatedFiling} from './types.d.js' +import type {Finding, ResolvedFiling, RepeatedFiling, FindingGroupIssue, Filing, IssueResponse} from './types.d.js' import process from 'node:process' import * as core from '@actions/core' import {Octokit} from '@octokit/core' @@ -11,6 +11,7 @@ import {isResolvedFiling} from './isResolvedFiling.js' import {openIssue} from './openIssue.js' import {reopenIssue} from './reopenIssue.js' import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js' +import {OctokitResponse} from '@octokit/types' const OctokitWithThrottling = Octokit.plugin(throttling) export default async function () { @@ -22,10 +23,12 @@ export default async function () { const cachedFilings: (ResolvedFiling | RepeatedFiling)[] = JSON.parse( 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: 'screenshot_repository: ${screenshotRepo}'`) core.debug(`Input: 'cached_filings: ${JSON.stringify(cachedFilings)}'`) + core.debug(`Input: 'open_grouped_issues: ${shouldOpenGroupedIssues}'`) const octokit = new OctokitWithThrottling({ auth: token, @@ -48,8 +51,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) @@ -58,8 +65,19 @@ export default async function () { } else if (isNewFiling(filing)) { // Open a new issue for the filing response = await openIssue(octokit, repoWithOwner, filing.findings[0], screenshotRepo) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(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) and update the body with the latest finding response = await reopenIssue( @@ -87,6 +105,32 @@ export default async function () { } } + // Open tracking issues for groups with >1 new issue and link back from each + // new issue + if (shouldOpenGroupedIssues) { + for (const [problemShort, issues] of Object.entries(newIssuesByProblemShort)) { + if (issues.length > 1) { + const capitalizedProblemShort = problemShort[0].toUpperCase() + problemShort.slice(1) + const title: string = `${capitalizedProblemShort} issues` + const body: string = + `# ${capitalizedProblemShort} 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 '${capitalizedProblemShort}' with ${issues.length} issues.`) + } catch (error) { + core.warning(`Failed to open tracking issue for '${capitalizedProblemShort}': ${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 36069a55..2c0c8ac7 100644 --- a/.github/actions/file/src/types.d.ts +++ b/.github/actions/file/src/types.d.ts @@ -18,6 +18,14 @@ export type Issue = { state?: 'open' | 'reopened' | 'closed' } +export type IssueResponse = { + id: number + node_id: string + number: number + html_url: string + title: string +} + export type ResolvedFiling = { findings: never[] issue: Issue @@ -34,3 +42,8 @@ export type RepeatedFiling = { } export type Filing = ResolvedFiling | NewFiling | RepeatedFiling + +export type FindingGroupIssue = { + url: string + id: number +} diff --git a/README.md b/README.md index c71fad78..66a12c3a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ jobs: # auth_context: # Optional: Stringified JSON object for complex authentication # skip_copilot_assignment: false # Optional: Set to true to skip assigning issues to GitHub Copilot (or if you don't have GitHub Copilot) # include_screenshots: false # Optional: Set to true to capture screenshots and include links to them in filed issues + # open_grouped_issues: false # Optional: Set to true to open an issue grouping individual issues per violation # reduced_motion: no-preference # Optional: Playwright reduced motion configuration option # color_scheme: light # Optional: Playwright color scheme configuration option ``` diff --git a/action.yml b/action.yml index 063206dd..794dc2ec 100644 --- a/action.yml +++ b/action.yml @@ -35,6 +35,10 @@ inputs: description: "Whether to capture screenshots and include links to them in the issue" 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" reduced_motion: description: "Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion" required: false @@ -102,6 +106,7 @@ runs: token: ${{ inputs.token }} cached_filings: ${{ steps.normalize_cache.outputs.value }} screenshot_repository: ${{ github.repository }} + open_grouped_issues: ${{ inputs.open_grouped_issues }} - if: ${{ steps.file.outputs.filings }} name: Get issues from filings id: get_issues_from_filings