diff --git a/.github/actions/file/src/generateIssueBody.ts b/.github/actions/file/src/generateIssueBody.ts index ad703d51..b9f234af 100644 --- a/.github/actions/file/src/generateIssueBody.ts +++ b/.github/actions/file/src/generateIssueBody.ts @@ -52,6 +52,7 @@ ${standardsLine} const body = `${categoryNotice}## What ${describeFinding(finding)} +${finding.ruleId ? `**Rule:** \`${finding.scannerType} rule: ${finding.ruleId}\`\n\n` : ''} ${screenshotSection ?? ''} To fix this, ${finding.solutionShort}. ${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''} diff --git a/.github/actions/file/src/openIssue.ts b/.github/actions/file/src/openIssue.ts index 67128d08..f22a4a35 100644 --- a/.github/actions/file/src/openIssue.ts +++ b/.github/actions/file/src/openIssue.ts @@ -1,11 +1,28 @@ import type {Octokit} from '@octokit/core' import type {Finding} from './types.d.js' import {generateIssueBody} from './generateIssueBody.js' +import {createHash} from 'node:crypto' import * as url from 'node:url' const URL = url.URL /** Max length for GitHub issue titles */ const GITHUB_ISSUE_TITLE_MAX_LENGTH = 256 +/** Max length for GitHub label names */ +const GITHUB_LABEL_NAME_MAX_LENGTH = 50 +const RULE_LABEL_HASH_LENGTH = 4 + +function createRuleLabel(scannerType: string, ruleId: string): string { + const label = `${scannerType} rule: ${ruleId}` + const characters = Array.from(label) + + if (characters.length <= GITHUB_LABEL_NAME_MAX_LENGTH) { + return label + } + + const hash = createHash('sha256').update(label).digest('hex').slice(0, RULE_LABEL_HASH_LENGTH) + const prefixLength = GITHUB_LABEL_NAME_MAX_LENGTH - hash.length - 1 + return `${characters.slice(0, prefixLength).join('')}-${hash}` +} /** * Truncates text to a maximum length, adding an ellipsis if truncated. @@ -25,7 +42,7 @@ export async function openIssue(octokit: Octokit, repoWithOwner: string, finding const labels = [`${primary.scannerType}-scanning-issue`] // Only include a ruleId label when it's defined if (primary.ruleId) { - labels.push(`${primary.scannerType} rule: ${primary.ruleId}`) + labels.push(createRuleLabel(primary.scannerType, primary.ruleId)) } // Flag non-WCAG findings so they can be filtered or triaged separately if (primary.category && primary.category !== 'wcag') { diff --git a/.github/actions/file/tests/generateIssueBody.test.ts b/.github/actions/file/tests/generateIssueBody.test.ts index 60a06db7..dfa78e64 100644 --- a/.github/actions/file/tests/generateIssueBody.test.ts +++ b/.github/actions/file/tests/generateIssueBody.test.ts @@ -24,12 +24,26 @@ describe('generateIssueBody', () => { const body = generateIssueBody(baseFinding, 'github/accessibility-scanner') expect(body).toContain('## What') + expect(body).toContain('**Rule:** `axe rule: color-contrast`') expect(body).toContain('## Acceptance Criteria') expect(body).toContain('The specific violation reported in this issue is no longer reproducible.') expect(body).toContain('The fix MUST meet WCAG 2.2 guidelines OR') expect(body).not.toContain('Specifically:') }) + it('omits the rule line when the finding has no ruleId', () => { + const body = generateIssueBody(findingWithEmptyOptionalFields, 'github/accessibility-scanner') + + expect(body).not.toContain('**Rule:**') + }) + + it('includes the full rule ID when its label would be shortened', () => { + const ruleId = 'keyboard-accessible/scrollable-region-with-an-extra-long-description' + const body = generateIssueBody({...baseFinding, ruleId}, 'github/accessibility-scanner') + + expect(body).toContain(`**Rule:** \`axe rule: ${ruleId}\``) + }) + it('formats solutionLong lines into bullets while preserving Fix any/Fix all lines', () => { const body = generateIssueBody( { diff --git a/.github/actions/file/tests/openIssue.test.ts b/.github/actions/file/tests/openIssue.test.ts index 890f8438..af4b54b4 100644 --- a/.github/actions/file/tests/openIssue.test.ts +++ b/.github/actions/file/tests/openIssue.test.ts @@ -1,4 +1,5 @@ import {describe, it, expect, vi} from 'vitest' +import {createHash} from 'node:crypto' // Mock generateIssueBody so we can inspect what screenshotRepo is passed vi.mock('../src/generateIssueBody.js', () => ({ @@ -65,6 +66,33 @@ describe('openIssue', () => { ) }) + it('shortens long rule labels with a deterministic hash suffix', async () => { + const octokit = mockOctokit() + const ruleId = 'keyboard-accessible/scrollable-region-with-an-extra-long-description' + const fullLabel = `axe rule: ${ruleId}` + await openIssue(octokit, 'org/repo', [{...baseFinding, ruleId}]) + + const labels = octokit.request.mock.calls[0][1].labels + const ruleLabel = labels[1] + const hash = createHash('sha256').update(fullLabel).digest('hex').slice(0, 4) + + expect(ruleLabel).toBe(`${fullLabel.slice(0, 45)}-${hash}`) + expect(Array.from(ruleLabel)).toHaveLength(50) + }) + + it('produces distinct labels for different long rule IDs', async () => { + const firstOctokit = mockOctokit() + const secondOctokit = mockOctokit() + const commonPrefix = 'keyboard-accessible/scrollable-region-with-a-long-description-' + await openIssue(firstOctokit, 'org/repo', [{...baseFinding, ruleId: `${commonPrefix}first`}]) + await openIssue(secondOctokit, 'org/repo', [{...baseFinding, ruleId: `${commonPrefix}second`}]) + + const firstLabel = firstOctokit.request.mock.calls[0][1].labels[1] + const secondLabel = secondOctokit.request.mock.calls[0][1].labels[1] + expect(firstLabel.slice(0, 45)).toBe(secondLabel.slice(0, 45)) + expect(firstLabel).not.toBe(secondLabel) + }) + it('adds a category label for non-WCAG findings', async () => { const octokit = mockOctokit() await openIssue(octokit, 'org/repo', [{...baseFinding, category: 'best-practice'}])