diff --git a/.github/actions/file/action.yml b/.github/actions/file/action.yml index c5db9d5..40c6394 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 edaf019..b3e35df 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 36069a5..2c0c8ac 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 c71fad7..66a12c3 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 063206d..794dc2e 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 diff --git a/tests/site-with-errors.test.ts b/tests/site-with-errors.test.ts index 5ce4696..7899b86 100644 --- a/tests/site-with-errors.test.ts +++ b/tests/site-with-errors.test.ts @@ -16,7 +16,8 @@ describe('site-with-errors', () => { }) it('cache has expected results', () => { - const actual = results.map(({issue: {url: issueUrl}, pullRequest: {url: pullRequestUrl}, findings}) => { + const actual = results.map(({issue: {url: issueUrl}, pullRequest, findings}) => { + const pullRequestUrl = pullRequest?.url const {problemUrl, solutionLong, screenshotId, ...finding} = findings[0] // Check volatile fields for existence only expect(issueUrl).toBeDefined() @@ -144,19 +145,20 @@ describe('site-with-errors', () => { ) // Fetch pull requests referenced in the findings file pullRequests = await Promise.all( - results.map(async ({pullRequest: {url: pullRequestUrl}}) => { + results.map(async ({pullRequest}) => { + const pullRequestUrl = pullRequest?.url expect(pullRequestUrl).toBeDefined() const {owner, repo, pullNumber} = /https:\/\/github\.com\/(?[^/]+)\/(?[^/]+)\/pull\/(?\d+)/.exec( pullRequestUrl!, )!.groups! - const {data: pullRequest} = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + const {data: fetchedPullRequest} = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { owner, repo, pull_number: parseInt(pullNumber, 10), }) - expect(pullRequest).toBeDefined() - return pullRequest + expect(fetchedPullRequest).toBeDefined() + return fetchedPullRequest }), ) })