From d4975fb8d3905a4afae52358610f94feb53f64cb Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 6 Nov 2023 16:08:37 -0700 Subject: [PATCH] Add function to load known MetaMask repos (#22) If a user passes a bare identifier on the command line, like this: ``` yarn dlx @metamask/module-lint utils ``` then we assume that they want to lint the `MetaMask/utils` repository. However we have to double-check that the repo they want to lint actually exists. They shouldn't be able to do this, for instance: ``` yarn dlx @metamask/module-lint asdlsdfl ``` The way we do this is by pulling the list of repositories that sit under the MetaMask GitHub organization. We exclude forks as well as archived repos. This list is cached for an hour so that future runs of the tool do not cause the rate limit for the GitHub API to be exceeded. --- .eslintrc.js | 5 +- package.json | 4 + ...nsure-metamask-repositories-loaded.test.ts | 43 +++++++++++ src/ensure-metamask-repositories-loaded.ts | 42 +++++++++++ src/index.test.ts | 9 --- src/index.ts | 9 --- tests/helpers.ts | 75 +++++++++++++++++++ yarn.lock | 46 ++++++++++-- 8 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 src/ensure-metamask-repositories-loaded.test.ts create mode 100644 src/ensure-metamask-repositories-loaded.ts delete mode 100644 src/index.test.ts delete mode 100644 src/index.ts create mode 100644 tests/helpers.ts diff --git a/.eslintrc.js b/.eslintrc.js index dc7fd43..497457a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,10 @@ module.exports = { overrides: [ { files: ['*.ts'], - extends: ['@metamask/eslint-config-typescript'], + extends: [ + '@metamask/eslint-config-typescript', + '@metamask/eslint-config-nodejs', + ], }, { diff --git a/package.json b/package.json index 9b07e70..f462c5c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "test": "jest && jest-it-up", "test:watch": "jest --watch" }, + "dependencies": { + "execa": "^5.1.1" + }, "devDependencies": { "@lavamoat/allow-scripts": "^2.3.1", "@lavamoat/preinstall-always-fail": "^1.0.0", @@ -72,6 +75,7 @@ "eslint-plugin-promise": "^6.1.1", "jest": "^28.1.3", "jest-it-up": "^2.0.2", + "jest-mock-extended": "^3.0.5", "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.3.0", "rimraf": "^3.0.2", diff --git a/src/ensure-metamask-repositories-loaded.test.ts b/src/ensure-metamask-repositories-loaded.test.ts new file mode 100644 index 0000000..58e4516 --- /dev/null +++ b/src/ensure-metamask-repositories-loaded.test.ts @@ -0,0 +1,43 @@ +import execa from 'execa'; + +import { ensureMetaMaskRepositoriesLoaded } from './ensure-metamask-repositories-loaded'; +import type { PrimaryExecaFunction } from '../tests/helpers'; +import { mockExeca } from '../tests/helpers'; + +jest.mock('execa'); + +const execaMock = jest.mocked(execa); + +describe('ensureMetaMaskRepositoriesLoaded', () => { + it('requests the repositories under the MetaMask GitHub organization, limiting the data to just a few fields', async () => { + mockExeca(execaMock, [ + { + args: [ + 'gh', + ['api', 'orgs/MetaMask/repos', '--cache', '1h', '--paginate'], + ], + result: { + stdout: JSON.stringify([ + { name: 'utils', fork: false, archived: false, extra: 'info' }, + { name: 'logo', fork: false, archived: false }, + { + name: 'ethjs-util', + fork: true, + archived: false, + something: 'else', + }, + { name: 'test-snaps', fork: true, archived: true }, + ]), + }, + }, + ]); + + const gitHubRepositories = await ensureMetaMaskRepositoriesLoaded(); + expect(gitHubRepositories).toStrictEqual([ + { name: 'utils', fork: false, archived: false }, + { name: 'logo', fork: false, archived: false }, + { name: 'ethjs-util', fork: true, archived: false }, + { name: 'test-snaps', fork: true, archived: true }, + ]); + }); +}); diff --git a/src/ensure-metamask-repositories-loaded.ts b/src/ensure-metamask-repositories-loaded.ts new file mode 100644 index 0000000..4495fb4 --- /dev/null +++ b/src/ensure-metamask-repositories-loaded.ts @@ -0,0 +1,42 @@ +import execa from 'execa'; + +/** + * The information about a GitHub repository that we care about. Primarily, + * we want to know whether repos are forks or have been archived, because we + * don't want to lint them. + */ +type GitHubRepository = { + name: string; + fork: boolean; + archived: boolean; +}; + +/** + * Requests data for the repositories listed under MetaMask's GitHub + * organization via the GitHub API, or returns the results from a previous call. + * The data is cached for an hour to prevent unnecessary calls to the GitHub + * API. + * + * @returns The list of repositories (whether previously or newly cached). + */ +export async function ensureMetaMaskRepositoriesLoaded(): Promise< + GitHubRepository[] +> { + const { stdout } = await execa('gh', [ + 'api', + 'orgs/MetaMask/repos', + '--cache', + '1h', + '--paginate', + ]); + const fullGitHubRepositories = JSON.parse(stdout); + return fullGitHubRepositories.map( + (fullGitHubRepository: Record) => { + return { + name: fullGitHubRepository.name, + fork: fullGitHubRepository.fork, + archived: fullGitHubRepository.archived, + }; + }, + ); +} diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index bc062d3..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 6972c11..0000000 --- a/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..783a09c --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,75 @@ +import type { + ExecaChildProcess, + Options as ExecaOptions, + ExecaReturnValue, +} from 'execa'; +import { mock } from 'jest-mock-extended'; +import { inspect, isDeepStrictEqual } from 'util'; + +/** + * `execa` can be called multiple ways. This is the way that we use it. + */ +export type PrimaryExecaFunction = ( + file: string, + args?: readonly string[] | undefined, + options?: ExecaOptions | undefined, +) => ExecaChildProcess; + +/** + * Builds an object that represents a successful result returned by `execa`. + * This kind of object is usually a bit cumbersome to build because it's a + * promise with extra properties glommed on to it (so it has a strange type). We + * use `jest-mock-extended` to help with this. + * + * @param overrides - Properties you want to add to the result object. + * @returns The complete `execa` result object. + */ +export function buildExecaResult( + overrides: Partial = { stdout: '' }, +): ExecaChildProcess { + return Object.assign(mock(), overrides); +} + +/** + * Mocks different invocations of `execa` to do different things. + * + * @param execaMock - The mocked version of `execa` (as obtained via + * `jest.mocked`). + * @param invocationMocks - Specifies outcomes of different invocations of + * `execa`. Each object in this array has `args` (the expected arguments to + * `execa`) and either `result` (properties of an ExecaResult object, such as + * `all: true`) or `error` (an Error). + */ +export function mockExeca( + execaMock: jest.MockedFn, + invocationMocks: ({ + args: Parameters; + } & ( + | { + result?: Partial; + } + | { + error?: Error; + } + ))[], +) { + execaMock.mockImplementation((...args): ExecaChildProcess => { + for (const invocationMock of invocationMocks) { + if (isDeepStrictEqual(args, invocationMock.args)) { + if ('error' in invocationMock && invocationMock.error) { + throw invocationMock.error; + } + if ('result' in invocationMock && invocationMock.result) { + return buildExecaResult(invocationMock.result); + } + throw new Error( + `No result or error was provided for execa() invocation ${inspect( + args, + )}`, + ); + } + } + + throw new Error(`Unmocked invocation of execa() with ${inspect(args)}`); + }); +} diff --git a/yarn.lock b/yarn.lock index ee76a89..9010cf9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -969,8 +969,10 @@ __metadata: eslint-plugin-n: ^15.7.0 eslint-plugin-prettier: ^4.2.1 eslint-plugin-promise: ^6.1.1 + execa: ^5.1.1 jest: ^28.1.3 jest-it-up: ^2.0.2 + jest-mock-extended: ^3.0.5 prettier: ^2.7.1 prettier-plugin-packagejson: ^2.3.0 rimraf: ^3.0.2 @@ -2345,6 +2347,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: ^4.2.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^7.0.0 + checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56 + languageName: node + linkType: hard + "clone-response@npm:^1.0.2": version: 1.0.3 resolution: "clone-response@npm:1.0.3" @@ -4486,6 +4499,18 @@ __metadata: languageName: node linkType: hard +"jest-mock-extended@npm:^3.0.5": + version: 3.0.5 + resolution: "jest-mock-extended@npm:3.0.5" + dependencies: + ts-essentials: ^7.0.3 + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 + typescript: ^3.0.0 || ^4.0.0 || ^5.0.0 + checksum: 440c52f743af588493c2cd02fa7e4e42177748ac3f7ae720f414bd58a4a72fad4271878457bf8796b62abcf9cf32cde4dc5151caad0805037bd965cc9ef07ca8 + languageName: node + linkType: hard + "jest-mock@npm:^28.1.3": version: 28.1.3 resolution: "jest-mock@npm:28.1.3" @@ -6557,6 +6582,15 @@ __metadata: languageName: node linkType: hard +"ts-essentials@npm:^7.0.3": + version: 7.0.3 + resolution: "ts-essentials@npm:7.0.3" + peerDependencies: + typescript: ">=3.7.0" + checksum: 74d75868acf7f8b95e447d8b3b7442ca21738c6894e576df9917a352423fde5eb43c5651da5f78997da6061458160ae1f6b279150b42f47ccc58b73e55acaa2f + languageName: node + linkType: hard + "ts-jest@npm:^28.0.7": version: 28.0.8 resolution: "ts-jest@npm:28.0.8" @@ -6985,7 +7019,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^21.0.0, yargs-parser@npm:^21.0.1": +"yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c @@ -7008,17 +7042,17 @@ __metadata: linkType: hard "yargs@npm:^17.0.1, yargs@npm:^17.3.1": - version: 17.5.1 - resolution: "yargs@npm:17.5.1" + version: 17.7.2 + resolution: "yargs@npm:17.7.2" dependencies: - cliui: ^7.0.2 + cliui: ^8.0.1 escalade: ^3.1.1 get-caller-file: ^2.0.5 require-directory: ^2.1.1 string-width: ^4.2.3 y18n: ^5.0.5 - yargs-parser: ^21.0.0 - checksum: 00d58a2c052937fa044834313f07910fd0a115dec5ee35919e857eeee3736b21a4eafa8264535800ba8bac312991ce785ecb8a51f4d2cc8c4676d865af1cfbde + yargs-parser: ^21.1.1 + checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a languageName: node linkType: hard