Skip to content

Commit 0332621

Browse files
mcmireGudahtt
andauthored
Count number of refs to contributor docs in PRs (#13)
Add a script that compiles the most recent 5000 comments created in pull requests across our major repos (`metamask-extension`, `metamask-mobile`, `core`, `design-tokens`, `snaps`), then counts how many comments contain a link to the `contributor-docs` repo. This gives us some idea of how often the contributor docs are being used. Co-authored-by: Mark Stacey <[email protected]>
1 parent e8fbf24 commit 0332621

17 files changed

+1812
-38
lines changed

.depcheckrc.json

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"@lavamoat/preinstall-always-fail",
55
"@types/*",
66
"prettier-plugin-packagejson",
7+
"@swc/cli",
8+
"@swc/core",
79
"ts-node",
810
"typedoc"
911
]

.eslintrc.js

+4-6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module.exports = {
1010
},
1111

1212
{
13-
files: ['*.js'],
13+
files: ['*.js', '*.ts'],
1414
parserOptions: {
1515
sourceType: 'script',
1616
},
@@ -19,18 +19,16 @@ module.exports = {
1919

2020
{
2121
files: ['*.test.ts', '*.test.js'],
22-
extends: [
23-
'@metamask/eslint-config-jest',
24-
'@metamask/eslint-config-nodejs',
25-
],
22+
extends: ['@metamask/eslint-config-jest'],
2623
},
2724
],
2825

2926
ignorePatterns: [
3027
'!.eslintrc.js',
3128
'!.prettierrc.js',
29+
'.yarn/',
3230
'dist/',
3331
'docs/',
34-
'.yarn/',
32+
'tmp/',
3533
],
3634
};

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,6 @@ node_modules/
7676
!.yarn/releases
7777
!.yarn/sdks
7878
!.yarn/versions
79+
80+
# Temporary files
81+
tmp

README.md

+40-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,48 @@
11
# GitHub Tools
22

3-
A place for internal GitHub tools to exist and be used. This currently only hosts a single script for getting the PR review load of the extension platform team but can be modified to include new tools or work with other teams.
3+
A place for internal GitHub tools to exist and be used.
44

55
## Usage
66

7-
This isn't a module, but the module template has the best setup. It cannot be installed and should not be published. Just clone this repo and run the script `yarn get-review-metrics`
7+
This repository holds a collection of scripts which are intended to be run locally:
8+
9+
- `yarn get-review-metrics`: Gets the PR load of the extension platform team.
10+
- `yarn count-references-to-contributor-docs`: Counts the number of references to the `contributor-docs` repo in pull request comments.
11+
12+
### Authentication
13+
14+
Some scripts require a GitHub token in order to run fully.
15+
16+
For best results, create a [classic personal token](https://github.com/settings/tokens) and ensure that it has the following scopes:
17+
18+
- `read:org`
19+
- `public_repo`
20+
21+
To use the token, you need to set the `GITHUB_AUTH_TOKEN` environment variable:
22+
23+
```
24+
GITHUB_AUTH_TOKEN="<your GitHub token>" <command>
25+
```
26+
27+
It's recommended to use your machine's local keychain to store the token and retrieve it from there. For example, under macOS, you can use the following command to store the token:
28+
29+
```
30+
security add-generic-password -a $USER -s 'GitHub auth token' -w "<your GitHub token>"
31+
```
32+
33+
Now you can use the token like this:
34+
35+
```
36+
GITHUB_NPM_TOKEN="$(security find-generic-password a $USER -s 'GitHub auth token' -w)" <command>
37+
```
38+
39+
### Logging
40+
41+
Some scripts print additional information that may be useful for debugging. To see it, set the `DEBUG` environment variable as follows:
42+
43+
```
44+
DEBUG="metamask:*" <command>
45+
```
846

947
## Contributing
1048

package.json

+10-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"private": true,
55
"description": "Tools for interacting with the GitHub API to do metrics gathering",
66
"scripts": {
7+
"count-references-to-contributor-docs": "ts-node --swc src/scripts/count-references-to-contributor-docs/cli.ts",
78
"get-review-metrics": "ts-node src/get-review-metrics.ts",
89
"lint": "yarn lint:tsc && yarn lint:eslint && yarn lint:constraints && yarn lint:misc --check && yarn lint:dependencies --check",
910
"lint:constraints": "yarn constraints",
@@ -16,9 +17,13 @@
1617
"test:watch": "jest --watch"
1718
},
1819
"dependencies": {
20+
"@metamask/utils": "^7.1.0",
21+
"@octokit/graphql": "^7.0.1",
22+
"@octokit/request": "^8.1.1",
1923
"@octokit/rest": "^19.0.13",
2024
"@types/luxon": "^3.3.0",
21-
"luxon": "^3.3.0"
25+
"luxon": "^3.3.0",
26+
"ora": "^5.4.1"
2227
},
2328
"devDependencies": {
2429
"@lavamoat/allow-scripts": "^2.3.1",
@@ -27,6 +32,8 @@
2732
"@metamask/eslint-config-jest": "^12.0.0",
2833
"@metamask/eslint-config-nodejs": "^12.0.0",
2934
"@metamask/eslint-config-typescript": "^12.0.0",
35+
"@swc/cli": "^0.1.62",
36+
"@swc/core": "^1.3.80",
3037
"@types/jest": "^28.1.6",
3138
"@types/node": "^20.3.2",
3239
"@typescript-eslint/eslint-plugin": "^5.43.0",
@@ -54,7 +61,8 @@
5461
},
5562
"lavamoat": {
5663
"allowScripts": {
57-
"@lavamoat/preinstall-always-fail": false
64+
"@lavamoat/preinstall-always-fail": false,
65+
"@swc/core": false
5866
}
5967
}
6068
}

