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 e95f6d4..36f2279 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 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,30 @@ 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 title: string = `${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}' with ${issues.length} issues.`) + } catch (error) { + core.warning(`Failed to open tracking 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 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/action.yml b/action.yml index 932bc27..149851e 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,8 @@ inputs: default: "false" include_screenshots: description: "Whether to capture screenshots and include links to them in the issue" + 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" @@ -94,6 +96,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