From a16bf045540828e9154dfbb89baaf03f7f0746d4 Mon Sep 17 00:00:00 2001 From: Rick Viscomi Date: Wed, 5 Aug 2026 12:48:51 -0400 Subject: [PATCH] feat: extract web-feature IDs from issue descriptions to automate ATL assignments --- guides/atl-triage.test.ts | 101 +++++++++++++++++++++++++++++++++++--- guides/atl-triage.ts | 93 +++++++++++++++++++++++++++++++---- guides/sync-use-cases.ts | 7 +-- lib/feature-parser.ts | 55 +++++++++++++++++++++ 4 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 lib/feature-parser.ts diff --git a/guides/atl-triage.test.ts b/guides/atl-triage.test.ts index de013eecf..4e6889266 100644 --- a/guides/atl-triage.test.ts +++ b/guides/atl-triage.test.ts @@ -1,4 +1,4 @@ -import { describe, it, mock } from 'node:test'; +import { describe, it, mock, before, after } from 'node:test'; import assert from 'node:assert'; import child_process from 'node:child_process'; import { normalizeLabel, handleIssue, handlePR } from './atl-triage.ts'; @@ -29,32 +29,121 @@ describe('handleIssue', () => { motion: 'philipwalton' }, web_features: { - 'canvas-html': 'override-issue-reviewer' + 'canvas-html': 'override-issue-reviewer', + 'user-action-pseudos': 'user-action-reviewer' }, web_features_groups: { 'scrolling': 'group-issue-reviewer' } }; + let execMock: any; + before(() => { + execMock = mock.method(child_process, 'execSync', () => ''); + }); + + after(() => { + execMock.mock.restore(); + }); + it('returns matched ATLs for matching labels', () => { - const result = handleIssue(123, ['category:performance', 'category:motion', 'other-label'], mockConfig); + const result = handleIssue(123, ['category:performance', 'category:motion', 'other-label'], '', mockConfig); assert.deepStrictEqual(result.sort(), ['philipwalton', 'rviscomi', 'paulirish'].sort()); }); it('returns overridden ATL for feature labels', () => { - const result = handleIssue(123, ['canvas-html'], mockConfig); + const result = handleIssue(123, ['canvas-html'], '', mockConfig); assert.deepStrictEqual(result, ['override-issue-reviewer']); }); it('returns overridden ATL for group labels', () => { - const result = handleIssue(123, ['scrolling'], mockConfig); + const result = handleIssue(123, ['scrolling'], '', mockConfig); assert.deepStrictEqual(result, ['group-issue-reviewer']); }); it('returns empty array when no labels match', () => { - const result = handleIssue(123, ['other-label'], mockConfig); + const result = handleIssue(123, ['other-label'], '', mockConfig); assert.deepStrictEqual(result, []); }); + + it('returns ATLs matched from web-feature ID in the description directly', () => { + const description = `This is an issue about canvas-html feature implementation.`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['override-issue-reviewer']); + }); + + it('returns ATLs matched from web-feature ID in the description as part of a group', () => { + // scroll-driven-animations belongs to 'scrolling' (which resolves to group-issue-reviewer) + const description = `Let's add support for scroll-driven-animations!`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['group-issue-reviewer']); + }); + + it('combines ATLs from both labels and description features without duplicates', () => { + // 'category:performance' label maps to ['rviscomi', 'paulirish'] + // 'canvas-html' inside description maps to 'override-issue-reviewer' + const description = `Please look at canvas-html behavior under load.`; + const result = handleIssue(123, ['category:performance'], description, mockConfig); + assert.deepStrictEqual( + result.sort(), + ['rviscomi', 'paulirish', 'override-issue-reviewer'].sort() + ); + }); + + it('supports extracting Web Feature ID from new-feature issue template format', () => { + const description = ` +### web-feature-id + +canvas-html + +### Feature description +Some description. +`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['override-issue-reviewer']); + }); + + it('supports extracting Web Feature ID from webstatus.dev URLs in the issue template', () => { + const description = ` +### web-feature-id + +https://webstatus.dev/features/canvas-html + +### Feature description +Some description. +`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['override-issue-reviewer']); + }); + + it('supports extracting Web Feature ID from bold label format (issue 1174 style)', () => { + const description = ` +This feature represents the behavior described in this section of the CSS spec: +https://www.w3.org/TR/selectors-4/#useraction-pseudos + +--- +**Web Feature ID**: user-action-pseudos +**Chrome Releases**: Chrome 148, Chrome 149 +`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['user-action-reviewer']); + }); + + it('supports extracting Web Feature ID from plain text label format', () => { + const description = ` +Web Feature ID: user-action-pseudos +`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['user-action-reviewer']); + }); + + it('supports extracting Web Feature ID wrapped in backticks or markdown tags', () => { + const description = ` +**Web Feature ID**: \`user-action-pseudos\` +`; + const result = handleIssue(123, [], description, mockConfig); + assert.deepStrictEqual(result, ['user-action-reviewer']); + }); }); describe('handlePR', () => { diff --git a/guides/atl-triage.ts b/guides/atl-triage.ts index c422dd05b..690a25a7d 100644 --- a/guides/atl-triage.ts +++ b/guides/atl-triage.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; -import { execSync } from 'node:child_process'; +import child_process from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { extractFeatureIds } from '../lib/feature-parser.ts'; // Define content file name constants inline to avoid importing from 'lib/guide-validation.ts' // which would transitively require external packages (like 'gray-matter' and 'marked') @@ -121,10 +122,70 @@ export function resolveAtl(category: string, featureIds: string[], atlConfig: At return resolvedValue; } -export function handleIssue(issueNumber: number, labels: string[], atlConfig: AtlConfig) { +export function getAtlsFromDescription(description: string, atlConfig: AtlConfig): string[] { + if (!description) return []; + + // 1. Extract feature IDs from known patterns (fields, URLs, etc.) + const featureIds = new Set(extractFeatureIds(description)); + + // 2. Scan the description for any known feature IDs (keyword match) as a fallback + const candidates = Array.from( + new Set([ + ...Object.keys(atlConfig.web_features), + ...Object.keys(featureGroups) + ]) + ).sort((a, b) => b.length - a.length); + + if (candidates.length > 0) { + const escapedCandidates = candidates.map(fid => fid.replace(new RegExp('[-/\\\\^$*+?.()|[\\]{}]', 'g'), '\\$&')); + const regex = new RegExp(`(? 0) { + console.log(`Matched/extracted feature IDs in description: ${Array.from(featureIds).join(', ')}`); + } + + const resolvedAtls = new Set(); + for (const fid of featureIds) { + // 1. Direct match + if (atlConfig.web_features[fid]) { + const val = atlConfig.web_features[fid]; + if (Array.isArray(val)) { + val.forEach(a => resolvedAtls.add(a)); + } else { + resolvedAtls.add(val); + } + } + // 2. Group match + const groups = featureGroups[fid] || []; + for (const group of groups) { + if (atlConfig.web_features_groups[group]) { + const val = atlConfig.web_features_groups[group]; + if (Array.isArray(val)) { + val.forEach(a => resolvedAtls.add(a)); + } else { + resolvedAtls.add(val); + } + } + } + } + + return Array.from(resolvedAtls); +} + +export function handleIssue(issueNumber: number, labels: string[], issueDescription: string, atlConfig: AtlConfig) { console.log(`Triaging issue #${issueNumber} with labels: ${labels.join(', ')}`); const assignedAtls = new Set(); + + // 1. Check labels for (const label of labels) { const normalized = normalizeLabel(label); @@ -142,15 +203,22 @@ export function handleIssue(issueNumber: number, labels: string[], atlConfig: At } } + // 2. Check issue description for web-feature IDs + const descriptionAtls = getAtlsFromDescription(issueDescription, atlConfig); + if (descriptionAtls.length > 0) { + console.log(`Found ATLs from description: ${descriptionAtls.join(', ')}`); + descriptionAtls.forEach(a => assignedAtls.add(a)); + } + if (assignedAtls.size === 0) { - console.log('No matching ATL labels found for this issue.'); + console.log('No matching ATL signals found for this issue.'); return []; } const assignees = Array.from(assignedAtls).join(','); console.log(`Assigning issue #${issueNumber} to: ${assignees}`); try { - execSync(`gh issue edit ${issueNumber} --add-assignee "${assignees}"`, { stdio: 'inherit' }); + child_process.execSync(`gh issue edit ${issueNumber} --add-assignee "${assignees}"`, { stdio: 'inherit' }); console.log('Successfully assigned issue.'); } catch (err) { console.error(`Failed to assign issue #${issueNumber}:`, err); @@ -166,7 +234,7 @@ export function handlePR(prNumber: number, prAuthor: string, atlConfig: AtlConfi files = mockFiles; } else { try { - const output = execSync(`gh pr view ${prNumber} --json files --jq ".files[].path"`, { encoding: 'utf8' }); + const output = child_process.execSync(`gh pr view ${prNumber} --json files --jq ".files[].path"`, { encoding: 'utf8' }); files = output.trim().split('\n').map(f => f.trim()).filter(Boolean); } catch (err) { console.error(`Failed to fetch files for PR #${prNumber}:`, err); @@ -211,7 +279,7 @@ export function handlePR(prNumber: number, prAuthor: string, atlConfig: AtlConfi if (!mockFiles) { try { - const output = execSync(`gh pr view ${prNumber} --json reviews,reviewRequests`, { encoding: 'utf8' }); + const output = child_process.execSync(`gh pr view ${prNumber} --json reviews,reviewRequests`, { encoding: 'utf8' }); const prData = JSON.parse(output); const existingRequested = (prData.reviewRequests || []).map((r: any) => r.login).filter(Boolean); @@ -241,7 +309,7 @@ export function handlePR(prNumber: number, prAuthor: string, atlConfig: AtlConfi console.log(`Requesting review on PR #${prNumber} from: ${reviewers}`); if (!mockFiles) { try { - execSync(`gh pr edit ${prNumber} --add-reviewer "${reviewers}"`, { stdio: 'inherit' }); + child_process.execSync(`gh pr edit ${prNumber} --add-reviewer "${reviewers}"`, { stdio: 'inherit' }); console.log('Successfully requested reviews.'); } catch (err) { console.error(`Failed to request reviews for PR #${prNumber}:`, err); @@ -262,7 +330,8 @@ export function main() { if (event.issue) { const issueNumber = event.issue.number; const labels = (event.issue.labels || []).map((l: any) => l.name); - handleIssue(issueNumber, labels, atlConfig); + const issueDescription = event.issue.body || ''; + handleIssue(issueNumber, labels, issueDescription, atlConfig); } // Check if it's a pull request event else if (event.pull_request) { @@ -293,7 +362,13 @@ export function main() { if (type === 'issue') { const labels = args.slice(2); - handleIssue(number, labels, atlConfig); + let issueDescription = ''; + try { + issueDescription = child_process.execSync(`gh issue view ${number} --json body --jq ".body"`, { encoding: 'utf8' }).trim(); + } catch (err) { + console.warn(`Failed to fetch issue body for issue #${number}:`, err); + } + handleIssue(number, labels, issueDescription, atlConfig); } else if (type === 'pr') { const author = args[2] || ''; handlePR(number, author, atlConfig); diff --git a/guides/sync-use-cases.ts b/guides/sync-use-cases.ts index 4ecea41ef..369ec7fcd 100644 --- a/guides/sync-use-cases.ts +++ b/guides/sync-use-cases.ts @@ -3,6 +3,7 @@ import path from 'path'; import { Octokit } from '@octokit/rest'; import { fileURLToPath } from 'url'; import { ProjectStatus, processGuideInventory, scanAllGuides, type GuideInventory } from '../lib/guide-validation.ts'; +import { extractFeatureIds } from '../lib/feature-parser.ts'; // --- Types --- @@ -165,13 +166,13 @@ export function buildIssueContent( export function buildFeatureToIssueMap(issues: any[]): Map { const map = new Map(); for (const issue of issues) { - const match = issue.body?.match(/(?:Feature ID:|### web-feature-id)[\s\r\n]+([a-z0-9-]+)/i); - if (match) { + const fids = extractFeatureIds(issue.body ?? ''); + for (const fid of fids) { const priorityLabel = issue.labels .map((l: any) => (typeof l === 'string' ? l : l.name)) .find((l: string) => PRIORITY_LABEL_REGEX.test(l)) || null; const milestoneNumber = issue.milestone ? issue.milestone.number : null; - map.set(match[1], { number: issue.number, priorityLabel, milestoneNumber, state: issue.state, body: issue.body ?? '' }); + map.set(fid, { number: issue.number, priorityLabel, milestoneNumber, state: issue.state, body: issue.body ?? '' }); } } return map; diff --git a/lib/feature-parser.ts b/lib/feature-parser.ts new file mode 100644 index 000000000..4bbeffdb5 --- /dev/null +++ b/lib/feature-parser.ts @@ -0,0 +1,55 @@ +export function extractFeatureIds(description: string): string[] { + const featureIds = new Set(); + + // Pattern 1: GitHub form template: + // ### web-feature-id + // + // value + const formRegex = /### web-feature-id\s*\r?\n\s*([^\r\n#]+)/gi; + let match; + while ((match = formRegex.exec(description)) !== null) { + const val = match[1].trim(); + if (val) featureIds.add(val); + } + + // Pattern 2: Bold or plain label: + // **Web Feature ID**: value + // Web Feature ID: value + // Feature ID: value + const labelRegex = /(?:\*\*|)?(?:Web\s+)?Feature ID(?:\*\*|)?:\s*([^\r\n]+)/gi; + while ((match = labelRegex.exec(description)) !== null) { + const val = match[1].trim(); + if (val) featureIds.add(val); + } + + // Pattern 3: webstatus.dev URLs: + // https://webstatus.dev/features/value + const urlRegex = /https:\/\/webstatus\.dev\/features\/([a-zA-Z0-9_-]+)/gi; + while ((match = urlRegex.exec(description)) !== null) { + const val = match[1].trim(); + if (val) featureIds.add(val); + } + + const cleanedFeatures = new Set(); + for (const id of featureIds) { + if (id.startsWith('http://') || id.startsWith('https://')) { + try { + const url = new URL(id); + const parts = url.pathname.split('/').filter(Boolean); + const lastPart = parts[parts.length - 1]; + if (lastPart) { + cleanedFeatures.add(lastPart); + } + } catch { + cleanedFeatures.add(id); + } + } else { + const clean = id.replace(/[`*_\u00a0]/g, '').trim(); + if (clean) { + cleanedFeatures.add(clean); + } + } + } + + return Array.from(cleanedFeatures); +}