| /* This file is a part of @mdn/browser-compat-data |
| * See LICENSE file for more information. */ |
| |
| import { exec, execSync } from 'node:child_process'; |
| import fs from 'node:fs'; |
| import { promisify } from 'node:util'; |
| import path from 'node:path'; |
| |
| import esMain from 'es-main'; |
| import yargs from 'yargs'; |
| import { hideBin } from 'yargs/helpers'; |
| import { temporaryDirectoryTask } from 'tempy'; |
| |
| /** |
| * Executes a command asynchronously. |
| * @param command The command to execute asynchronously. |
| * @returns The output of the command. |
| */ |
| const execAsync = async (command: string): Promise<string> => { |
| const result = await promisify(exec)(command, { encoding: 'utf-8' }); |
| |
| return result.stdout.trim(); |
| }; |
| |
| /** |
| * Compare two references and print diff as Markdown or JSON |
| * @param opts Options |
| * @param opts.ref1 First reference to compare |
| * @param opts.ref2 Second reference to compare |
| * @param opts.format Format to export data as (either 'markdown' or 'json', default 'json') |
| * @param opts.github Whether to obtain artifacts from GitHub |
| */ |
| const main = async (opts: { |
| ref1: string | undefined; |
| ref2: string | undefined; |
| format?: string; |
| github?: boolean; |
| }): Promise<void> => { |
| const { ref1, ref2, format, github } = opts; |
| const results = await diff({ ref1, ref2, github }); |
| |
| if (format === 'markdown') { |
| printMarkdown(results.added, results.removed); |
| } else { |
| console.log(JSON.stringify(results, undefined, 2)); |
| } |
| }; |
| |
| /** |
| * Compare two references and get feature diff |
| * @param opts Options |
| * @param opts.ref1 First reference to compare |
| * @param opts.ref2 Second reference to compare |
| * @param opts.github Whether to obtain artifacts from GitHub |
| * @param opts.quiet If true, don't log to console |
| * @returns Diff between two refs |
| */ |
| const diff = async (opts: { |
| ref1?: string; |
| ref2?: string; |
| github?: boolean; |
| quiet?: boolean; |
| }): Promise<{ added: string[]; removed: string[] }> => { |
| const { ref1, ref2, github, quiet } = opts; |
| let refA, refB; |
| |
| if (ref1 === undefined && ref2 === undefined) { |
| // No refs: compare HEAD to parent commit |
| refA = 'HEAD^'; |
| refB = 'HEAD'; |
| } else if (ref2 === undefined) { |
| // One ref: compare ref to parent of ref |
| refB = `${ref1}`; |
| refA = `${ref1}^`; |
| } else { |
| // Two refs: compare ref2 to ref1 |
| refA = `${ref2}`; |
| refB = `${ref1}`; |
| } |
| |
| const aSide = await enumerate(refA, github === false, quiet); |
| const bSide = await enumerate(refB, github === false, quiet); |
| |
| return { |
| added: [...bSide].filter((feature) => !aSide.has(feature)), |
| removed: [...aSide].filter((feature) => !bSide.has(feature)), |
| }; |
| }; |
| |
| /** |
| * Enumerate features from GitHub or local checkout |
| * @param ref Reference to obtain features for |
| * @param skipGithub Skip fetching artifacts from GitHub |
| * @param quiet If true, don't log to console |
| * @returns Feature list from reference |
| */ |
| const enumerate = async ( |
| ref: string, |
| skipGithub: boolean, |
| quiet = false, |
| ): Promise<Set<string>> => { |
| if (!skipGithub) { |
| try { |
| return new Set(await getEnumerationFromGithub(ref)); |
| } catch (e) { |
| if (!quiet) { |
| console.error( |
| `Fetching artifact from GitHub failed: ${e} Using fallback.`, |
| ); |
| } |
| } |
| } |
| |
| return new Set(enumerateFeatures(ref, quiet)); |
| }; |
| |
| /** |
| * Enumerate features from GitHub |
| * @param ref Reference to obtain features for |
| * @returns Feature list from reference |
| */ |
| const getEnumerationFromGithub = async (ref: string): Promise<string[]> => { |
| const ENUMERATE_WORKFLOW = '15595228'; |
| const ENUMERATE_WORKFLOW_ARTIFACT = 'enumerate-features'; |
| const ENUMERATE_WORKFLOW_FILE = 'features.json'; |
| |
| const hash = await execAsync(`git rev-parse ${ref}`); |
| const workflowRun = await execAsync( |
| `gh api /repos/:owner/:repo/actions/workflows/${ENUMERATE_WORKFLOW}/runs\\?head_sha=${hash}\\&per_page=1 --jq '[.workflow_runs[] | select(.head_sha=="${hash}") | .id] | first'`, |
| ); |
| |
| if (!workflowRun) { |
| throw Error('No workflow run found for commit.'); |
| } |
| |
| return await temporaryDirectoryTask(async (tempdir) => { |
| await execAsync( |
| `gh run download ${workflowRun} -n ${ENUMERATE_WORKFLOW_ARTIFACT} --dir ${tempdir}`, |
| ); |
| const file = path.join(tempdir, ENUMERATE_WORKFLOW_FILE); |
| |
| return JSON.parse(fs.readFileSync(file, { encoding: 'utf-8' })); |
| }); |
| }; |
| |
| /** |
| * Enumerate features from local checkout |
| * @param ref Reference to obtain features for |
| * @param quiet If true, don't log to console |
| * @returns Feature list from reference |
| */ |
| const enumerateFeatures = (ref = 'HEAD', quiet = false): string[] => { |
| // GitHub API returns wrong merge commit for https://github.com/mdn/browser-compat-data/pull/25668. |
| ref = ref.replace( |
| '19d8ce0fd1016c3cd1cb6f7b98f72e99ae2f3f16', |
| '3af3a24bdf71f5393893f3724bc47acdd23acfe0', |
| ); |
| |
| // Get the short hash for this ref. |
| // Most of the time, you check out named references (a branch or a tag). |
| // However, if `ref` is already checked out, then `git worktree add` fails. As |
| // long as you haven't checked out a detached HEAD for `ref`, then |
| // `git worktree add` for the hash succeeds. |
| const hash = execSync(`git rev-parse --short ${ref}`, { |
| encoding: 'utf-8', |
| }).trim(); |
| |
| const worktree = `__enumerating__${hash}`; |
| |
| if (!quiet) { |
| console.error(`Enumerating features for ${ref} (${hash})`); |
| } |
| |
| try { |
| execSync(`git worktree add ${worktree} ${hash}`); |
| |
| try { |
| execSync('npm ci', { cwd: worktree }); |
| } catch (e) { |
| // If the clean install fails, proceed anyways |
| } |
| |
| execSync(`npx tsx ./scripts/enumerate-features.ts --data-from=${worktree}`); |
| |
| return JSON.parse(fs.readFileSync('.features.json', { encoding: 'utf-8' })); |
| } finally { |
| execSync(`git worktree remove ${worktree}`); |
| } |
| }; |
| |
| /** |
| * Format feature for Markdown printing |
| * @param feat Feature |
| * @returns Formatted feature |
| */ |
| const fmtFeature = (feat: string) => `- \`${feat}\``; |
| |
| /** |
| * Print feature diff as Markdown |
| * @param added List of added features |
| * @param removed List of removed features |
| */ |
| const printMarkdown = (added: string[], removed: string[]): void => { |
| if (removed.length) { |
| console.log('## Removed\n'); |
| console.log(removed.map(fmtFeature).join('\n')); |
| } |
| if (added.length) { |
| if (removed.length) { |
| console.log(''); |
| } |
| console.log('## Added\n'); |
| console.log(added.map(fmtFeature).join('\n')); |
| } |
| }; |
| |
| if (esMain(import.meta)) { |
| const { argv } = yargs(hideBin(process.argv)).command( |
| '$0 [ref1] [ref2]', |
| 'Compare the set of features at refA and refB', |
| (yargs) => { |
| yargs |
| .positional('ref1', { |
| description: 'A Git ref (branch, tag, or commit)', |
| defaultDescription: 'ref1^', |
| }) |
| .positional('ref2', { |
| description: 'A Git ref (branch, tag, or commit)', |
| defaultDescription: 'HEAD', |
| }) |
| .option('format', { |
| type: 'string', |
| nargs: 1, |
| choices: ['json', 'markdown'], |
| demandOption: 'a named format is required', |
| default: 'markdown', |
| }) |
| .option('no-github', { |
| type: 'boolean', |
| description: "Don't fetch artifacts from GitHub.", |
| }) |
| .example('$0', 'compare HEAD to parent commit') |
| .example('$0 176d4ed', 'compare 176d4ed to its parent commit') |
| .example('$0 topic-branch main', 'compare a branch to main'); |
| }, |
| ); |
| |
| await main(argv as any); |
| } |
| |
| export default diff; |