Skip to content

Commit 729fbbc

Browse files
authored
Add PR annotations for validate, format, and types (#2345)
Lint errors already show up in the GitHub changes view. This makes errors from the validate, format, and a new type checking step also show up inline when applicable. Type checking has hundreds of false positives so it's not required to pass. It'll also only show warnings from files modified by the PR. GitHub's annotation feature is very poorly designed and implemented, so this has a lot of restrictions, such as 10 annotation limit per job. Anything beyond that is silently ignored because of course. (This also isn't documented in GitHub's user-facing docs anywhere, because again of course)
1 parent d6952a1 commit 729fbbc

File tree

7 files changed

+257
-10
lines changed

7 files changed

+257
-10
lines changed

.github/workflows/validate.yml

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
cache: npm
3636
- name: Install dependencies
3737
run: npm ci
38-
- name: Validate
38+
- name: Lint
3939
run: npm run lint
4040

4141
format:
@@ -52,5 +52,33 @@ jobs:
5252
cache: npm
5353
- name: Install dependencies
5454
run: npm ci
55-
- name: Validate
56-
run: npm run check-format
55+
- name: Check format
56+
shell: bash
57+
run: |
58+
# Rewrite the prettier output format into the GitHub Actions annotation format
59+
# This is a hack, but this is easier than reimplementing the prettier CLI's ignore
60+
# rules ourselves.
61+
NO_COLOR=1 npm run check-format 2> >(tee >( \
62+
grep -v 'Run Prettier with --write to fix' | \
63+
sed -e 's/^\[warn] \(.*\)$/::warning file=\1,line=1::File was not formatted with prettier. Usually, commenting !format will fix this./' \
64+
))
65+
66+
type-warnings:
67+
runs-on: ubuntu-latest
68+
steps:
69+
- name: Checkout
70+
uses: actions/checkout@v4
71+
with:
72+
persist-credentials: false
73+
- name: Install Node.js
74+
uses: actions/setup-node@v4
75+
with:
76+
node-version: 22
77+
cache: npm
78+
- name: Install dependencies
79+
run: npm ci
80+
- name: Generate type warnings
81+
run: node development/ci-generate-type-warnings.js
82+
env:
83+
PR_NUMBER: "${{ github.event.number }}"
84+
GH_REPO: "${{ github.repository }}"

development/builder.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -960,12 +960,13 @@ class Builder {
960960
async validate() {
961961
const errors = [];
962962
const build = await this.build();
963-
for (const [fileName, file] of Object.entries(build.files)) {
963+
for (const [outputFileName, file] of Object.entries(build.files)) {
964964
try {
965965
await file.validate();
966966
} catch (e) {
967967
errors.push({
968-
fileName,
968+
outputFileName,
969+
inputFilePath: file.sourcePath,
969970
error: e,
970971
});
971972
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import pathUtil from "node:path";
2+
import ts from "typescript";
3+
import { createAnnotation, getChangedFiles, isCI } from "./ci-interop.js";
4+
5+
/**
6+
* @fileoverview Generates CI annotations for type warnings in files modified by the
7+
* pull request. Standard TypeScript CLI will include all files and its output is
8+
* detected as errors, so not usable for us.
9+
*/
10+
11+
const check = async () => {
12+
const rootDir = pathUtil.join(import.meta.dirname, "..");
13+
14+
const changedFiles = Array.from(await getChangedFiles()).sort();
15+
const changedFilesAbsolute = changedFiles.map((f) =>
16+
pathUtil.join(rootDir, f)
17+
);
18+
19+
console.log(`${changedFiles.size} changed files:`);
20+
console.log(Array.from(changedFiles).sort().join("\n"));
21+
console.log("");
22+
23+
const tsconfigPath = pathUtil.join(rootDir, "tsconfig.json");
24+
const commandLine = ts.getParsedCommandLineOfConfigFile(
25+
tsconfigPath,
26+
{},
27+
ts.sys
28+
);
29+
30+
const program = ts.createProgram({
31+
rootNames: commandLine.fileNames,
32+
options: commandLine.options,
33+
});
34+
const emitted = program.emit();
35+
36+
const diagnostics = [
37+
...ts.getPreEmitDiagnostics(program),
38+
...emitted.diagnostics,
39+
];
40+
41+
let numWarnings = 0;
42+
43+
for (const diagnostic of diagnostics) {
44+
if (!changedFilesAbsolute.includes(diagnostic.file.fileName)) {
45+
continue;
46+
}
47+
48+
const startPosition = ts.getLineAndCharacterOfPosition(
49+
diagnostic.file,
50+
diagnostic.start
51+
);
52+
const endPosition = ts.getLineAndCharacterOfPosition(
53+
diagnostic.file,
54+
diagnostic.start
55+
);
56+
// Won't be the most readable, but it's probably fine.
57+
const flattened = ts.flattenDiagnosticMessageText(
58+
diagnostic.messageText,
59+
" ",
60+
0
61+
);
62+
63+
numWarnings++;
64+
createAnnotation({
65+
type: "warning",
66+
file: diagnostic.file.fileName,
67+
title: "Type warning - may indicate a bug - ignore if no bug",
68+
onlyIfChanged: true,
69+
message: flattened,
70+
line: startPosition.line + 1,
71+
col: startPosition.character + 1,
72+
endLine: endPosition.line + 1,
73+
endCol: endPosition.character + 1,
74+
});
75+
}
76+
77+
console.log(`Warnings in changed files: ${numWarnings}`);
78+
console.log(`Warnings in all files: ${diagnostics.length}`);
79+
};
80+
81+
if (isCI()) {
82+
check();
83+
} else {
84+
console.error(
85+
"This script is only intended to be used in CI. For development, use normal TypeScript CLI instead."
86+
);
87+
process.exit(1);
88+
}

development/ci-interop.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import pathUtil from "node:path";
2+
3+
/**
4+
* @fileoverview Various utilities related to integrating with CI.
5+
*/
6+
7+
// https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md#limitations
8+
export const MAX_ANNOTATIONS_PER_TYPE_PER_STEP = 10;
9+
export const MAX_ANNOTATIONS_PER_JOB = 50;
10+
11+
/**
12+
* @returns {boolean} true if running in CI (GitHub Actions, etc.)
13+
*/
14+
export const isCI = () => !!process.env.CI;
15+
16+
/**
17+
* Note: PR may have changed between when this CI job was queued and when this job runs.
18+
* So, don't use this for security-critical things where accuracy is a must.
19+
* @returns {Promise<Set<string>>} List of paths (relative to root without leading / or ./) changed by this PR.
20+
*/
21+
export const getChangedFiles = async () => {
22+
if (!isCI()) {
23+
return [];
24+
}
25+
26+
const prNumber = +process.env.PR_NUMBER;
27+
if (!prNumber) {
28+
throw new Error("Missing PR_NUMBER");
29+
}
30+
31+
const repo = process.env.GH_REPO;
32+
if (typeof repo !== "string" || !repo.includes("/")) {
33+
throw new Error("Missing GH_REPO");
34+
}
35+
36+
const diffResponse = await fetch(
37+
`https://patch-diff.githubusercontent.com/raw/${repo}/pull/${prNumber}.diff`
38+
);
39+
const diffText = await diffResponse.text();
40+
const fileMatches = [
41+
...diffText.matchAll(/^(?:---|\+\+\+) [ab]\/(.+)$/gm),
42+
].map((match) => match[1]);
43+
44+
return new Set(fileMatches);
45+
};
46+
47+
/**
48+
* @typedef Annotation
49+
* @property {'notice'|'warning'|'error'} type
50+
* @property {string} file Absolute path to file or relative from repository root
51+
* @property {string} title
52+
* @property {string} message
53+
* @property {number} [line] 1-indexed
54+
* @property {number} [col] 1-indexed
55+
* @property {number} [endLine] 1-indexed
56+
* @property {number} [endCol] 1-indexed
57+
*/
58+
59+
/**
60+
* @param {Annotation} annotation
61+
*/
62+
export const createAnnotation = (annotation) => {
63+
const rootDir = pathUtil.join(import.meta.dirname, "..");
64+
const relativeFileName = pathUtil.relative(rootDir, annotation.file);
65+
66+
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
67+
68+
let output = "";
69+
output += `::${annotation.type} `;
70+
output += `file=${relativeFileName}`;
71+
72+
// Documentation says line number is not required, but in practice that gets interpreted as
73+
// line 0 and ends up not showing up. So, we'll default to line 1.
74+
if (typeof annotation.line === "number") {
75+
output += `,line=${annotation.line}`;
76+
} else {
77+
output += ",line=1";
78+
}
79+
80+
// These are all actually optional.
81+
if (typeof annotation.col === "number") {
82+
output += `,col=${annotation.col}`;
83+
}
84+
if (typeof annotation.endLine === "number") {
85+
output += `,endLine=${annotation.endLine}`;
86+
}
87+
if (typeof annotation.endCol === "number") {
88+
output += `,endCol=${annotation.endCol}`;
89+
}
90+
91+
output += `,title=${annotation.title}::`;
92+
output += annotation.message;
93+
94+
console.log(output);
95+
};

development/validate.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import pathUtil from "node:path";
12
import Builder from "./builder.js";
3+
import { createAnnotation, isCI } from "./ci-interop.js";
24
import * as Colors from "./colors.js";
35

46
const builder = new Builder("production");
57
const errors = await builder.validate();
68

9+
const rootDir = pathUtil.join(import.meta.dirname, "..");
10+
711
if (errors.length === 0) {
812
console.log(
913
`${Colors.GREEN}${Colors.BOLD}Validation checks passed.${Colors.RESET}`
@@ -15,10 +19,25 @@ if (errors.length === 0) {
1519
errors.length === 1 ? "file" : "files"
1620
} failed validation.${Colors.RESET}`
1721
);
22+
1823
console.error("");
19-
for (const { fileName, error } of errors) {
20-
console.error(`${Colors.BOLD}${fileName}${Colors.RESET}: ${error}`);
24+
25+
for (const { outputFileName, inputFilePath, error } of errors) {
26+
const displayName = inputFilePath
27+
? pathUtil.relative(rootDir, inputFilePath)
28+
: outputFileName;
29+
console.error(`${Colors.BOLD}${displayName}${Colors.RESET}: ${error}`);
30+
31+
if (isCI() && inputFilePath) {
32+
createAnnotation({
33+
type: "error",
34+
file: inputFilePath,
35+
title: "Validation error",
36+
message: error,
37+
});
38+
}
2139
}
22-
console.error(``);
40+
41+
console.error("");
2342
process.exit(1);
2443
}

package-lock.json

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"devDependencies": {
4444
"@transifex/api": "^7.1.5",
4545
"prettier": "^3.6.0",
46-
"spdx-expression-parse": "^4.0.0"
46+
"spdx-expression-parse": "^4.0.0",
47+
"typescript": "^5.9.3"
4748
},
4849
"private": true
4950
}

0 commit comments

Comments
 (0)