src/cache-utils.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Json } from '@metamask/utils';
2+
3+
import { isFile, readJsonFile, writeJsonFile } from './fs-utils';
4+
import { log } from './logging-utils';
5+
6+
type Cache<Data extends Json> = {
7+
ctime: string;
8+
data: Data;
9+
};
10+
11+
/**
12+
* How long data retrieved from the GitHub API is cached.
13+
*/
14+
const DEFAULT_MAX_AGE = 60 * 60 * 1000; // 1 hour
15+
16+
/**
17+
* Avoids rate limits when making requests to an API by consulting a file cache.
18+
*
19+
* Reads the given cache file and returns the data within it if it exists and is
20+
* fresh enough; otherwise runs the given function and saves its return value to
21+
* the file.
22+
*
23+
* @param args - The arguments to this function.
24+
* @param args.filePath - The path to the file where the data should be saved.
25+
* @param args.getDataToCache - A function to get the data that should be cached
26+
* if the cache does not exist or is stale.
27+
* @param args.maxAge - The amount of time (in milliseconds) that the cache is
28+
* considered "fresh". Affects subsequent calls: if `fetchOrPopulateFileCache`
29+
* is called again with the same file path within the duration specified here,
30+
* `getDataToCache` will not get called again, otherwise it will.
31+
*/
32+
export async function fetchOrPopulateFileCache<Data extends Json>({
33+
filePath,
34+
getDataToCache,
35+
maxAge = DEFAULT_MAX_AGE,
36+
}: {
37+
filePath: string;
38+
getDataToCache: () => Data | Promise<Data>;
39+
maxAge?: number;
40+
}): Promise<Data> {
41+
const now = new Date();
42+
43+
if (await isFile(filePath)) {
44+
const cache = await readJsonFile<Cache<Data>>(filePath);
45+
const createdDate = new Date(cache.ctime);
46+
47+
if (now.getTime() - createdDate.getTime() <= maxAge) {
48+
log(`Reusing fresh cached data under ${filePath}`);
49+
return cache.data;
50+
}
51+
}
52+
53+
log(
54+
`Cache does not exist or is stale; preparing data to write to ${filePath}`,
55+
);
56+
const dataToCache = await getDataToCache();
57+
await writeJsonFile(filePath, {
58+
ctime: now.toISOString(),
59+
data: dataToCache,
60+
});
61+
return dataToCache;
62+
}

src/constants.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import path from 'path';
2+
3+
/**
4+
* The directory where cache files will be stored.
5+
*/
6+
export const CACHE_DIR = path.resolve(__dirname, '../tmp/cache');

src/env-utils.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Retrieves the value of an environment variable, throwing if it doesn't exist.
3+
*
4+
* @param name - The property in `process.env` you want to retrieve.
5+
* @throws If the given environment variable has not been set.
6+
* @returns The value of the environment variable.
7+
*/
8+
export function getRequiredEnvironmentVariable(name: string): string {
9+
// This function is designed to access `process.env`.
10+
// eslint-disable-next-line n/no-process-env
11+
const value = process.env[name];
12+
13+
if (value === undefined) {
14+
throw new Error(`Must set ${name}`);
15+
}
16+
17+
return value;
18+
}

