pr-checkmate 1.23.24 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -11
- package/cspell.json +3 -8
- package/dist/cli.js +37 -64
- package/dist/config/constants.d.ts +21 -3
- package/dist/config/constants.js +33 -6
- package/dist/config/pr-checkmate-workflow.yml +33 -327
- package/dist/core/checks/dependencies/banned-imports.js +23 -24
- package/dist/core/checks/dependencies/circular-deps.js +1 -0
- package/dist/core/checks/dependencies/dependency-check.js +1 -0
- package/dist/core/checks/dependencies/grype-scan.js +1 -0
- package/dist/core/checks/dependencies/npm-audit.js +6 -1
- package/dist/core/checks/dependencies/vuln-scan.js +1 -0
- package/dist/core/checks/findings.d.ts +198 -19
- package/dist/core/checks/findings.js +474 -22
- package/dist/core/checks/git/custom-rules.js +21 -16
- package/dist/core/checks/git/diff-smell.d.ts +17 -0
- package/dist/core/checks/git/diff-smell.js +43 -24
- package/dist/core/checks/git/large-files.js +14 -2
- package/dist/core/checks/git/leftover-debug.js +23 -14
- package/dist/core/checks/git/lockfile-drift.js +2 -1
- package/dist/core/checks/git/merge-conflict.js +19 -8
- package/dist/core/checks/git/missing-tests.js +22 -6
- package/dist/core/checks/git/todo-fixme.js +10 -9
- package/dist/core/checks/languages/shellcheck.d.ts +1 -1
- package/dist/core/checks/languages/shellcheck.js +1 -1
- package/dist/core/checks/languages/typecheck.js +1 -0
- package/dist/core/checks/pr/pr-size-check.js +2 -1
- package/dist/core/checks/quality/config-validation.d.ts +14 -4
- package/dist/core/checks/quality/config-validation.js +119 -17
- package/dist/core/checks/quality/dead-code.js +1 -0
- package/dist/core/checks/quality/duplicate-check.js +1 -0
- package/dist/core/checks/quality/license-header.js +12 -2
- package/dist/core/checks/quality/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +111 -28
- package/dist/core/checks/security/dockerfile-security.js +23 -20
- package/dist/core/checks/security/migration-safety.js +19 -17
- package/dist/core/checks/security/sensitive-file-guard.js +37 -5
- package/dist/core/checks/security/workflow-security.d.ts +10 -0
- package/dist/core/checks/security/workflow-security.js +48 -26
- package/dist/core/commands.d.ts +27 -0
- package/dist/core/commands.js +91 -0
- package/dist/core/context.d.ts +12 -3
- package/dist/core/context.js +118 -16
- package/dist/core/install/cache.d.ts +39 -0
- package/dist/core/install/cache.js +99 -0
- package/dist/core/install/doctor.d.ts +35 -0
- package/dist/core/install/doctor.js +145 -0
- package/dist/core/install/ensure.d.ts +60 -0
- package/dist/core/install/ensure.js +169 -0
- package/dist/core/install/lock.d.ts +33 -0
- package/dist/core/install/lock.js +99 -0
- package/dist/core/install/profiles.d.ts +64 -0
- package/dist/core/install/profiles.js +170 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.js +91 -14
- package/dist/core/targets.js +26 -4
- package/dist/core/types.d.ts +49 -1
- package/dist/init.d.ts +29 -14
- package/dist/init.js +204 -262
- package/dist/interfaces/index.d.ts +120 -0
- package/dist/scripts/generate-install-manifests.d.ts +39 -0
- package/dist/scripts/generate-install-manifests.js +121 -0
- package/dist/scripts/publish-github.js +18 -4
- package/dist/utils/detect-language.d.ts +37 -4
- package/dist/utils/detect-language.js +113 -19
- package/dist/utils/http.d.ts +59 -0
- package/dist/utils/http.js +119 -0
- package/dist/utils/logger.d.ts +52 -6
- package/dist/utils/logger.js +125 -33
- package/dist/utils/pool.d.ts +49 -0
- package/dist/utils/pool.js +124 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.js +57 -3
- package/eslint.config.mjs +14 -2
- package/install-manifests/commitlint/package-lock.json +1028 -0
- package/install-manifests/commitlint/package.json +9 -0
- package/install-manifests/core/package-lock.json +1335 -0
- package/install-manifests/core/package.json +11 -0
- package/install-manifests/cpp/package-lock.json +29 -0
- package/install-manifests/cpp/package.json +8 -0
- package/install-manifests/duplicate/package-lock.json +113 -0
- package/install-manifests/duplicate/package.json +8 -0
- package/install-manifests/js/package-lock.json +5099 -0
- package/install-manifests/js/package.json +26 -0
- package/install-manifests/markdown/package-lock.json +1281 -0
- package/install-manifests/markdown/package.json +8 -0
- package/install-manifests/python/package-lock.json +21 -0
- package/install-manifests/python/package.json +8 -0
- package/install-manifests/spellcheck/package-lock.json +1047 -0
- package/install-manifests/spellcheck/package.json +8 -0
- package/package.json +13 -28
- package/pr-checkmate.schema.json +1062 -104
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- package/src/config/pr-checkmate.schema.json +1062 -104
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PRCheckMateConfig } from '../../interfaces';
|
|
2
|
+
import type { Language, LanguageUsage } from '../../utils/detect-language';
|
|
3
|
+
/**
|
|
4
|
+
* A named bundle of tooling, and the checks that stop working without it.
|
|
5
|
+
*
|
|
6
|
+
* The package ships ~233 MB of tooling, and no repository needs all of it: a
|
|
7
|
+
* Python service pays for the whole JavaScript toolchain, a repository with
|
|
8
|
+
* Spellcheck switched off still pays for cspell. Profiles are how that stops —
|
|
9
|
+
* they name which slice of the tooling a given repository actually needs.
|
|
10
|
+
*
|
|
11
|
+
* `checks` is not documentation. It is what turns a failed install into an
|
|
12
|
+
* honest report: when a profile is missing, exactly those checks skip, with the
|
|
13
|
+
* reason, instead of quietly disappearing (the mistake of ticket 057).
|
|
14
|
+
*/
|
|
15
|
+
interface Profile {
|
|
16
|
+
name: ProfileName;
|
|
17
|
+
/** Top-level packages installed for this profile. */
|
|
18
|
+
packages: string[];
|
|
19
|
+
/** Registry names of the checks that need it. */
|
|
20
|
+
checks: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Languages that make this profile necessary. Absent for profiles driven by
|
|
23
|
+
* enabled checks rather than by what the repository is written in.
|
|
24
|
+
*/
|
|
25
|
+
languages?: Language[];
|
|
26
|
+
}
|
|
27
|
+
export type ProfileName = 'core' | 'js' | 'python' | 'cpp' | 'spellcheck' | 'markdown' | 'commitlint' | 'duplicate';
|
|
28
|
+
/**
|
|
29
|
+
* The catalogue.
|
|
30
|
+
*
|
|
31
|
+
* Sizes in the comments are measured exclusive footprints — what leaves with the
|
|
32
|
+
* profile and is shared with nothing else (see ticket 069).
|
|
33
|
+
*/
|
|
34
|
+
export declare const PROFILES: Profile[];
|
|
35
|
+
/** The profile a check belongs to, or `undefined` when it needs no extra tooling. */
|
|
36
|
+
export declare function profileForCheck(check: string): Profile | undefined;
|
|
37
|
+
/** Why a profile is (or is not) part of this run — the answer `doctor` prints. */
|
|
38
|
+
export interface ProfileDecision {
|
|
39
|
+
profile: ProfileName;
|
|
40
|
+
required: boolean;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Decide which profiles this repository needs, and why.
|
|
45
|
+
*
|
|
46
|
+
* The two inputs are deliberately different in kind:
|
|
47
|
+
*
|
|
48
|
+
* - **Languages are counted in files, never in markers.** A Python service that
|
|
49
|
+
* installed us through npm has a `package.json`, which is a JavaScript marker
|
|
50
|
+
* — and downloading 152 MB of JavaScript tooling because of the file that
|
|
51
|
+
* installed us would be the whole feature backfiring. `LanguageUsage.files`
|
|
52
|
+
* exists for exactly this (ticket 068).
|
|
53
|
+
* - **Universal profiles are driven by enabled checks**, so `severity: {
|
|
54
|
+
* "Spellcheck": "off" }` starts saving bandwidth instead of only a report row.
|
|
55
|
+
*
|
|
56
|
+
* @param usage Detected languages with their tracked file counts.
|
|
57
|
+
* @param config The client's config; `install.profiles` and `install.excludeProfiles`
|
|
58
|
+
* have the last word, in that order — an explicit exclusion beats an explicit
|
|
59
|
+
* request, because it is the one that can be a hard requirement (air-gapped,
|
|
60
|
+
* licence policy, a runner that must not download a wasm binary).
|
|
61
|
+
* @returns One decision per profile, in catalogue order.
|
|
62
|
+
*/
|
|
63
|
+
export declare function planProfiles(usage: LanguageUsage[], config: PRCheckMateConfig): ProfileDecision[];
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PROFILES = void 0;
|
|
4
|
+
exports.profileForCheck = profileForCheck;
|
|
5
|
+
exports.planProfiles = planProfiles;
|
|
6
|
+
/**
|
|
7
|
+
* The catalogue.
|
|
8
|
+
*
|
|
9
|
+
* Sizes in the comments are measured exclusive footprints — what leaves with the
|
|
10
|
+
* profile and is shared with nothing else (see ticket 069).
|
|
11
|
+
*/
|
|
12
|
+
exports.PROFILES = [
|
|
13
|
+
{
|
|
14
|
+
// Always installed. Everything here backs a check that runs on any
|
|
15
|
+
// repository whatever it is written in, so there is nothing to decide.
|
|
16
|
+
name: 'core',
|
|
17
|
+
packages: ['execa', 'yaml', 'inquirer', 'gitleaks-secret-scanner-fixed'],
|
|
18
|
+
checks: ['Security Scan'],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
// 152 MB, the largest by far, and the one a Python repository pays for
|
|
22
|
+
// today with nothing to show for it.
|
|
23
|
+
name: 'js',
|
|
24
|
+
packages: [
|
|
25
|
+
'typescript',
|
|
26
|
+
'eslint',
|
|
27
|
+
'eslint-config-prettier',
|
|
28
|
+
'eslint-plugin-import-x',
|
|
29
|
+
'eslint-plugin-jsdoc',
|
|
30
|
+
'eslint-plugin-prettier',
|
|
31
|
+
'eslint-plugin-sonarjs',
|
|
32
|
+
'@typescript-eslint/eslint-plugin',
|
|
33
|
+
'@typescript-eslint/parser',
|
|
34
|
+
'prettier',
|
|
35
|
+
'dpdm',
|
|
36
|
+
'knip',
|
|
37
|
+
'ts-unused-exports',
|
|
38
|
+
'license-checker-rseidelsohn',
|
|
39
|
+
],
|
|
40
|
+
checks: [
|
|
41
|
+
'ESLint',
|
|
42
|
+
'Prettier',
|
|
43
|
+
'TypeScript',
|
|
44
|
+
'Circular Deps',
|
|
45
|
+
'Dependencies',
|
|
46
|
+
'Dead Code',
|
|
47
|
+
'License Check',
|
|
48
|
+
],
|
|
49
|
+
languages: ['typescript', 'javascript'],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'python',
|
|
53
|
+
packages: ['@astral-sh/ruff-wasm-nodejs'],
|
|
54
|
+
checks: ['Ruff Lint', 'Ruff Format'],
|
|
55
|
+
languages: ['python'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'cpp',
|
|
59
|
+
packages: ['@wasm-fmt/clang-format'],
|
|
60
|
+
checks: ['C++ Format'],
|
|
61
|
+
languages: ['cpp'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
// The four below are not languages. They are single checks with heavy
|
|
65
|
+
// tooling behind them, which is exactly why they are separate: switching
|
|
66
|
+
// one off should stop costing megabytes, not just a line in the report.
|
|
67
|
+
name: 'spellcheck',
|
|
68
|
+
packages: ['cspell'],
|
|
69
|
+
checks: ['Spellcheck'],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'markdown',
|
|
73
|
+
packages: ['markdownlint-cli2'],
|
|
74
|
+
checks: ['Markdown'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'commitlint',
|
|
78
|
+
packages: ['@commitlint/cli', '@commitlint/config-conventional'],
|
|
79
|
+
checks: ['Commitlint'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'duplicate',
|
|
83
|
+
packages: ['jscpd'],
|
|
84
|
+
checks: ['Duplicate Code'],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
/** The profile a check belongs to, or `undefined` when it needs no extra tooling. */
|
|
88
|
+
function profileForCheck(check) {
|
|
89
|
+
return exports.PROFILES.find(p => p.checks.includes(check));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A check counts as enabled unless it is switched off.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately asymmetric with how the runner selects checks: this must not
|
|
95
|
+
* import the registry (that would drag every check into the install path), and
|
|
96
|
+
* being wrong in the "enabled" direction only costs a download, while being
|
|
97
|
+
* wrong the other way silently removes a check. So anything not explicitly off
|
|
98
|
+
* is treated as on.
|
|
99
|
+
*/
|
|
100
|
+
function isCheckEnabled(check, config) {
|
|
101
|
+
return config.severity?.[check] !== 'off';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Decide which profiles this repository needs, and why.
|
|
105
|
+
*
|
|
106
|
+
* The two inputs are deliberately different in kind:
|
|
107
|
+
*
|
|
108
|
+
* - **Languages are counted in files, never in markers.** A Python service that
|
|
109
|
+
* installed us through npm has a `package.json`, which is a JavaScript marker
|
|
110
|
+
* — and downloading 152 MB of JavaScript tooling because of the file that
|
|
111
|
+
* installed us would be the whole feature backfiring. `LanguageUsage.files`
|
|
112
|
+
* exists for exactly this (ticket 068).
|
|
113
|
+
* - **Universal profiles are driven by enabled checks**, so `severity: {
|
|
114
|
+
* "Spellcheck": "off" }` starts saving bandwidth instead of only a report row.
|
|
115
|
+
*
|
|
116
|
+
* @param usage Detected languages with their tracked file counts.
|
|
117
|
+
* @param config The client's config; `install.profiles` and `install.excludeProfiles`
|
|
118
|
+
* have the last word, in that order — an explicit exclusion beats an explicit
|
|
119
|
+
* request, because it is the one that can be a hard requirement (air-gapped,
|
|
120
|
+
* licence policy, a runner that must not download a wasm binary).
|
|
121
|
+
* @returns One decision per profile, in catalogue order.
|
|
122
|
+
*/
|
|
123
|
+
function planProfiles(usage, config) {
|
|
124
|
+
const install = config.install ?? {};
|
|
125
|
+
const minFiles = Math.max(1, install.minFilesPerLanguage ?? 1);
|
|
126
|
+
const requested = new Set(install.profiles ?? []);
|
|
127
|
+
const excluded = new Set(install.excludeProfiles ?? []);
|
|
128
|
+
return exports.PROFILES.map(profile => {
|
|
129
|
+
const decision = decide(profile, usage, config, minFiles);
|
|
130
|
+
if (excluded.has(profile.name)) {
|
|
131
|
+
return {
|
|
132
|
+
profile: profile.name,
|
|
133
|
+
required: false,
|
|
134
|
+
reason: 'excluded by install.excludeProfiles',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (!decision.required && requested.has(profile.name)) {
|
|
138
|
+
return { profile: profile.name, required: true, reason: 'requested by install.profiles' };
|
|
139
|
+
}
|
|
140
|
+
return { profile: profile.name, required: decision.required, reason: decision.reason };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** The automatic half of {@link planProfiles}, before the client's overrides. */
|
|
144
|
+
function decide(profile, usage, config, minFiles) {
|
|
145
|
+
if (profile.name === 'core')
|
|
146
|
+
return { required: true, reason: 'always installed' };
|
|
147
|
+
if (profile.languages) {
|
|
148
|
+
const found = profile.languages
|
|
149
|
+
.map(lang => usage.find(u => u.lang === lang))
|
|
150
|
+
.filter((u) => Boolean(u) && u.files >= minFiles);
|
|
151
|
+
if (found.length > 0) {
|
|
152
|
+
const detail = found.map(u => `${u.files} ${u.lang} file(s)`).join(', ');
|
|
153
|
+
return { required: true, reason: detail };
|
|
154
|
+
}
|
|
155
|
+
// Named separately from "no files": a marker with no source files is the
|
|
156
|
+
// OctoBot case — a `Cargo.toml` beside zero `.rs`. The language is real
|
|
157
|
+
// enough to run checks for; it is not real enough to download a toolchain.
|
|
158
|
+
const markerOnly = profile.languages.some(lang => usage.some(u => u.lang === lang));
|
|
159
|
+
return {
|
|
160
|
+
required: false,
|
|
161
|
+
reason: markerOnly
|
|
162
|
+
? 'declared by a marker file but no source files tracked'
|
|
163
|
+
: 'no source files tracked',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const enabled = profile.checks.filter(check => isCheckEnabled(check, config));
|
|
167
|
+
return enabled.length > 0
|
|
168
|
+
? { required: true, reason: `${enabled.join(', ')} enabled` }
|
|
169
|
+
: { required: false, reason: `${profile.checks.join(', ')} switched off` };
|
|
170
|
+
}
|
package/dist/core/reporters.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import type { CheckReport, ReporterKind } from './types';
|
|
1
|
+
import type { CheckReport, ReporterConfig, ReporterKind } from './types';
|
|
2
|
+
/** Whether the comment reached the PR, so the caller can act on a failure. */
|
|
3
|
+
export interface PostOutcome {
|
|
4
|
+
attempted: boolean;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildMarkdownReport(report: CheckReport, cfg?: ReporterConfig): string;
|
|
2
9
|
/**
|
|
3
10
|
* Emit the report through the selected channel.
|
|
4
11
|
* none: nothing · stdout: console table · github/gitlab/bitbucket: table +
|
|
5
12
|
* upsert the PR/MR comment · auto: detect the platform from the environment.
|
|
13
|
+
*
|
|
14
|
+
* Returns whether the comment reached the PR, so `runChecks` can honour
|
|
15
|
+
* `reporter.onPostFailure` — for teams whose review process depends on the
|
|
16
|
+
* comment, a silently lost report is worse than a red build.
|
|
6
17
|
*/
|
|
7
|
-
export declare function report(kind: ReporterKind, result: CheckReport): Promise<
|
|
18
|
+
export declare function report(kind: ReporterKind, result: CheckReport, cfg?: ReporterConfig): Promise<PostOutcome>;
|
package/dist/core/reporters.js
CHANGED
|
@@ -3,9 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildMarkdownReport = buildMarkdownReport;
|
|
6
7
|
exports.report = report;
|
|
7
8
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
9
|
const utils_1 = require("../utils");
|
|
10
|
+
const http_1 = require("../utils/http");
|
|
9
11
|
const STATUS_ICON = {
|
|
10
12
|
pass: '✅',
|
|
11
13
|
warn: '⚠️',
|
|
@@ -16,6 +18,9 @@ const STATUS_ICON = {
|
|
|
16
18
|
const COMMENT_MARKER = '<!-- pr-checkmate-summary -->';
|
|
17
19
|
const SUMMARY_BORDER = '═══════════════════════════════════════════════════════════════';
|
|
18
20
|
const SUMMARY_RULE = '───────────────────────────────────────────────────────────────';
|
|
21
|
+
/** Page size asked for everywhere; both GitHub and GitLab cap at 100. */
|
|
22
|
+
const PER_PAGE = 100;
|
|
23
|
+
const SKIPPED = { attempted: false, ok: true };
|
|
19
24
|
function getPrContext() {
|
|
20
25
|
const token = process.env.GITHUB_TOKEN;
|
|
21
26
|
const repository = process.env.GITHUB_REPOSITORY;
|
|
@@ -35,14 +40,28 @@ function getPrContext() {
|
|
|
35
40
|
return null;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
|
-
|
|
43
|
+
/** Rows the client wants in the PR comment. */
|
|
44
|
+
function visibleResults(report, cfg) {
|
|
45
|
+
const include = cfg.include ?? {};
|
|
46
|
+
return report.results.filter(r => {
|
|
47
|
+
if (r.status === 'pass' && include.passed === false)
|
|
48
|
+
return false;
|
|
49
|
+
if (r.status === 'skip' && include.skipped === false)
|
|
50
|
+
return false;
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function buildMarkdownReport(report, cfg = {}) {
|
|
39
55
|
const { passed, warned, failed, skipped } = report.summary;
|
|
40
|
-
const
|
|
56
|
+
const include = cfg.include ?? {};
|
|
57
|
+
const rows = visibleResults(report, cfg);
|
|
58
|
+
const table = rows
|
|
41
59
|
.map(r => {
|
|
42
|
-
const details = r.details ? ` — ${r.details}` : '';
|
|
60
|
+
const details = r.details && include.details !== false ? ` — ${r.details}` : '';
|
|
43
61
|
return `| ${r.name} | ${STATUS_ICON[r.status]} ${r.status}${details} |`;
|
|
44
62
|
})
|
|
45
63
|
.join('\n');
|
|
64
|
+
const hidden = report.results.length - rows.length;
|
|
46
65
|
return [
|
|
47
66
|
COMMENT_MARKER,
|
|
48
67
|
'## PR CheckMate Summary',
|
|
@@ -51,144 +70,156 @@ function buildMarkdownReport(report) {
|
|
|
51
70
|
'',
|
|
52
71
|
'| Check | Result |',
|
|
53
72
|
'|---|---|',
|
|
54
|
-
|
|
73
|
+
table,
|
|
74
|
+
// Say so rather than letting the table quietly disagree with the counts above.
|
|
75
|
+
...(hidden > 0 ? ['', `_${hidden} row(s) hidden by \`reporter.include\`._`] : []),
|
|
55
76
|
'',
|
|
56
77
|
'_Generated by [PR CheckMate](https://www.npmjs.com/package/pr-checkmate)_',
|
|
57
78
|
].join('\n');
|
|
58
79
|
}
|
|
59
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The run summary, printed verbatim.
|
|
82
|
+
*
|
|
83
|
+
* `logger.print` rather than `logger.info`: this is the product's output, not a
|
|
84
|
+
* note about producing it. Sent through the log formatter, every line picked up
|
|
85
|
+
* an ISO timestamp and a level tag, which pushed the box borders out of
|
|
86
|
+
* alignment — the headline artefact of the whole run looked broken.
|
|
87
|
+
*/
|
|
88
|
+
function printStdout(report, cfg) {
|
|
60
89
|
const { passed, warned, failed, skipped } = report.summary;
|
|
61
|
-
utils_1.logger.
|
|
62
|
-
utils_1.logger.
|
|
63
|
-
utils_1.logger.
|
|
64
|
-
utils_1.logger.
|
|
90
|
+
utils_1.logger.print('');
|
|
91
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
92
|
+
utils_1.logger.print(' PR CheckMate Summary');
|
|
93
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
65
94
|
for (const r of report.results) {
|
|
66
95
|
const details = r.details ? `: ${r.details}` : '';
|
|
67
|
-
utils_1.logger.
|
|
96
|
+
utils_1.logger.print(` ${STATUS_ICON[r.status]} ${r.name}${details}`);
|
|
97
|
+
}
|
|
98
|
+
utils_1.logger.print(SUMMARY_RULE);
|
|
99
|
+
utils_1.logger.print(` ✅ ${passed} passed ⚠️ ${warned} warnings ❌ ${failed} failed ⏭️ ${skipped} skipped`);
|
|
100
|
+
utils_1.logger.print(SUMMARY_BORDER);
|
|
101
|
+
if (cfg.include?.docsLink !== false) {
|
|
102
|
+
// The docs cover every check and config key.
|
|
103
|
+
utils_1.logger.print(' DOCUMENTATION: https://pr-checkmate-docs.pages.dev/');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Network settings shared by every platform call. */
|
|
107
|
+
function httpOptions(cfg, label) {
|
|
108
|
+
return {
|
|
109
|
+
timeoutMs: cfg.timeoutMs,
|
|
110
|
+
retries: cfg.retries,
|
|
111
|
+
maxPages: cfg.maxPages,
|
|
112
|
+
label,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Log and shape the result of a write. */
|
|
116
|
+
function finish(platform, res, verb) {
|
|
117
|
+
if (res.ok) {
|
|
118
|
+
utils_1.logger.info(`[reporter]: ✅ ${verb} PR CheckMate summary on ${platform}`);
|
|
119
|
+
return { attempted: true, ok: true };
|
|
68
120
|
}
|
|
69
|
-
utils_1.logger.
|
|
70
|
-
|
|
71
|
-
utils_1.logger.info(SUMMARY_BORDER);
|
|
72
|
-
// Always shown under the report — the docs cover every check and config key.
|
|
73
|
-
utils_1.logger.info(' DOCUMENTATION: https://pr-checkmate-docs.pages.dev/');
|
|
121
|
+
utils_1.logger.warn(`[reporter]: ⚠️ Could not ${verb.toLowerCase()} ${platform} comment: ${res.error}`);
|
|
122
|
+
return { attempted: true, ok: false, error: res.error };
|
|
74
123
|
}
|
|
75
|
-
|
|
124
|
+
/** Page-number pagination: stop as soon as a page comes back short. */
|
|
125
|
+
function pageAfter(base, count, page) {
|
|
126
|
+
return count < PER_PAGE ? null : `${base}${base.includes('?') ? '&' : '?'}page=${page + 1}`;
|
|
127
|
+
}
|
|
128
|
+
async function upsertGithubComment(body, cfg) {
|
|
76
129
|
const ctx = getPrContext();
|
|
77
130
|
if (!ctx) {
|
|
78
131
|
utils_1.logger.info('[reporter]: Skipping GitHub comment (not in PR context or GITHUB_TOKEN missing)');
|
|
79
|
-
return;
|
|
132
|
+
return SKIPPED;
|
|
80
133
|
}
|
|
81
134
|
const { token, owner, repo, prNumber } = ctx;
|
|
82
135
|
const apiBase = `https://api.github.com/repos/${owner}/${repo}`;
|
|
136
|
+
const listBase = `${apiBase}/issues/${prNumber}/comments?per_page=${PER_PAGE}`;
|
|
83
137
|
const headers = {
|
|
84
138
|
Authorization: `Bearer ${token}`,
|
|
85
139
|
'User-Agent': 'pr-checkmate',
|
|
86
140
|
Accept: 'application/vnd.github.v3+json',
|
|
87
141
|
'Content-Type': 'application/json',
|
|
88
142
|
};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (existing) {
|
|
95
|
-
await fetch(`${apiBase}/issues/comments/${existing.id}`, {
|
|
96
|
-
method: 'PATCH',
|
|
97
|
-
headers,
|
|
98
|
-
body: JSON.stringify({ body }),
|
|
99
|
-
});
|
|
100
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate comment on PR');
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
await fetch(`${apiBase}/issues/${prNumber}/comments`, {
|
|
105
|
-
method: 'POST',
|
|
106
|
-
headers,
|
|
107
|
-
body: JSON.stringify({ body }),
|
|
108
|
-
});
|
|
109
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary comment to PR');
|
|
110
|
-
}
|
|
111
|
-
catch (err) {
|
|
112
|
-
utils_1.logger.warn(`[reporter]: ⚠️ Could not post GitHub comment: ${err}`);
|
|
143
|
+
const opts = httpOptions(cfg, 'GitHub');
|
|
144
|
+
const existing = await findExisting(listBase, { headers }, b => (Array.isArray(b) ? b : []), c => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER), opts);
|
|
145
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
146
|
+
const res = await (0, http_1.request)(`${apiBase}/issues/comments/${existing.reuse.id}`, { method: 'PATCH', headers, body: JSON.stringify({ body }) }, opts);
|
|
147
|
+
return finish('GitHub', res, 'Updated');
|
|
113
148
|
}
|
|
149
|
+
const res = await (0, http_1.request)(`${apiBase}/issues/${prNumber}/comments`, { method: 'POST', headers, body: JSON.stringify({ body }) }, opts);
|
|
150
|
+
return finish('GitHub', res, 'Posted');
|
|
114
151
|
}
|
|
115
152
|
/**
|
|
116
|
-
* Upsert a GitLab MR note
|
|
117
|
-
*
|
|
118
|
-
*
|
|
153
|
+
* Upsert a GitLab MR note. Uses the standard CI variables plus a `GITLAB_TOKEN`
|
|
154
|
+
* with `api` scope. Skips gracefully when not in an MR pipeline or the token is
|
|
155
|
+
* missing.
|
|
119
156
|
*/
|
|
120
|
-
async function upsertGitlabNote(body) {
|
|
157
|
+
async function upsertGitlabNote(body, cfg) {
|
|
121
158
|
const base = process.env.CI_API_V4_URL;
|
|
122
159
|
const project = process.env.CI_PROJECT_ID;
|
|
123
160
|
const iid = process.env.CI_MERGE_REQUEST_IID;
|
|
124
161
|
const token = process.env.GITLAB_TOKEN;
|
|
125
162
|
if (!base || !project || !iid || !token) {
|
|
126
163
|
utils_1.logger.info('[reporter]: Skipping GitLab note (not an MR pipeline or GITLAB_TOKEN missing)');
|
|
127
|
-
return;
|
|
164
|
+
return SKIPPED;
|
|
128
165
|
}
|
|
129
166
|
const notes = `${base}/projects/${project}/merge_requests/${iid}/notes`;
|
|
130
167
|
const headers = { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' };
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await fetch(`${notes}/${existing.id}`, {
|
|
137
|
-
method: 'PUT',
|
|
138
|
-
headers,
|
|
139
|
-
body: JSON.stringify({ body }),
|
|
140
|
-
});
|
|
141
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate note on GitLab MR');
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
await fetch(notes, { method: 'POST', headers, body: JSON.stringify({ body }) });
|
|
146
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary to GitLab MR');
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
utils_1.logger.warn(`[reporter]: ⚠️ Could not post GitLab note: ${err}`);
|
|
168
|
+
const opts = httpOptions(cfg, 'GitLab');
|
|
169
|
+
const existing = await findExisting(`${notes}?per_page=${PER_PAGE}`, { headers }, b => (Array.isArray(b) ? b : []), n => typeof n.body === 'string' && n.body.includes(COMMENT_MARKER), opts);
|
|
170
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
171
|
+
const res = await (0, http_1.request)(`${notes}/${existing.reuse.id}`, { method: 'PUT', headers, body: JSON.stringify({ body }) }, opts);
|
|
172
|
+
return finish('GitLab', res, 'Updated');
|
|
150
173
|
}
|
|
174
|
+
const res = await (0, http_1.request)(notes, { method: 'POST', headers, body: JSON.stringify({ body }) }, opts);
|
|
175
|
+
return finish('GitLab', res, 'Posted');
|
|
151
176
|
}
|
|
152
177
|
/**
|
|
153
|
-
* Upsert a Bitbucket PR comment
|
|
154
|
-
*
|
|
155
|
-
*
|
|
178
|
+
* Upsert a Bitbucket PR comment. Uses Bitbucket Pipelines variables plus a
|
|
179
|
+
* `BITBUCKET_TOKEN` (repo/PR write). Skips gracefully when not in a PR pipeline
|
|
180
|
+
* or the token is missing.
|
|
156
181
|
*/
|
|
157
|
-
async function upsertBitbucketComment(body) {
|
|
182
|
+
async function upsertBitbucketComment(body, cfg) {
|
|
158
183
|
const fullName = process.env.BITBUCKET_REPO_FULL_NAME;
|
|
159
184
|
const prId = process.env.BITBUCKET_PR_ID;
|
|
160
185
|
const token = process.env.BITBUCKET_TOKEN;
|
|
161
186
|
if (!fullName || !prId || !token) {
|
|
162
187
|
utils_1.logger.info('[reporter]: Skipping Bitbucket comment (not a PR pipeline or BITBUCKET_TOKEN missing)');
|
|
163
|
-
return;
|
|
188
|
+
return SKIPPED;
|
|
164
189
|
}
|
|
165
190
|
const commentsUrl = `https://api.bitbucket.org/2.0/repositories/${fullName}/pullrequests/${prId}/comments`;
|
|
166
191
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
method: 'PUT',
|
|
175
|
-
headers,
|
|
176
|
-
body: JSON.stringify({ content: { raw: body } }),
|
|
177
|
-
});
|
|
178
|
-
utils_1.logger.info('[reporter]: ✅ Updated PR CheckMate comment on Bitbucket PR');
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
await fetch(commentsUrl, {
|
|
183
|
-
method: 'POST',
|
|
184
|
-
headers,
|
|
185
|
-
body: JSON.stringify({ content: { raw: body } }),
|
|
186
|
-
});
|
|
187
|
-
utils_1.logger.info('[reporter]: ✅ Posted PR CheckMate summary to Bitbucket PR');
|
|
192
|
+
const opts = httpOptions(cfg, 'Bitbucket');
|
|
193
|
+
const existing = await findExisting(`${commentsUrl}?pagelen=${PER_PAGE}`, { headers }, b => b?.values ?? [], c => Boolean(c.content?.raw?.includes(COMMENT_MARKER)), opts,
|
|
194
|
+
// Bitbucket is cursor-paginated: the next page is a URL in the body.
|
|
195
|
+
b => b?.next ?? null);
|
|
196
|
+
if (existing.reuse && cfg.mode !== 'append') {
|
|
197
|
+
const res = await (0, http_1.request)(`${commentsUrl}/${existing.reuse.id}`, { method: 'PUT', headers, body: JSON.stringify({ content: { raw: body } }) }, opts);
|
|
198
|
+
return finish('Bitbucket', res, 'Updated');
|
|
188
199
|
}
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
const res = await (0, http_1.request)(commentsUrl, { method: 'POST', headers, body: JSON.stringify({ content: { raw: body } }) }, opts);
|
|
201
|
+
return finish('Bitbucket', res, 'Posted');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Search every page of a comment thread for our own marker.
|
|
205
|
+
*
|
|
206
|
+
* Reading only the first page is what turned the upsert into an insert: with 30
|
|
207
|
+
* comments per page on GitHub and 20 on GitLab, an actively reviewed PR pushed
|
|
208
|
+
* the marker past the end and each new push added another summary.
|
|
209
|
+
*/
|
|
210
|
+
async function findExisting(listUrl, init, extract, matches, opts, cursorNext) {
|
|
211
|
+
const base = listUrl;
|
|
212
|
+
const result = await (0, http_1.paginate)(listUrl, init, extract, (body, page, count) => (cursorNext ? cursorNext(body) : pageAfter(base, count, page)), opts);
|
|
213
|
+
if (!result.ok) {
|
|
214
|
+
// A failed search is not a failed post: fall through and create a comment.
|
|
215
|
+
// A duplicate is a far smaller problem than no report at all.
|
|
216
|
+
utils_1.logger.warn(`[reporter]: ⚠️ Could not read existing comments (${result.error}) — will create`);
|
|
191
217
|
}
|
|
218
|
+
if (result.truncated) {
|
|
219
|
+
utils_1.logger.warn('[reporter]: ⚠️ Stopped searching comments at the page limit — a duplicate summary is ' +
|
|
220
|
+
'possible (raise reporter.maxPages)');
|
|
221
|
+
}
|
|
222
|
+
return { reuse: result.items.find(matches) ?? null };
|
|
192
223
|
}
|
|
193
224
|
/** Detect the CI platform from the environment, or null when unknown/local. */
|
|
194
225
|
function detectPlatform() {
|
|
@@ -201,26 +232,36 @@ function detectPlatform() {
|
|
|
201
232
|
return null;
|
|
202
233
|
}
|
|
203
234
|
/** Post the markdown report to a specific platform's PR/MR. */
|
|
204
|
-
async function postToPlatform(platform, result) {
|
|
205
|
-
const body = buildMarkdownReport(result);
|
|
235
|
+
async function postToPlatform(platform, result, cfg) {
|
|
236
|
+
const body = buildMarkdownReport(result, cfg);
|
|
206
237
|
if (platform === 'github')
|
|
207
|
-
return upsertGithubComment(body);
|
|
238
|
+
return upsertGithubComment(body, cfg);
|
|
208
239
|
if (platform === 'gitlab')
|
|
209
|
-
return upsertGitlabNote(body);
|
|
210
|
-
return upsertBitbucketComment(body);
|
|
240
|
+
return upsertGitlabNote(body, cfg);
|
|
241
|
+
return upsertBitbucketComment(body, cfg);
|
|
211
242
|
}
|
|
212
243
|
/**
|
|
213
244
|
* Emit the report through the selected channel.
|
|
214
245
|
* none: nothing · stdout: console table · github/gitlab/bitbucket: table +
|
|
215
246
|
* upsert the PR/MR comment · auto: detect the platform from the environment.
|
|
247
|
+
*
|
|
248
|
+
* Returns whether the comment reached the PR, so `runChecks` can honour
|
|
249
|
+
* `reporter.onPostFailure` — for teams whose review process depends on the
|
|
250
|
+
* comment, a silently lost report is worse than a red build.
|
|
216
251
|
*/
|
|
217
|
-
async function report(kind, result) {
|
|
252
|
+
async function report(kind, result, cfg = {}) {
|
|
218
253
|
if (kind === 'none')
|
|
219
|
-
return;
|
|
220
|
-
printStdout(result);
|
|
254
|
+
return SKIPPED;
|
|
255
|
+
printStdout(result, cfg);
|
|
221
256
|
if (result.results.length === 0)
|
|
222
|
-
return;
|
|
257
|
+
return SKIPPED;
|
|
258
|
+
// Nothing to say and nothing wrong: don't notify every PR subscriber.
|
|
259
|
+
if (cfg.skipWhenClean && result.summary.failed === 0 && result.summary.warned === 0) {
|
|
260
|
+
utils_1.logger.info('[reporter]: Nothing to report (skipWhenClean)');
|
|
261
|
+
return SKIPPED;
|
|
262
|
+
}
|
|
223
263
|
const platform = kind === 'auto' ? detectPlatform() : kind === 'stdout' ? null : kind;
|
|
224
|
-
if (platform)
|
|
225
|
-
|
|
264
|
+
if (!platform)
|
|
265
|
+
return SKIPPED;
|
|
266
|
+
return postToPlatform(platform, result, cfg);
|
|
226
267
|
}
|