-
Notifications
You must be signed in to change notification settings - Fork 29
GitHub action to update interop proposals with web-features-explorer data #978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
225f751
GitHub action to update interop proposals with web-features-explorer …
captainbrosset ae819e2
package lock file
captainbrosset bcdc5bb
Address review comments
captainbrosset 4771527
typo
captainbrosset 9b4204e
More whitespace
captainbrosset 9109d68
fix missing wpt link
captainbrosset b9ac7ef
nicer wpt link preview
captainbrosset 6ce3fad
bump web-features
captainbrosset File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# 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 | ||
|
||
on: | ||
issues: | ||
types: [opened, edited] | ||
|
||
jobs: | ||
run-script: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v2 | ||
- name: Set up Node.js | ||
uses: actions/setup-node@v2 | ||
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 }} | ||
env: | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
node_modules |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,320 @@ | ||
import { Octokit } from "octokit"; | ||
import { features } from "web-features"; | ||
import yargs from "yargs"; | ||
|
||
// 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 = "<!-- interop-proposals-bot web-features update -->"; | ||
const GITHUB_API_VERSION = "2022-11-28"; | ||
|
||
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 }); | ||
|
||
function escapeFeatureName(feature) { | ||
// Escape the feature name for use in HTML. | ||
return feature.name.replace(/</g, "<").replace(/>/g, ">"); | ||
} | ||
|
||
async function getReferencedIssue() { | ||
const response = await octokit.request(`GET /repos/${argv.repo}/issues/${argv.number}`,); | ||
return response.data; | ||
} | ||
|
||
function gatherUrlsFromIssue(issueBody) { | ||
const urls = issueBody.match(/https?:\/\/[^)\s]+/g) || []; | ||
captainbrosset marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return urls.map(url => new URL(url)); | ||
} | ||
|
||
// Identify web-features based on spec URLs in the issue body. | ||
function gatherFeaturesFromSpecUrls(urls) { | ||
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)); | ||
|
||
if (specUrls.some(specUrl => { | ||
return specUrl.hostname === url.hostname && | ||
specUrl.pathname === url.pathname && | ||
(specUrl.hash ? specUrl.hash === url.hash : true); | ||
})) { | ||
gatheredFeatures.add(id) | ||
captainbrosset marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
} | ||
|
||
return gatheredFeatures; | ||
} | ||
|
||
// Identify web-features based on explorer URLs in the issue body. | ||
function gatherFeaturesFromExplorerUrls(urls) { | ||
const gatheredFeatures = new Set(); | ||
|
||
for (const url of urls) { | ||
if (url.hostname !== "web-platform-dx.github.io" || !url.pathname.startsWith("/web-features-explorer/features/")) { | ||
continue; | ||
} | ||
|
||
const candidateId = url.pathname.substring(url.pathname.indexOf("features/") + 9).replace("/", "").replace(".json", ""); | ||
if (features[candidateId]) { | ||
gatheredFeatures.add(candidateId); | ||
} | ||
} | ||
|
||
return gatheredFeatures; | ||
} | ||
|
||
// Identify web-features based on WPT URLs in the issue body. | ||
function gatherFeaturesFromWPTUrls(urls) { | ||
const gatheredFeatures = new Set(); | ||
|
||
for (const url of urls) { | ||
if (url.hostname !== "wpt.fyi" || !url.pathname.startsWith("/results/") || !url.searchParams.has("q")) { | ||
continue; | ||
} | ||
|
||
const query = url.searchParams.get("q"); | ||
const match = query.match(/feature:([a-z0-9-]+)/); | ||
if (match && match[1] && features[match[1]]) { | ||
gatheredFeatures.add(match[1]); | ||
} | ||
} | ||
|
||
return gatheredFeatures; | ||
} | ||
|
||
// Identify web-features by checking for explicit mentions in the issue body. | ||
function gatherFeaturesFromExplicitMentions(issueBody) { | ||
const gatheredFeatures = new Set(); | ||
|
||
// Look for `web-features: <feature-id>` or `web-feature: <feature-id>` 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]]) { | ||
gatheredFeatures.add(match[1]); | ||
} | ||
} | ||
|
||
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); | ||
|
||
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) { | ||
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; | ||
} | ||
} | ||
|
||
function getBaselineStatusAsMarkdown(feature) { | ||
if (feature.status && feature.status.baseline === "high") { | ||
return "Widely Available"; | ||
} else if (feature.status && feature.status.baseline === "low") { | ||
return "Newly Available"; | ||
} | ||
return "Limited Availability"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There might also be totally unimplemented features, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, but Limited Availability currently encompasses them too. |
||
} | ||
|
||
function getDocsAsMarkdown(feature) { | ||
if (!feature.mdnUrls.length) { | ||
return ""; | ||
} | ||
|
||
const docs = feature.mdnUrls.map(url => `[${url.title}](${url.url})`).join(", "); | ||
return `* **Docs:** ${docs}\n`; | ||
} | ||
|
||
function getStandardPositionsAsMarkdown(feature) { | ||
if (!feature.standardPositions.mozilla.url && !feature.standardPositions.webkit.url) { | ||
return ""; | ||
} | ||
|
||
let pos = "* **Standard positions:** "; | ||
|
||
if (feature.standardPositions.mozilla.url) { | ||
pos += `[Mozilla](${feature.standardPositions.mozilla.url})`; | ||
} | ||
if (feature.standardPositions.webkit.url) { | ||
pos += (pos ? ", " : "") + `[WebKit](${feature.standardPositions.webkit.url})`; | ||
} | ||
|
||
return pos + "\n"; | ||
} | ||
|
||
function getUseCounterAsMarkdown(feature) { | ||
if (!feature.useCounters.chromeStatusUrl) { | ||
return ""; | ||
} | ||
return `* **Chrome use counter:** [chromestatus.com](${feature.useCounters.chromeStatusUrl})\n`; | ||
} | ||
|
||
function getSurveysAsMarkdown(feature) { | ||
if (!feature.stateOfSurveys || !feature.stateOfSurveys.length) { | ||
return ""; | ||
} | ||
|
||
const surveys = feature.stateOfSurveys.map(survey => { | ||
return `[${survey.name} (${survey.question} question)](${survey.link})`; | ||
}).join(", "); | ||
|
||
return `* **State of CSS/JS/HTML surveys:** ${surveys}\n`; | ||
} | ||
|
||
function getPreviousInteropsAsMarkdown(feature) { | ||
if (!feature.interop.length) { | ||
return ""; | ||
} | ||
|
||
const interops = feature.interop.map(i => { | ||
return `[${i.year}](https://wpt.fyi/interop-2024?feature=${i.label})`; | ||
}).join(", "); | ||
|
||
return `* **Included in previous Interop iterations:** ${interops}\n` | ||
} | ||
|
||
function getWPTLinkAsMarkdown(feature) { | ||
if (!feature.wptLink) { | ||
return ""; | ||
} | ||
return `* **WPT tests:** [wpt.fyi](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); | ||
captainbrosset marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} | ||
}); | ||
} | ||
} | ||
|
||
// The main entry point to the script. | ||
async function main() { | ||
const issue = await getReferencedIssue(); | ||
|
||
console.log(`Processing issue #${issue.number}: "${issue.title}"`); | ||
const featureIds = findFeaturesInIssue(issue); | ||
const features = await Promise.all(featureIds.map(id => getFeatureData(id))); | ||
|
||
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."); | ||
|
||
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: <feature-id>`.\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 += "If this doesn't accurately correspond to your proposal, please update your initial comment to include `web-features: <feature-id>`.\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) { | ||
const featureContent = getMarkdownContentForFeature(feature); | ||
|
||
if (features.length > 1) { | ||
content += `<details>\n`; | ||
content += `<summary>${escapeFeatureName(feature)}</summary>\n\n`; | ||
content += featureContent; | ||
content += `</details>\n\n`; | ||
} else { | ||
content += featureContent; | ||
} | ||
} | ||
} | ||
|
||
captainbrosset marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Add the hidden comment to find this comment again later. | ||
content += `\n${HIDDEN_COMMENT_IN_ISSUE}`; | ||
|
||
await postOrUpdateComment(issue.number, content); | ||
} | ||
|
||
main(); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps updating those actions to newer versions (co@v5 and node@v4)?
https://github.com/actions/checkout/releases
https://github.com/actions/setup-node/releases