src/error-utils.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { isObject } from '@metamask/utils';
2+
3+
/**
4+
* Type guard for determining whether the given value is an instance of Error.
5+
* For errors generated via `fs.promises`, `error instanceof Error` won't work,
6+
* so we have to come up with another way of testing.
7+
*
8+
* @param error - The object to check.
9+
* @returns True or false, depending on the result.
10+
*/
11+
function isError(error: unknown): error is Error {
12+
return (
13+
error instanceof Error ||
14+
(isObject(error) && error.constructor.name === 'Error')
15+
);
16+
}
17+
18+
/**
19+
* Type guard for determining whether the given value is an error object with a
20+
* `code` property such as the type of error that Node throws for filesystem
21+
* operations, etc.
22+
*
23+
* @param error - The object to check.
24+
* @returns True or false, depending on the result.
25+
*/
26+
export function isErrorWithCode(error: unknown): error is { code: string } {
27+
return typeof error === 'object' && error !== null && 'code' in error;
28+
}
29+
30+
/**
31+
* Builds a new error object, linking to the original error via the `cause`
32+
* property if it is an Error.
33+
*
34+
* This function is useful to reframe error messages in general, but is
35+
* _critical_ when interacting with any of Node's filesystem functions as
36+
* provided via `fs.promises`, because these do not produce stack traces in the
37+
* case of an I/O error (see <https://github.com/nodejs/node/issues/30944>).
38+
*
39+
* @param message - The desired message of the new error.
40+
* @param originalError - The error that you want to cover (either an Error or
41+
* something throwable).
42+
* @returns A new error object.
43+
*/
44+
export function wrapError(message: string, originalError: unknown) {
45+
if (isError(originalError)) {
46+
const error: Error & { code?: string } = new Error(message, {
47+
cause: originalError,
48+
});
49+
50+
if (isErrorWithCode(originalError)) {
51+
error.code = originalError.code;
52+
}
53+
54+
return error;
55+
}
56+
57+
return new Error(`${message}: ${String(originalError)}`);
58+
}

src/fs-utils.ts

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Json } from '@metamask/utils';
2+
import fs from 'fs';
3+
import path from 'path';
4+
5+
import { isErrorWithCode, wrapError } from './error-utils';
6+
7+
/**
8+
* Reads the file at the given path, assuming its content is encoded as UTF-8.
9+
*
10+
* @param filePath - The path to the file.
11+
* @returns The content of the file.
12+
* @throws An error with a stack trace if reading fails in any way.
13+
*/
14+
export async function readFile(filePath: string): Promise<string> {
15+
try {
16+
return await fs.promises.readFile(filePath, 'utf8');
17+
} catch (error) {
18+
throw wrapError(`Could not read file '${filePath}'`, error);
19+
}
20+
}
21+
22+
/**
23+
* Writes content to the file at the given path.
24+
*
25+
* @param filePath - The path to the file.
26+
* @param content - The new content of the file.
27+
* @throws An error with a stack trace if writing fails in any way.
28+
*/
29+
export async function writeFile(
30+
filePath: string,
31+
content: string,
32+
): Promise<void> {
33+
try {
34+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
35+
await fs.promises.writeFile(filePath, content);
36+
} catch (error) {
37+
throw wrapError(`Could not write file '${filePath}'`, error);
38+
}
39+
}
40+
41+
/**
42+
* Reads the assumed JSON file at the given path, attempts to parse it, and
43+
* returns the resulting object.
44+
*
45+
* @param filePath - The path segments pointing to the JSON file. Will be passed
46+
* to path.join().
47+
* @returns The object corresponding to the parsed JSON file, typed against the
48+
* struct.
49+
* @throws An error with a stack trace if reading fails in any way, or if the
50+
* parsed value is not a plain object.
51+
*/
52+
export async function readJsonFile<Value extends Json>(
53+
filePath: string,
54+
): Promise<Value> {
55+
try {
56+
const content = await readFile(filePath);
57+
return JSON.parse(content);
58+
} catch (error) {
59+
throw wrapError(`Could not read JSON file '${filePath}'`, error);
60+
}
61+
}
62+
63+
/**
64+
* Attempts to write the given JSON-like value to the file at the given path.
65+
* Adds a newline to the end of the file.
66+
*
67+
* @param filePath - The path to write the JSON file to, including the file
68+
* itself.
69+
* @param jsonValue - The JSON-like value to write to the file. Make sure that
70+
* JSON.stringify can handle it.
71+
* @throws An error with a stack trace if writing fails in any way.
72+
*/
73+
export async function writeJsonFile(
74+
filePath: string,
75+
jsonValue: Json,
76+
): Promise<void> {
77+
try {
78+
await writeFile(filePath, JSON.stringify(jsonValue, null, ' '));
79+
} catch (error) {
80+
throw wrapError(`Could not write JSON file '${filePath}'`, error);
81+
}
82+
}
83+
84+
/**
85+
* Determines whether the given path refers to a file.
86+
*
87+
* @param entryPath - The path.
88+
* @returns The boolean result.
89+
*/
90+
export async function isFile(entryPath: string): Promise<boolean> {
91+
try {
92+
const stats = await fs.promises.stat(entryPath);
93+
return stats.isFile();
94+
} catch (error) {
95+
if (isErrorWithCode(error) && error.code === 'ENOENT') {
96+
return false;
97+
}
98+
throw error;
99+
}
100+
}

0 commit comments

Comments
 (0)