Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions guides/atl-triage.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
93 changes: 84 additions & 9 deletions guides/atl-triage.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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(`(?<![a-zA-Z0-9_-])(${escapedCandidates.join('|')})(?![a-zA-Z0-9_-])`, 'g');

let match;
while ((match = regex.exec(description)) !== null) {
featureIds.add(match[1]);
}
}

if (featureIds.size === 0) return [];

if (featureIds.size > 0) {
console.log(`Matched/extracted feature IDs in description: ${Array.from(featureIds).join(', ')}`);
}

const resolvedAtls = new Set<string>();
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<string>();

// 1. Check labels
for (const label of labels) {
const normalized = normalizeLabel(label);

Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions guides/sync-use-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -165,13 +166,13 @@ export function buildIssueContent(
export function buildFeatureToIssueMap(issues: any[]): Map<string, FeatureIssueData> {
const map = new Map<string, FeatureIssueData>();
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;
Expand Down
55 changes: 55 additions & 0 deletions lib/feature-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export function extractFeatureIds(description: string): string[] {
const featureIds = new Set<string>();

// 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<string>();
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);
}
Loading