Skip to content
27 changes: 25 additions & 2 deletions .github/actions/file/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Files GitHub issues to track potential accessibility gaps.
**Required** Path to a JSON file containing the list of potential accessibility gaps. The path can be absolute or relative to the working directory (which defaults to `GITHUB_WORKSPACE`). For example: `findings.json`.

The file should contain a JSON array of finding objects. For example:

```json
[]
```
Expand All @@ -28,27 +29,49 @@ The file should contain a JSON array of finding objects. For example:
**Optional** Path to a JSON file containing cached filings from previous runs. The path can be absolute or relative to the working directory (which defaults to `GITHUB_WORKSPACE`). Without this, duplicate issues may be filed. For example: `cached-filings.json`.

The file should contain a JSON array of filing objects. For example:

```json
[
{
"findings": [],
"issue": {"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"}
"issue": {
"id": 1,
"nodeId": "SXNzdWU6MQ==",
"url": "https://github.com/github/docs/issues/123",
"title": "Accessibility issue: 1"
}
}
]
```

#### `group_by`

**Optional** How to consolidate findings into issues. One of:

- `finding` (default): one issue per individual violation — current behavior, unchanged.
- `rule`: one issue per rule (`ruleId`/`scannerType`), aggregating every occurrence across all scanned URLs.
- `rule+url`: one issue per rule per scanned URL.

When grouping, each additional occurrence is appended to the single "umbrella" issue body as a checklist item under an **Occurrences** section rather than spawning a new issue. This is the preferred mechanism for consolidating issues over `open_grouped_issues`.

### Outputs

#### `filings_file`

Absolute path to a JSON file containing the list of issues filed (and their associated finding(s)). The action writes this file to a temporary directory and returns the absolute path. For example: `$RUNNER_TEMP/filings-<uuid>.json`.

The file will contain a JSON array of filing objects. For example:

```json
[
{
"findings": [],
"issue": {"id":1,"nodeId":"SXNzdWU6MQ==","url":"https://github.com/github/docs/issues/123","title":"Accessibility issue: 1"}
"issue": {
"id": 1,
"nodeId": "SXNzdWU6MQ==",
"url": "https://github.com/github/docs/issues/123",
"title": "Accessibility issue: 1"
}
Comment thread
taarikashenafi marked this conversation as resolved.
}
]
```
40 changes: 22 additions & 18 deletions .github/actions/file/action.yml
Comment thread
taarikashenafi marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,50 +1,54 @@
name: "File"
description: "Files GitHub issues to track potential accessibility gaps."
name: 'File'
description: 'Files GitHub issues to track potential accessibility gaps.'

inputs:
findings_file:
description: "Path to a JSON file containing the list of potential accessibility gaps"
description: 'Path to a JSON file containing the list of potential accessibility gaps'
required: true
repository:
description: "Repository (with owner) to file issues in"
description: 'Repository (with owner) to file issues in'
required: true
token:
description: "Token with fine-grained permission 'issues: write'"
required: true
base_url:
description: "Optional base URL to pass into Octokit for the GitHub API (for example, `https://YOUR_HOSTNAME/api/v3` for GitHub Enterprise Server)"
description: 'Optional base URL to pass into Octokit for the GitHub API (for example, `https://YOUR_HOSTNAME/api/v3` for GitHub Enterprise Server)'
required: false
cached_filings_file:
description: "Path to a JSON file containing cached filings from previous runs. Without this, duplicate issues may be filed."
description: 'Path to a JSON file containing cached filings from previous runs. Without this, duplicate issues may be filed.'
required: false
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"
default: 'false'
group_by:
description: "How to group findings into issues: 'finding' (one issue per violation, default), 'rule' (one issue per rule), or 'rule+url' (one issue per rule per scanned URL)."
required: false
default: 'finding'
dry_run:
description: "When true, log the issues that would be filed without opening, closing, or reopening any issues."
description: 'When true, log the issues that would be filed without opening, closing, or reopening any issues.'
required: false
default: "false"
default: 'false'
file_best_practice_issues:
description: "File issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Disabling only suppresses new issues; existing ones are left untouched."
description: 'File issues for best-practice findings (accessibility recommendations that are not hard WCAG failures). Disabling only suppresses new issues; existing ones are left untouched.'
required: false
default: "true"
default: 'true'
file_experimental_issues:
description: "File issues for experimental findings (checks that are not yet stable). Disabling only suppresses new issues; existing ones are left untouched."
description: 'File issues for experimental findings (checks that are not yet stable). Disabling only suppresses new issues; existing ones are left untouched.'
required: false
default: "true"
default: 'true'

outputs:
filings_file:
description: "Path to a JSON file containing the list of issues filed (and their associated finding(s))"
description: 'Path to a JSON file containing the list of issues filed (and their associated finding(s))'

runs:
using: "node24"
main: "bootstrap.js"
using: 'node24'
main: 'bootstrap.js'

branding:
icon: "compass"
color: "blue"
icon: 'compass'
color: 'blue'
17 changes: 15 additions & 2 deletions .github/actions/file/src/generateIssueBody.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type {Finding} from './types.d.js'

