diff --git a/.github/workflows/identify-web-features.yml b/.github/workflows/identify-web-features.yml index 0c692aa..35d44e5 100644 --- a/.github/workflows/identify-web-features.yml +++ b/.github/workflows/identify-web-features.yml @@ -1,25 +1,86 @@ -# This GitHub Actions workflow identifies web features in new and edited issues. -# Identified features are posted as a comment on the issue. -name: Identify Web Features in Issues +# This GitHub Actions workflow identifies web-features in new and edited focus-area-proposal issues. +# Identified web-features are listed as a comment on the issue. +name: Identify web-features in focus area proposals on: + # Trigger the workflow either when a single issue is opened, edited, reopened, or labeled. + # The labeled trigger only applies when the label is 'focus-area-proposal'. issues: - types: [opened, edited] + types: [opened, edited, labeled, reopened] + # Trigger the workflow when we update the package.json file (web-features dependency). + push: + branches: [main] + paths: + - scripts/package.json + - scripts/package-lock.json + # Lets us trigger the job manually. + workflow_dispatch: + +permissions: + contents: read + issues: write jobs: - run-script: + + # The process-issue job runs when a single issue is opened, edited, reopened or labeled. + process-issue: + if: >- + github.event_name == 'issues' && + ( + github.event.action == 'opened' || + github.event.action == 'edited' || + github.event.action == 'reopened' || + (github.event.action == 'labeled' && github.event.label.name == 'focus-area-proposal') + ) + runs-on: ubuntu-latest + # Concurrency is used to ensure that only one job per issue runs at a time, with newer + # jobs canceling older ones. + concurrency: + group: identify-web-features-issue-${{ github.event.issue.number }} + cancel-in-progress: true + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: scripts/package-lock.json + - name: Install dependencies + run: npm ci + working-directory: scripts + - name: Refresh issue + run: node identify-web-features.js --number ${{ github.event.issue.number }} --repo ${{ github.repository }} + working-directory: scripts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The process-all-open-proposals job runs when the package.json file is changed, and updates all issues. + # This is used to refresh all open proposals when the web-features dependency is updated. + # The job can also be triggered manually. + process-all-open-proposals: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + # Concurrency is used to ensure that only one job per repository runs at a time, with newer + # jobs canceling older ones. + concurrency: + group: identify-web-features-all-open-proposals + cancel-in-progress: true steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: '22' - - name: Run identification script - run: | - cd scripts - npm install - node identify-web-features.js -n ${{ github.event.issue.number }} -r ${{ github.repository }} + cache: npm + cache-dependency-path: scripts/package-lock.json + - name: Install dependencies + run: npm ci + working-directory: scripts + - name: Refresh all open proposals + run: node identify-web-features.js --all-open-proposals --repo ${{ github.repository }} + working-directory: scripts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/identify-web-features.js b/scripts/identify-web-features.js index bab0f55..fc1ebab 100644 --- a/scripts/identify-web-features.js +++ b/scripts/identify-web-features.js @@ -1,59 +1,66 @@ +import { pathToFileURL } from "node:url"; import { Octokit } from "octokit"; -import { features } from "web-features"; +import { features as webFeatures } from "web-features"; import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; const GITHUB_API_VERSION = "2022-11-28"; -// This is used as a hidden HTML comment when posting comments to GitHub issues. -// This way, we can retrieve the comment later, and update it if needed. -const HIDDEN_COMMENT_IN_ISSUE = ""; -// The label which the issue must have for the bot to process it. -const REQUIRED_LABEL = "focus-area-proposal"; - -const argv = yargs(process.argv) - .option("number", { - alias: "n", - type: "number", - default: false, - describe: "The issue number to process", - }) - .option("repo", { - alias: "r", - type: "string", - describe: "The owner and repository name. For example: web-platform-tests/interop", - }).argv; - -const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); +export const HIDDEN_COMMENT_IN_ISSUE = ""; +export const REQUIRED_LABEL = "focus-area-proposal"; -function escapeFeatureName(feature) { - // Escape the feature name for use in HTML. - return feature.name.replace(//g, ">"); +function getGitHubHeaders() { + return { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }; } -async function getReferencedIssue() { - const response = await octokit.request(`GET /repos/${argv.repo}/issues/${argv.number}`,); - return response.data; +function parseRepository(repository) { + const [owner, repo, ...extra] = repository.split("/"); + if (!owner || !repo || extra.length > 0) { + throw new Error(`Invalid repository "${repository}". Expected "owner/repository".`); + } + return { owner, repo }; +} + +function issueHasLabel(issue, labelName) { + return issue.labels.some(label => (typeof label === "string" ? label : label.name) === labelName); +} + +export function shouldProcessIssue(issue) { + return issue.state === "open" && issueHasLabel(issue, REQUIRED_LABEL); } -function gatherUrlsFromIssue(issueBody) { +function escapeFeatureName(feature) { + return feature.name.replace(//g, ">"); +} + +export function gatherUrlsFromIssue(issueBody) { const urls = issueBody.match(/https?:\/\/[^)\s]+/g) || []; - return urls.map(url => new URL(url)); + return urls.flatMap(url => { + try { + return [new URL(url)]; + } catch { + return []; + } + }); } -// Identify web-features based on spec URLs in the issue body. -function gatherFeaturesFromSpecUrls(urls) { +function gatherFeaturesFromSpecUrls(urls, featureCatalog) { const gatheredFeatures = new Set(); for (const url of urls) { - for (const id in features) { - const feature = features[id]; - const specUrls = (Array.isArray(feature.spec) ? feature.spec : [feature.spec]).map(url => new URL(url)); + for (const id in featureCatalog) { + const feature = featureCatalog[id]; + const specUrls = (Array.isArray(feature.spec) ? feature.spec : [feature.spec]) + .filter(Boolean) + .map(specUrl => new URL(specUrl)); if (specUrls.some(specUrl => { return specUrl.hostname === url.hostname && specUrl.pathname === url.pathname && (specUrl.hash ? specUrl.hash === url.hash : true); })) { - gatheredFeatures.add(id) + gatheredFeatures.add(id); } } } @@ -61,8 +68,7 @@ function gatherFeaturesFromSpecUrls(urls) { return gatheredFeatures; } -// Identify web-features based on explorer URLs in the issue body. -function gatherFeaturesFromExplorerUrls(urls) { +function gatherFeaturesFromExplorerUrls(urls, featureCatalog) { const gatheredFeatures = new Set(); for (const url of urls) { @@ -70,8 +76,10 @@ function gatherFeaturesFromExplorerUrls(urls) { continue; } - const candidateId = url.pathname.substring(url.pathname.indexOf("features/") + 9).replace("/", "").replace(".json", ""); - if (features[candidateId]) { + const candidateId = url.pathname.substring(url.pathname.indexOf("features/") + 9) + .replace("/", "") + .replace(".json", ""); + if (featureCatalog[candidateId]) { gatheredFeatures.add(candidateId); } } @@ -79,8 +87,7 @@ function gatherFeaturesFromExplorerUrls(urls) { return gatheredFeatures; } -// Identify web-features based on WPT URLs in the issue body. -function gatherFeaturesFromWPTUrls(urls) { +function gatherFeaturesFromWPTUrls(urls, featureCatalog) { const gatheredFeatures = new Set(); for (const url of urls) { @@ -90,7 +97,7 @@ function gatherFeaturesFromWPTUrls(urls) { const query = url.searchParams.get("q"); const match = query.match(/feature:([a-z0-9-]+)/); - if (match && match[1] && features[match[1]]) { + if (match?.[1] && featureCatalog[match[1]]) { gatheredFeatures.add(match[1]); } } @@ -98,29 +105,24 @@ function gatherFeaturesFromWPTUrls(urls) { return gatheredFeatures; } -// Identify web-features by checking for explicit mentions in the issue body. -function gatherFeaturesFromExplicitMentions(issueBody) { +function gatherFeaturesFromExplicitMentions(issueBody, featureCatalog) { const gatheredFeatures = new Set(); - // Look for `web-features: ` or `web-feature: ` in the issue body. - // There might be spaces between the colon and the feature ID. And there might be spaces after the ID, or a period, or end of line. const explicitMentions = issueBody.match(/web-features?:\s*([a-z0-9-]+)/gi) || []; for (const mention of explicitMentions) { const match = mention.match(/web-features?:\s*([a-z0-9-]+)/i); - if (match && match[1] && features[match[1]]) { + if (match?.[1] && featureCatalog[match[1]]) { gatheredFeatures.add(match[1]); } } - // Also look for an h3 markdown section like: - // ### web-feature - // - // The bug template includes this section. Note that there may be empty lines and spaces before or after the id. const sectionMentions = issueBody.match(/###\s*web-features?\s*([\r\n]+[ \t]*[a-z0-9-]+)+/gi) || []; for (const section of sectionMentions) { - const lines = section.split(/[\r\n]+/).map(line => line.trim()).filter(line => line && !line.startsWith("###")); + const lines = section.split(/[\r\n]+/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith("###")); for (const line of lines) { - if (features[line]) { + if (featureCatalog[line]) { gatheredFeatures.add(line); } } @@ -129,54 +131,51 @@ function gatherFeaturesFromExplicitMentions(issueBody) { return gatheredFeatures; } -// Given a GitHub issue, find the web-features that are referenced in the issue body. -function findFeaturesInIssue(issue) { - const urls = gatherUrlsFromIssue(issue.body); +export function findFeaturesInIssue(issue, featureCatalog) { + const issueBody = issue.body || ""; + const urls = gatherUrlsFromIssue(issueBody); + const specFeatures = gatherFeaturesFromSpecUrls(urls, featureCatalog); + const wptFeatures = gatherFeaturesFromWPTUrls(urls, featureCatalog); + const explorerFeatures = gatherFeaturesFromExplorerUrls(urls, featureCatalog); + const explicitWebFeatureMentions = gatherFeaturesFromExplicitMentions(issueBody, featureCatalog); - const specFeatures = gatherFeaturesFromSpecUrls(urls); - const wptFeatures = gatherFeaturesFromWPTUrls(urls); - const explorerFeatures = gatherFeaturesFromExplorerUrls(urls); - const explicitWebFeatureMentions = gatherFeaturesFromExplicitMentions(issue.body); - - // Explorer URLs take precedence over spec and WPT URLs. - // And explicit mentions take precedence over everything else. if (explicitWebFeatureMentions.size > 0) { return [...explicitWebFeatureMentions]; } if (explorerFeatures.size > 0) { - // If we have explorer features, we don't need to combine them with spec and WPT features. return [...explorerFeatures]; } return [...new Set([...specFeatures, ...wptFeatures])]; } -// Given a feature id, retrieve the feature's data. -// We use the web-features-explorer's JSON files to get the full data, which includes both -// the data that comes from the web-features project and the additional data that the explorer augments it with. -async function getFeatureData(id) { +export async function getFeatureData(id, fetchImpl) { console.log(`Getting data for feature ${id}`); - try { - const response = await fetch(`https://web-platform-dx.github.io/web-features-explorer/features/${id}.json`); - return await response.json(); - } catch (error) { - console.error(`Error fetching the feature data for ${id}:`, error); - return null; + const response = await fetchImpl(`https://web-platform-dx.github.io/web-features-explorer/features/${id}.json`); + if (!response.ok) { + throw new Error(`Could not fetch feature "${id}": HTTP ${response.status}`); + } + + const feature = await response.json(); + if (!feature || feature.id !== id || !feature.name) { + throw new Error(`Feature "${id}" returned malformed explorer data.`); } + return feature; } function getBaselineStatusAsMarkdown(feature) { - if (feature.status && feature.status.baseline === "high") { + if (feature.status?.baseline === "high") { return "Widely Available"; - } else if (feature.status && feature.status.baseline === "low") { + } + if (feature.status?.baseline === "low") { return "Newly Available"; } return "Limited Availability"; } function getDocsAsMarkdown(feature) { - if (!feature.mdnUrls.length) { + if (!feature.mdnUrls?.length) { return ""; } @@ -185,156 +184,415 @@ function getDocsAsMarkdown(feature) { } function getStandardPositionsAsMarkdown(feature) { - if (!feature.standardPositions.mozilla.url && !feature.standardPositions.webkit.url) { + if (!feature.standardPositions?.length) { return ""; } const positions = []; - - if (feature.standardPositions.mozilla.url) { - positions.push(`[Mozilla](${feature.standardPositions.mozilla.url})`); + const vendorNames = { + apple: "WebKit", + mozilla: "Mozilla", + }; + + for (const { vendor, url, position, concerns = [] } of feature.standardPositions) { + const vendorName = vendorNames[vendor] || vendor; + const details = [ + position, + concerns.length ? `concerns: ${concerns.join(", ")}` : "", + ].filter(Boolean).join(", "); + positions.push(`[${vendorName}](${url})${details ? ` (${details})` : ""}`); } - if (feature.standardPositions.webkit.url) { - positions.push(`[WebKit](${feature.standardPositions.webkit.url})`); + + return `* **Standard positions:** ${positions.join(", ")}\n`; +} + +function getDeveloperSignalsAsMarkdown(feature) { + if (!feature.developerSignals) { + return ""; } - return "* **Standard positions:** " + positions.join(", ") + "\n"; + const useCaseCount = feature.useCases?.length || 0; + const useCases = useCaseCount + ? ` / ${useCaseCount} use case${useCaseCount === 1 ? "" : "s"}` + : ""; + return `* **Developer signals:** ${feature.developerSignals.votes} votes${useCases} ([details](${feature.developerSignals.url}))\n`; } function getUseCounterAsMarkdown(feature) { - if (!feature.useCounters.chromeStatusUrl) { + const { percentageOfPageLoad, url } = feature.chromeUseCounters || {}; + if (!url) { return ""; } - return `* **Chrome use counter:** [chromestatus.com](${feature.useCounters.chromeStatusUrl})\n`; + + const usage = Number.isFinite(percentageOfPageLoad) + ? ` (~${(percentageOfPageLoad * 100).toFixed(3)}% of page loads)` + : ""; + return `* **Chrome use counter:** [chromestatus.com](${url})${usage}\n`; } function getSurveysAsMarkdown(feature) { - if (!feature.stateOfSurveys || !feature.stateOfSurveys.length) { + if (!feature.stateOfSurveys?.length) { return ""; } const surveys = feature.stateOfSurveys.map(survey => { - return `[${survey.name} (${survey.question} question)](${survey.link})`; + return `[${survey.name} (${survey.question} question)](${survey.url})`; }).join(", "); return `* **State of CSS/JS/HTML surveys:** ${surveys}\n`; } function getPreviousInteropsAsMarkdown(feature) { - if (!feature.interop.length) { + if (!feature.interop?.length) { return ""; } const interops = feature.interop.map(i => { - return `[${i.year}](https://wpt.fyi/interop-2024?feature=${i.label})`; + return `[${i.year}](${i.url})`; }).join(", "); - return `* **Included in previous Interop iterations:** ${interops}\n` + return `* **Included in previous Interop iterations:** ${interops}\n`; } function getWPTLinkAsMarkdown(feature) { if (!feature.wpt) { return ""; } - return `* **WPT tests:** [wpt.fyi/results/?q=feature:${feature.id}](https://wpt.fyi/results/?q=feature:${feature.id})\n`; -} - -// Generate the markdown content for the given feature. -function getMarkdownContentForFeature(feature) { - let str = `### Feature **${escapeFeatureName(feature)}**\n\n`; - str += `* **ID:** ${feature.id}\n`; - str += `* **Name:** ${escapeFeatureName(feature)}\n`; - str += `* **Description:** ${feature.description_html}\n`; - str += `* **Baseline status:** ${getBaselineStatusAsMarkdown(feature)}\n`; - str += getDocsAsMarkdown(feature); - str += getStandardPositionsAsMarkdown(feature); - str += getUseCounterAsMarkdown(feature); - str += getSurveysAsMarkdown(feature); - str += getPreviousInteropsAsMarkdown(feature); - str += getWPTLinkAsMarkdown(feature); - str += `* **More information:** See the [web-features explorer](https://web-platform-dx.github.io/web-features-explorer/features/${feature.id}/)\n\n`; - - return str; -} - -// Post a new comment with the given markdown content or update an existing comment if it already exists. -async function postOrUpdateComment(issueNumber, markdown) { - // Retrieve existing comments to check if we already posted a comment. - const commentsResponse = await octokit.request(`GET /repos/${argv.repo}/issues/${issueNumber}/comments`, { - headers: { - "X-GitHub-Api-Version": GITHUB_API_VERSION - } - }); - const existingComment = commentsResponse.data.find(comment => comment.body.includes(HIDDEN_COMMENT_IN_ISSUE)); - - if (existingComment) { - // The bot already posted a comment. Update it. - console.log(`Updating existing comment #${existingComment.id}...`); - await octokit.request(`PATCH /repos/${argv.repo}/issues/comments/${existingComment.id}`, { - body: markdown, - headers: { - "X-GitHub-Api-Version": GITHUB_API_VERSION - } - }); - } else { - // Post a new comment. - console.log(`Posting a new comment...`); - await octokit.request(`POST /repos/${argv.repo}/issues/${issueNumber}/comments`, { - body: markdown, - headers: { - "X-GitHub-Api-Version": GITHUB_API_VERSION - } - }); - } + return `* **WPT tests:** [wpt.fyi results](${feature.wpt.url})\n`; } -// The main entry point to the script. -async function main() { - const issue = await getReferencedIssue(); - - if (!issue.labels.some(label => label.name === REQUIRED_LABEL)) { - console.log(`The issue does not have the required label "${REQUIRED_LABEL}". Exiting.`); - return; - } - - console.log(`Processing issue #${issue.number}: "${issue.title}"`); - const featureIds = findFeaturesInIssue(issue); - const features = await Promise.all(featureIds.map(id => getFeatureData(id))); +export function getMarkdownContentForFeature(feature) { + let content = `### Feature **${escapeFeatureName(feature)}**\n\n`; + content += `* **ID:** ${feature.id}\n`; + content += `* **Name:** ${escapeFeatureName(feature)}\n`; + content += `* **Description:** ${feature.description_html}\n`; + content += `* **Baseline status:** ${getBaselineStatusAsMarkdown(feature)}\n`; + content += getDocsAsMarkdown(feature); + content += getStandardPositionsAsMarkdown(feature); + content += getDeveloperSignalsAsMarkdown(feature); + content += getUseCounterAsMarkdown(feature); + content += getSurveysAsMarkdown(feature); + content += getPreviousInteropsAsMarkdown(feature); + content += getWPTLinkAsMarkdown(feature); + content += `* **More information:** See the [web-features explorer](https://web-platform-dx.github.io/web-features-explorer/features/${feature.id}/)\n\n`; + + return content; +} +function buildIssueComment(featureData) { let content = "_This comment was automatically generated based on the information you provided. Please don't edit it._\n\n"; - if (features.length === 0) { - console.log("Could not find any matching features the issue body."); - + if (featureData.length === 0) { content += "No web features (from the [web-features project](https://github.com/web-platform-dx/web-features/)) were found in your proposal. If your proposal doesn't correspond to a web feature, that is fine.\\\n"; content += "Otherwise, please update your initial comment to include `web-features: `.\n"; content += "To find feature IDs, use the [web-features explorer](https://web-platform-dx.github.io/web-features-explorer/).\n\n"; } else { - console.log(`Found ${features.length} matching feature(s):`); - console.log(features.map(f => `- ${f.id}`).join("\n")); - - content += `Below is additional information about the web feature${features.length > 1 ? "s" : ""} (from the [web-features project](https://github.com/web-platform-dx/web-features/)) which ${features.length > 1 ? "are" : "is"} referenced in your proposal.\\\n`; + content += `Below is additional information about the web feature${featureData.length > 1 ? "s" : ""} (from the [web-features project](https://github.com/web-platform-dx/web-features/)) which ${featureData.length > 1 ? "are" : "is"} referenced in your proposal.\\\n`; content += "If this doesn't accurately correspond to your proposal, please update your initial comment to include `web-features: `.\n"; content += "To find feature IDs, use the [web-features explorer](https://web-platform-dx.github.io/web-features-explorer/).\n\n"; - for (const feature of features) { + for (const feature of featureData) { const featureContent = getMarkdownContentForFeature(feature); - if (features.length > 1) { - content += `
\n`; + if (featureData.length > 1) { + content += "
\n"; content += `${escapeFeatureName(feature)}\n\n`; content += featureContent; - content += `
\n\n`; + content += "
\n\n"; } else { content += featureContent; } } } - // Add the hidden comment to find this comment again later. - content += `\n${HIDDEN_COMMENT_IN_ISSUE}`; + return `${content}\n${HIDDEN_COMMENT_IN_ISSUE}`; +} + +async function getReferencedIssue(octokit, repository, issueNumber) { + const response = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}", { + ...repository, + issue_number: issueNumber, + headers: getGitHubHeaders(), + }); + return response.data; +} + +export async function listOpenProposalIssues(octokit, repository) { + const issues = await octokit.paginate("GET /repos/{owner}/{repo}/issues", { + ...repository, + state: "open", + labels: REQUIRED_LABEL, + per_page: 100, + headers: getGitHubHeaders(), + }); + + return issues.filter(issue => !issue.pull_request && shouldProcessIssue(issue)); +} + +// List all bot comments in an issue, and sort them by ID (oldest first). +// This is used to identify duplicates. +async function listBotComments(octokit, repository, issueNumber) { + const comments = await octokit.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", { + ...repository, + issue_number: issueNumber, + per_page: 100, + headers: getGitHubHeaders(), + }); + return comments + .filter(comment => comment.body?.includes(HIDDEN_COMMENT_IN_ISSUE)) + .sort((a, b) => a.id - b.id); +} + +// Keep only the first bot comment and delete any duplicates. +async function keepOnlyFirstBotComment(octokit, repository, botComments) { + const [commentToKeep, ...duplicates] = botComments; + + for (const duplicate of duplicates) { + console.log(`Deleting duplicate bot comment #${duplicate.id}...`); + try { + await octokit.request("DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}", { + ...repository, + comment_id: duplicate.id, + headers: getGitHubHeaders(), + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + return commentToKeep; +} + +async function postComment(octokit, repository, issueNumber, markdown) { + console.log(`Posting a new comment on issue #${issueNumber}...`); + const response = await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", { + ...repository, + issue_number: issueNumber, + body: markdown, + headers: getGitHubHeaders(), + }); + + return response.data; +} + +async function updateComment(octokit, repository, commentId, markdown) { + console.log(`Updating comment #${commentId}...`); + await octokit.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { + ...repository, + comment_id: commentId, + body: markdown, + headers: getGitHubHeaders(), + }); +} + +// This function is intended to maintain exactly one bot comment per issue, +// including when two scripts runs race each other. +// Races can happen in theory when the process-issue and process-all-open-proposals +// jobs in identify-web-features.yml run concurrently. +export async function postOrUpdateComment(octokit, repository, issueNumber, markdown) { + // Check if there is already an existing bot comment in the issue. + // Delete any duplicate at the same time, and keep only the first one. + const existingComment = await keepOnlyFirstBotComment( + octokit, + repository, + await listBotComments(octokit, repository, issueNumber), + ); + + if (!existingComment) { + // If there is no existing comment, post a new one. + const newCommentData = await postComment(octokit, repository, issueNumber, markdown); + + // List bot comments again, and delete duplicates in case another run created + // a comment at the same time. + const commentToKeep = await keepOnlyFirstBotComment( + octokit, + repository, + await listBotComments(octokit, repository, issueNumber), + ); + + // If another run had created a comment in the meantime, update it with the new content. + if (commentToKeep.id !== newCommentData.id) { + console.log(`Another run created comment #${commentToKeep.id}; kept that comment instead.`); + if (commentToKeep.body === markdown) { + return "unchanged"; + } + + await updateComment(octokit, repository, commentToKeep.id, markdown); + return "updated"; + } + + return "created"; + } + + if (existingComment.body === markdown) { + console.log(`Comment on issue #${issueNumber} is already up to date.`); + return "unchanged"; + } - await postOrUpdateComment(issue.number, content); + console.log(`Updating comment #${existingComment.id} on issue #${issueNumber}...`); + await updateComment(octokit, repository, existingComment.id, markdown); + return "updated"; } -main(); +export async function processIssue(issue, { + octokit, + repository, + fetchImpl = fetch, + featureCatalog, +} = {}) { + if (!shouldProcessIssue(issue)) { + console.log(`Skipping issue #${issue.number}: it is not an open ${REQUIRED_LABEL} issue.`); + return "skipped"; + } + + console.log(`Processing issue #${issue.number}: "${issue.title}"`); + const featureIds = findFeaturesInIssue(issue, featureCatalog); + + // Handle moved and split features by redirecting to the target(s) in the catalog. + const processedFeatureIds = new Set(); + for (const id of featureIds) { + if (featureCatalog[id].kind === "moved") { + processedFeatureIds.add(featureCatalog[id].redirect_target); + } else if (featureCatalog[id].kind === "split") { + for (const target of featureCatalog[id].redirect_targets) { + processedFeatureIds.add(target); + } + } else { + processedFeatureIds.add(id); + } + } + + // Fetch feature data for each identified feature, and build the comment content. + const featureData = await Promise.all([...processedFeatureIds].map(async id => { + try { + return await getFeatureData(id, fetchImpl); + } catch (error) { + throw new Error(`Issue #${issue.number}, feature "${id}": ${error.message}`, { cause: error }); + } + })); + + if (featureData.length === 0) { + console.log(`No matching features found for issue #${issue.number}.`); + } else { + console.log(`Found ${featureData.length} matching feature(s) for issue #${issue.number}:`); + console.log(featureData.map(feature => `- ${feature.id}`).join("\n")); + } + + return postOrUpdateComment( + octokit, + repository, + issue.number, + buildIssueComment(featureData), + ); +} + +function createSummary(scanned) { + return { + scanned, + created: 0, + updated: 0, + unchanged: 0, + skipped: 0, + failed: 0, + }; +} + +function printSummary(summary) { + console.log("Refresh summary:"); + for (const [name, count] of Object.entries(summary)) { + console.log(`- ${name}: ${count}`); + } +} + +export async function processAllOpenProposals(options) { + const issues = await listOpenProposalIssues(options.octokit, options.repository); + const summary = createSummary(issues.length); + const failures = []; + + for (const issue of issues) { + try { + const currentIssue = await getReferencedIssue( + options.octokit, + options.repository, + issue.number, + ); + const result = await processIssue(currentIssue, options); + summary[result] += 1; + } catch (error) { + summary.failed += 1; + failures.push(error); + console.error(`Failed to process issue #${issue.number}:`, error); + } + } + + printSummary(summary); + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to refresh ${failures.length} proposal issue(s).`); + } + + return summary; +} + +async function parseArguments(args) { + return yargs(args) + .option("number", { + alias: "n", + type: "number", + describe: "The issue number to process", + }) + .option("all-open-proposals", { + type: "boolean", + default: false, + describe: `Process every open issue with the "${REQUIRED_LABEL}" label`, + }) + .option("repo", { + alias: "r", + type: "string", + demandOption: true, + describe: "The owner and repository name. For example: web-platform-tests/interop", + }) + .check(argv => { + const hasIssueNumber = Number.isInteger(argv.number) && argv.number > 0; + if (hasIssueNumber === argv.allOpenProposals) { + throw new Error("Choose exactly one of --number or --all-open-proposals."); + } + return true; + }) + .strict() + .parse(); +} + +async function main() { + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + const argv = await parseArguments(hideBin(process.argv)); + const repository = parseRepository(argv.repo); + + const options = { + octokit, + repository, + fetchImpl: fetch, + featureCatalog: webFeatures + }; + + if (argv.allOpenProposals) { + await processAllOpenProposals(options); + return; + } + + const issue = await getReferencedIssue(octokit, repository, argv.number); + const result = await processIssue(issue, options); + printSummary({ + ...createSummary(1), + [result]: 1, + }); +} + +const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMainModule) { + main().catch(error => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/identify-web-features.test.js b/scripts/identify-web-features.test.js new file mode 100644 index 0000000..d029ec4 --- /dev/null +++ b/scripts/identify-web-features.test.js @@ -0,0 +1,519 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + HIDDEN_COMMENT_IN_ISSUE, + findFeaturesInIssue, + getFeatureData, + getMarkdownContentForFeature, + listOpenProposalIssues, + postOrUpdateComment, + processAllOpenProposals, + processIssue, +} from "./identify-web-features.js"; + +const repository = { owner: "web-platform-tests", repo: "interop" }; + +function proposal(number, overrides = {}) { + return { + number, + title: `Proposal ${number}`, + body: "", + state: "open", + labels: [{ name: "focus-area-proposal" }], + ...overrides, + }; +} + +test("explicit feature IDs take precedence over URL matches", () => { + const featureCatalog = { + explicit: { spec: "https://example.com/explicit" }, + explorer: { spec: "https://example.com/explorer" }, + specification: { spec: "https://example.com/specification" }, + }; + const issue = proposal(1, { + body: [ + "https://example.com/specification", + "https://web-platform-dx.github.io/web-features-explorer/features/explorer/", + "web-features: explicit", + ].join("\n"), + }); + + assert.deepEqual(findFeaturesInIssue(issue, featureCatalog), ["explicit"]); +}); + +test("spec URLs match array entries and respect fragments", () => { + const featureCatalog = { + "whole-spec": { spec: "https://example.com/specification" }, + "matching-section": { + spec: [ + "https://other.example/specification", + "https://example.com/specification#matching-section", + ], + }, + "different-section": { + spec: "https://example.com/specification#different-section", + }, + }; + const issue = proposal(1, { + body: "https://example.com/specification#matching-section", + }); + + assert.deepEqual(findFeaturesInIssue(issue, featureCatalog), [ + "whole-spec", + "matching-section", + ]); +}); + +test("explorer URLs identify features and take precedence over other URL matches", () => { + const featureCatalog = { + "explorer-feature": {}, + "spec-feature": { spec: "https://example.com/specification" }, + "wpt-feature": {}, + }; + const issue = proposal(1, { + body: [ + "https://example.com/specification", + "https://wpt.fyi/results/?q=feature%3Awpt-feature", + "https://web-platform-dx.github.io/web-features-explorer/features/explorer-feature.json", + ].join("\n"), + }); + + assert.deepEqual(findFeaturesInIssue(issue, featureCatalog), ["explorer-feature"]); +}); + +test("spec and WPT URL matches are combined without duplicates", () => { + const featureCatalog = { + "shared-feature": { spec: "https://example.com/shared" }, + "wpt-only-feature": {}, + }; + const issue = proposal(1, { + body: [ + "https://example.com/shared", + "https://wpt.fyi/results/?q=feature%3Ashared-feature", + "https://wpt.fyi/results/?q=feature%3Awpt-only-feature", + ].join("\n"), + }); + + assert.deepEqual(findFeaturesInIssue(issue, featureCatalog), [ + "shared-feature", + "wpt-only-feature", + ]); +}); + +test("web-features sections identify multiple feature IDs", () => { + const featureCatalog = { + "first-feature": {}, + "second-feature": {}, + }; + const issue = proposal(1, { + body: [ + "### web-features", + "first-feature", + "second-feature", + ].join("\r\n"), + }); + + assert.deepEqual(findFeaturesInIssue(issue, featureCatalog), [ + "first-feature", + "second-feature", + ]); +}); + +test("a newly available feature ID becomes detectable", () => { + const issue = proposal(1, { body: "web-features: newly-added" }); + + assert.deepEqual(findFeaturesInIssue(issue, {}), []); + assert.deepEqual(findFeaturesInIssue(issue, { + "newly-added": { spec: "https://example.com/newly-added" }, + }), ["newly-added"]); +}); + +test("feature data requests fail explicitly on non-success responses", async () => { + await assert.rejects( + getFeatureData("missing-feature", async () => ({ + ok: false, + status: 404, + })), + /Could not fetch feature "missing-feature": HTTP 404/, + ); +}); + +test("current explorer enrichment data is rendered from its current fields", () => { + const markdown = getMarkdownContentForFeature({ + id: "example-feature", + name: "Example feature", + description_html: "An example.", + status: { baseline: "low" }, + mdnUrls: [{ + title: "Example API", + url: "https://developer.mozilla.org/docs/Web/API/Example", + }], + standardPositions: [{ + vendor: "apple", + url: "https://github.com/WebKit/standards-positions/issues/1", + position: "support", + concerns: ["example concern", "another concern"], + }], + developerSignals: { + votes: 6, + url: "https://github.com/web-platform-dx/developer-signals/issues/1", + }, + useCases: [{ description: "An example use case." }], + chromeUseCounters: { + percentageOfPageLoad: 0.0023047, + url: "https://chromestatus.com/metrics/webfeature/timeline/popularity/1", + }, + stateOfSurveys: [{ + name: "State of HTML 2025", + question: "usage", + url: "https://2025.stateofhtml.com/en-US/usage/", + }], + interop: [{ + year: 2026, + label: "interop-2026-example", + url: "https://wpt.fyi/interop-2026?feature=interop-2026-example", + }], + wpt: { + url: "https://wpt.fyi/results?q=feature:example-feature", + }, + }); + + assert.match(markdown, /\[WebKit\]\(https:\/\/github\.com\/WebKit\/standards-positions\/issues\/1\) \(support, concerns: example concern, another concern\)/); + assert.match(markdown, /6 votes \/ 1 use case \(\[details\]\(https:\/\/github\.com\/web-platform-dx\/developer-signals\/issues\/1\)\)/); + assert.match(markdown, /\[chromestatus\.com\]\(https:\/\/chromestatus\.com\/metrics\/webfeature\/timeline\/popularity\/1\) \(~0\.230% of page loads\)/); + assert.match(markdown, /\[State of HTML 2025 \(usage question\)\]\(https:\/\/2025\.stateofhtml\.com\/en-US\/usage\/\)/); + assert.match(markdown, /\[2026\]\(https:\/\/wpt\.fyi\/interop-2026\?feature=interop-2026-example\)/); + assert.match(markdown, /\[wpt\.fyi results\]\(https:\/\/wpt\.fyi\/results\?q=feature:example-feature\)/); + assert.doesNotMatch(markdown, /undefined/); +}); + +test("missing explorer enrichment data does not prevent rendering", () => { + const markdown = getMarkdownContentForFeature({ + id: "minimal-feature", + name: "Minimal feature", + description_html: "A minimal feature.", + status: { baseline: false }, + }); + + assert.match(markdown, /\*\*ID:\*\* minimal-feature/); + assert.doesNotMatch(markdown, /Standard positions|Developer signals|Chrome use counter|State of CSS\/JS\/HTML surveys|previous Interop|WPT tests/); +}); + +test("bulk selection keeps only open labeled issues and excludes pull requests", async () => { + const octokit = { + paginate: async () => [ + proposal(1), + proposal(2, { state: "closed" }), + proposal(3, { labels: [] }), + proposal(4, { pull_request: {} }), + ], + }; + + const issues = await listOpenProposalIssues(octokit, repository); + + assert.deepEqual(issues.map(issue => issue.number), [1]); +}); + +test("an unchanged marked comment is not patched", async () => { + const markdown = `Current content\n${HIDDEN_COMMENT_IN_ISSUE}`; + const requests = []; + const octokit = { + paginate: async () => [{ id: 10, body: markdown }], + request: async (...args) => requests.push(args), + }; + + const result = await postOrUpdateComment(octokit, repository, 1, markdown); + + assert.equal(result, "unchanged"); + assert.equal(requests.length, 0); +}); + +test("a stale marked comment is patched", async () => { + const requests = []; + const octokit = { + paginate: async () => [{ id: 10, body: `Old content\n${HIDDEN_COMMENT_IN_ISSUE}` }], + request: async (...args) => requests.push(args), + }; + + const result = await postOrUpdateComment( + octokit, + repository, + 1, + `New content\n${HIDDEN_COMMENT_IN_ISSUE}`, + ); + + assert.equal(result, "updated"); + assert.equal(requests.length, 1); + assert.equal(requests[0][0], "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}"); + assert.equal(requests[0][1].comment_id, 10); +}); + +test("concurrent comment creation keeps only the first marked comment", async () => { + const markdown = `Current content\n${HIDDEN_COMMENT_IN_ISSUE}`; + const requests = []; + let commentLookup = 0; + const octokit = { + paginate: async () => { + commentLookup += 1; + if (commentLookup === 1) { + return []; + } + return [ + { id: 10, body: markdown }, + { id: 11, body: markdown }, + ]; + }, + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route.startsWith("POST")) { + return { data: { id: 11 } }; + } + return { data: {} }; + }, + }; + + const result = await postOrUpdateComment(octokit, repository, 1, markdown); + + assert.equal(result, "unchanged"); + assert.deepEqual(requests.map(request => request[0]), [ + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + "DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}", + ]); + assert.equal(requests[1][1].comment_id, 11); +}); + +test("concurrent comment creation updates a stale surviving comment", async () => { + const markdown = `New content\n${HIDDEN_COMMENT_IN_ISSUE}`; + const requests = []; + let commentLookup = 0; + const octokit = { + paginate: async () => { + commentLookup += 1; + if (commentLookup === 1) { + return []; + } + return [ + { id: 10, body: `Old content\n${HIDDEN_COMMENT_IN_ISSUE}` }, + { id: 11, body: markdown }, + ]; + }, + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route.startsWith("POST")) { + return { data: { id: 11 } }; + } + return { data: {} }; + }, + }; + + const result = await postOrUpdateComment(octokit, repository, 1, markdown); + + assert.equal(result, "updated"); + assert.deepEqual(requests.map(request => request[0]), [ + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + "DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}", + "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", + ]); + assert.equal(requests[2][1].comment_id, 10); + assert.equal(requests[2][1].body, markdown); +}); + +test("single-issue processing skips closed and unlabeled issues", async () => { + const options = { + octokit: {}, + repository, + featureCatalog: {}, + }; + + assert.equal(await processIssue(proposal(1, { state: "closed" }), options), "skipped"); + assert.equal(await processIssue(proposal(2, { labels: [] }), options), "skipped"); +}); + +test("single-issue processing fetches feature data and creates a comment", async () => { + const requests = []; + let createdComment; + const octokit = { + paginate: async () => createdComment ? [createdComment] : [], + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route.startsWith("POST")) { + createdComment = { id: 10, body: parameters.body }; + } + return { data: { id: 10 } }; + }, + }; + const fetchImpl = async url => ({ + ok: true, + json: async () => ({ + id: "newly-added", + name: "Newly added", + description_html: "A newly added feature.", + status: {}, + wpt: false, + }), + url, + }); + + const result = await processIssue(proposal(1, { + body: "web-features: newly-added", + }), { + octokit, + repository, + fetchImpl, + featureCatalog: { + "newly-added": { spec: "https://example.com/newly-added" }, + }, + }); + + assert.equal(result, "created"); + const postRequest = requests.find(([route]) => route.startsWith("POST")); + assert.match(postRequest[1].body, /\*\*ID:\*\* newly-added/); +}); + +test("single-issue processing redirects moved features to their target", async () => { + const requests = []; + const fetchedIds = []; + let createdComment; + const octokit = { + paginate: async () => createdComment ? [createdComment] : [], + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route.startsWith("POST")) { + createdComment = { id: 10, body: parameters.body }; + } + return { data: { id: 10 } }; + }, + }; + const fetchImpl = async url => { + const id = new URL(url).pathname.split("/").at(-1).replace(".json", ""); + fetchedIds.push(id); + return { + ok: true, + json: async () => ({ + id, + name: id, + description_html: `${id} description`, + status: {}, + wpt: false, + }), + }; + }; + + const result = await processIssue(proposal(1, { + body: "web-features: old-feature", + }), { + octokit, + repository, + fetchImpl, + featureCatalog: { + "old-feature": { + kind: "moved", + redirect_target: "new-feature", + }, + "new-feature": {}, + }, + }); + + assert.equal(result, "created"); + assert.deepEqual(fetchedIds, ["new-feature"]); + const postRequest = requests.find(([route]) => route.startsWith("POST")); + assert.match(postRequest[1].body, /\*\*ID:\*\* new-feature/); + assert.doesNotMatch(postRequest[1].body, /\*\*ID:\*\* old-feature/); +}); + +test("single-issue processing redirects split features to every target", async () => { + const requests = []; + const fetchedIds = []; + let createdComment; + const octokit = { + paginate: async () => createdComment ? [createdComment] : [], + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route.startsWith("POST")) { + createdComment = { id: 10, body: parameters.body }; + } + return { data: { id: 10 } }; + }, + }; + const fetchImpl = async url => { + const id = new URL(url).pathname.split("/").at(-1).replace(".json", ""); + fetchedIds.push(id); + return { + ok: true, + json: async () => ({ + id, + name: id, + description_html: `${id} description`, + status: {}, + wpt: false, + }), + }; + }; + + const result = await processIssue(proposal(1, { + body: "web-features: former-combined-feature", + }), { + octokit, + repository, + fetchImpl, + featureCatalog: { + "former-combined-feature": { + kind: "split", + redirect_targets: ["first-feature", "second-feature"], + }, + "first-feature": {}, + "second-feature": {}, + }, + }); + + assert.equal(result, "created"); + assert.deepEqual(fetchedIds, ["first-feature", "second-feature"]); + const postRequest = requests.find(([route]) => route.startsWith("POST")); + assert.match(postRequest[1].body, /\*\*ID:\*\* first-feature/); + assert.match(postRequest[1].body, /\*\*ID:\*\* second-feature/); + assert.doesNotMatch(postRequest[1].body, /\*\*ID:\*\* former-combined-feature/); +}); + +test("bulk processing continues after an issue failure and reports it at the end", async () => { + const requests = []; + const createdComments = new Map(); + const octokit = { + paginate: async (route, parameters) => { + if (route === "GET /repos/{owner}/{repo}/issues") { + return [proposal(1), proposal(2)]; + } + if (parameters.issue_number === 2) { + throw new Error("Comment lookup failed"); + } + const comment = createdComments.get(parameters.issue_number); + return comment ? [comment] : []; + }, + request: async (route, parameters) => { + requests.push([route, parameters]); + if (route === "GET /repos/{owner}/{repo}/issues/{issue_number}") { + return { data: proposal(parameters.issue_number) }; + } + if (route.startsWith("POST")) { + createdComments.set(parameters.issue_number, { id: 10, body: parameters.body }); + } + return { data: { id: 10 } }; + }, + }; + + await assert.rejects( + processAllOpenProposals({ + octokit, + repository, + featureCatalog: {}, + }), + error => error instanceof AggregateError && error.errors.length === 1, + ); + + const postRequests = requests.filter(([route]) => route.startsWith("POST")); + const issueRequests = requests.filter(([route]) => { + return route === "GET /repos/{owner}/{repo}/issues/{issue_number}"; + }); + assert.equal(postRequests.length, 1); + assert.equal(postRequests[0][1].issue_number, 1); + assert.deepEqual(issueRequests.map(([, parameters]) => parameters.issue_number), [1, 2]); +}); diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 0d7b036..08726c8 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -7,7 +7,7 @@ "name": "interop-scripts", "devDependencies": { "octokit": "^5.0.3", - "web-features": "^2.48.0", + "web-features": "^3.34.2", "yargs": "^18.0.0" } }, @@ -578,9 +578,9 @@ "license": "ISC" }, "node_modules/web-features": { - "version": "2.48.0", - "resolved": "https://registry.npmjs.org/web-features/-/web-features-2.48.0.tgz", - "integrity": "sha512-Qv/yRQP/gIQiMXF7XNCblDQDRQJ814PFArrjDvjXJnkVh80CulDMCIRaC4dB4Ex1ClE71xMwgjiBDdO8kcc48Q==", + "version": "3.34.2", + "resolved": "https://registry.npmjs.org/web-features/-/web-features-3.34.2.tgz", + "integrity": "sha512-1/prthzNwl/ITxBgFuKUCJ1ezWxZDM8rhz/VIBCD6zMEv5STMDQxgf05IfYxmJvFBTTLONaTDUk7umPkhUHmpw==", "dev": true, "license": "Apache-2.0" }, diff --git a/scripts/package.json b/scripts/package.json index ef36b6f..62abb78 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -1,9 +1,12 @@ { "name": "interop-scripts", "type": "module", + "scripts": { + "test": "node --test" + }, "devDependencies": { "octokit": "^5.0.3", - "web-features": "^2.48.0", + "web-features": "^3.34.2", "yargs": "^18.0.0" } }