export function generateIssueBody(finding: Finding, screenshotRepo: string): string {
export function generateIssueBody(occurrences: Finding | Finding[], screenshotRepo: string): string {
const findings = Array.isArray(occurrences) ? occurrences : [occurrences]
const finding = findings[0]

const solutionLong = finding.solutionLong
?.split('\n')
.map((line: string) =>
Expand All @@ -18,6 +21,16 @@ export function generateIssueBody(finding: Finding, screenshotRepo: string): str
`
}

let occurrencesSection = ''
if (findings.length > 1) {
const items = findings.map(f => `- [ ] ${f.html ? `\`${f.html}\` on ${f.url}` : f.url}`).join('\n')
occurrencesSection = `
## ${findings.length} Other Occurrences:

${items}
`
}

const categoryNotice =
finding.category && finding.category !== 'wcag'
? `> [!NOTE]\n> This is ${
Expand All @@ -42,7 +55,7 @@ An accessibility scan ${finding.html ? `flagged the element \`${finding.html}\``
${screenshotSection ?? ''}
To fix this, ${finding.solutionShort}.
${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''}

${occurrencesSection}
${acceptanceCriteria}
`

Expand Down
7 changes: 7 additions & 0 deletions .github/actions/file/src/groupBy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const GROUP_BY_VALUES = ['finding', 'rule', 'rule+url'] as const

export type GroupBy = (typeof GROUP_BY_VALUES)[number]

export function isGroupBy(value: string): value is GroupBy {
return (GROUP_BY_VALUES as readonly string[]).includes(value)
}
16 changes: 12 additions & 4 deletions .github/actions/file/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {getWontfixIssueNumbers, shouldReopenIssue, WONTFIX_LABEL} from './should
import {openIssue} from './openIssue.js'
import {reopenIssue} from './reopenIssue.js'
import {updateFilingsWithNewFindings} from './updateFilingsWithNewFindings.js'
import {GROUP_BY_VALUES, isGroupBy} from './groupBy.js'
import {OctokitResponse} from '@octokit/types'
const OctokitWithThrottling = Octokit.plugin(throttling)

Expand All @@ -36,6 +37,12 @@ export default async function () {
? JSON.parse(fs.readFileSync(cachedFilingsFile, 'utf8'))
: []
const shouldOpenGroupedIssues = core.getBooleanInput('open_grouped_issues')
const groupByInput = core.getInput('group_by') || 'finding'
if (!isGroupBy(groupByInput)) {
core.setFailed(`Invalid 'group_by' value: '${groupByInput}'. Must be one of: ${GROUP_BY_VALUES.join(', ')}.`)
return
}
const groupBy = groupByInput
const dryRun = core.getBooleanInput('dry_run')
const fileBestPracticeIssues = getBooleanInputWithDefault('file_best_practice_issues', true)
const fileExperimentalIssues = getBooleanInputWithDefault('file_experimental_issues', true)
Expand All @@ -45,6 +52,7 @@ export default async function () {
core.debug(`Input: 'screenshot_repository: ${screenshotRepo}'`)
core.debug(`Input: 'cached_filings_file: ${cachedFilingsFile}'`)
core.debug(`Input: 'open_grouped_issues: ${shouldOpenGroupedIssues}'`)
core.debug(`Input: 'group_by: ${groupBy}'`)
core.debug(`Input: 'dry_run: ${dryRun}'`)
core.debug(`Input: 'file_best_practice_issues: ${fileBestPracticeIssues}'`)
core.debug(`Input: 'file_experimental_issues: ${fileExperimentalIssues}'`)
Expand All @@ -69,7 +77,7 @@ export default async function () {
},
},
})
const filings = updateFilingsWithNewFindings(cachedFilings, findings)
const filings = updateFilingsWithNewFindings(cachedFilings, findings, groupBy)

// Suppressed new filings are kept out of the cache
const suppressedFilings = new Set<Filing>()
Expand Down Expand Up @@ -131,7 +139,7 @@ export default async function () {
filing.issue.state = 'closed'
} else if (isNewFiling(filing)) {
// Open a new issue for the filing
response = await openIssue(octokit, repoWithOwner, filing.findings[0], screenshotRepo)
response = await openIssue(octokit, repoWithOwner, filing.findings, screenshotRepo)
;(filing as Filing).issue = {state: 'open'} as Issue

// Track for grouping
Expand All @@ -151,8 +159,8 @@ export default async function () {
// The developer intentionally closed this issue and labeled it 'wontfix', so leave it closed
core.info(`Skipping reopen of issue labeled '${WONTFIX_LABEL}': ${filing.issue.url}`)
} else {
// Reopen the filing's issue and update the body with the latest finding
response = await reopenIssue(octokit, issue, filing.findings[0], repoWithOwner, screenshotRepo)
// Reopen the filing's issue and update the body with the latest finding(s)
response = await reopenIssue(octokit, issue, filing.findings, repoWithOwner, screenshotRepo)
filing.issue.state = 'reopened'
}
}
Expand Down
19 changes: 11 additions & 8 deletions .github/actions/file/src/openIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,29 @@ function truncateWithEllipsis(text: string, maxLength: number): string {
return text.length > maxLength ? text.slice(0, maxLength - 1) + '…' : text
}

export async function openIssue(octokit: Octokit, repoWithOwner: string, finding: Finding, screenshotRepo?: string) {
export async function openIssue(octokit: Octokit, repoWithOwner: string, findings: Finding[], screenshotRepo?: string) {
const owner = repoWithOwner.split('/')[0]
const repo = repoWithOwner.split('/')[1]
const primary = findings[0]

const labels = [`${finding.scannerType}-scanning-issue`]
const labels = [`${primary.scannerType}-scanning-issue`]
// Only include a ruleId label when it's defined
if (finding.ruleId) {
labels.push(`${finding.scannerType} rule: ${finding.ruleId}`)
if (primary.ruleId) {
labels.push(`${primary.scannerType} rule: ${primary.ruleId}`)
}
// Flag non-WCAG findings so they can be filtered or triaged separately
if (finding.category && finding.category !== 'wcag') {
labels.push(finding.category)
if (primary.category && primary.category !== 'wcag') {
labels.push(primary.category)
}

const count = findings.length
const titleSuffix = count > 1 ? ` (${count} occurrences)` : ` on ${new URL(primary.url).pathname}`
const title = truncateWithEllipsis(
`Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`,
`Accessibility issue: ${primary.problemShort[0].toUpperCase() + primary.problemShort.slice(1)}${titleSuffix}`,
GITHUB_ISSUE_TITLE_MAX_LENGTH,
)

const body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner)
const body = generateIssueBody(findings, screenshotRepo ?? repoWithOwner)

return octokit.request(`POST /repos/${owner}/${repo}/issues`, {
owner,
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/file/src/reopenIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {generateIssueBody} from './generateIssueBody.js'
export async function reopenIssue(
octokit: Octokit,
{owner, repository, issueNumber}: Issue,
finding?: Finding,
findings?: Finding[],
repoWithOwner?: string,
screenshotRepo?: string,
) {
let body: string | undefined
if (finding && repoWithOwner) {
body = generateIssueBody(finding, screenshotRepo ?? repoWithOwner)
if (findings?.length && repoWithOwner) {
body = generateIssueBody(findings, screenshotRepo ?? repoWithOwner)
}

return octokit.request(`PATCH /repos/${owner}/${repository}/issues/${issueNumber}`, {
Expand Down
36 changes: 26 additions & 10 deletions .github/actions/file/src/updateFilingsWithNewFindings.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
import type {Finding, ResolvedFiling, NewFiling, RepeatedFiling, Filing} from './types.d.js'
import type {GroupBy} from './groupBy.js'

function getFilingKey(filing: ResolvedFiling | RepeatedFiling): string {
return filing.issue.url
}

function getFindingKey(finding: Finding): string {
if (finding.ruleId && finding.html) {
return `${finding.url};${finding.ruleId};${finding.html}`
function getFindingKey(finding: Finding, groupBy: GroupBy): string {
const rule = finding.ruleId
? `${finding.scannerType};${finding.ruleId}`
: `${finding.scannerType};${finding.problemUrl}`

switch (groupBy) {
case 'rule':
return rule
case 'rule+url':
return `${finding.url};${rule}`
case 'finding':
default:
if (finding.ruleId && finding.html) {
return `${finding.url};${finding.ruleId};${finding.html}`
}
return `${finding.url};${finding.scannerType};${finding.problemUrl}`
}
return `${finding.url};${finding.scannerType};${finding.problemUrl}`
}

export function updateFilingsWithNewFindings(
filings: (ResolvedFiling | RepeatedFiling)[],
findings: Finding[],
groupBy: GroupBy = 'finding',
): Filing[] {
const filingKeys: {
[key: string]: ResolvedFiling | RepeatedFiling
} = {}
const findingKeys: {[key: string]: string} = {}
const newFilings: NewFiling[] = []
const newFilingKeys: {[key: string]: NewFiling} = {}

// Create maps for filing and finding data from previous runs, for quick lookups
for (const filing of filings) {
Expand All @@ -29,21 +43,23 @@ export function updateFilingsWithNewFindings(
findings: [],
}
for (const finding of filing.findings) {
findingKeys[getFindingKey(finding)] = getFilingKey(filing)
findingKeys[getFindingKey(finding, groupBy)] = getFilingKey(filing)
}
Comment on lines 45 to 47
}

for (const finding of findings) {
const filingKey = findingKeys[getFindingKey(finding)]
const key = getFindingKey(finding, groupBy)
const filingKey = findingKeys[key]
if (filingKey) {
// This finding already has an associated filing; add it to that filing's findings
;(filingKeys[filingKey] as RepeatedFiling).findings.push(finding)
} else if (newFilingKeys[key]) {
newFilingKeys[key].findings.push(finding)
} else {
// This finding is new; create a new entry with no associated issue yet
newFilings.push({findings: [finding]})
newFilingKeys[key] = {findings: [finding]}
}
}

const updatedFilings = Object.values(filingKeys)
return [...updatedFilings, ...newFilings]
return [...updatedFilings, ...Object.values(newFilingKeys)]
}
Loading