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.
Files changed (98) hide show
  1. package/README.md +61 -11
  2. package/cspell.json +3 -8
  3. package/dist/cli.js +37 -64
  4. package/dist/config/constants.d.ts +21 -3
  5. package/dist/config/constants.js +33 -6
  6. package/dist/config/pr-checkmate-workflow.yml +33 -327
  7. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  8. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  9. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  10. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  11. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  13. package/dist/core/checks/findings.d.ts +198 -19
  14. package/dist/core/checks/findings.js +474 -22
  15. package/dist/core/checks/git/custom-rules.js +21 -16
  16. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  17. package/dist/core/checks/git/diff-smell.js +43 -24
  18. package/dist/core/checks/git/large-files.js +14 -2
  19. package/dist/core/checks/git/leftover-debug.js +23 -14
  20. package/dist/core/checks/git/lockfile-drift.js +2 -1
  21. package/dist/core/checks/git/merge-conflict.js +19 -8
  22. package/dist/core/checks/git/missing-tests.js +22 -6
  23. package/dist/core/checks/git/todo-fixme.js +10 -9
  24. package/dist/core/checks/languages/shellcheck.d.ts +1 -1
  25. package/dist/core/checks/languages/shellcheck.js +1 -1
  26. package/dist/core/checks/languages/typecheck.js +1 -0
  27. package/dist/core/checks/pr/pr-size-check.js +2 -1
  28. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  29. package/dist/core/checks/quality/config-validation.js +119 -17
  30. package/dist/core/checks/quality/dead-code.js +1 -0
  31. package/dist/core/checks/quality/duplicate-check.js +1 -0
  32. package/dist/core/checks/quality/license-header.js +12 -2
  33. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  34. package/dist/core/checks/quality/spellcheck.js +141 -13
  35. package/dist/core/checks/quality/yaml-lint.js +22 -9
  36. package/dist/core/checks/security/diff-security.js +111 -28
  37. package/dist/core/checks/security/dockerfile-security.js +23 -20
  38. package/dist/core/checks/security/migration-safety.js +19 -17
  39. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  40. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  41. package/dist/core/checks/security/workflow-security.js +48 -26
  42. package/dist/core/commands.d.ts +27 -0
  43. package/dist/core/commands.js +91 -0
  44. package/dist/core/context.d.ts +12 -3
  45. package/dist/core/context.js +118 -16
  46. package/dist/core/install/cache.d.ts +39 -0
  47. package/dist/core/install/cache.js +99 -0
  48. package/dist/core/install/doctor.d.ts +35 -0
  49. package/dist/core/install/doctor.js +145 -0
  50. package/dist/core/install/ensure.d.ts +60 -0
  51. package/dist/core/install/ensure.js +169 -0
  52. package/dist/core/install/lock.d.ts +33 -0
  53. package/dist/core/install/lock.js +99 -0
  54. package/dist/core/install/profiles.d.ts +64 -0
  55. package/dist/core/install/profiles.js +170 -0
  56. package/dist/core/reporters.d.ts +13 -2
  57. package/dist/core/reporters.js +145 -104
  58. package/dist/core/run-checks.js +91 -14
  59. package/dist/core/targets.js +26 -4
  60. package/dist/core/types.d.ts +49 -1
  61. package/dist/init.d.ts +29 -14
  62. package/dist/init.js +204 -262
  63. package/dist/interfaces/index.d.ts +120 -0
  64. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  65. package/dist/scripts/generate-install-manifests.js +121 -0
  66. package/dist/scripts/publish-github.js +18 -4
  67. package/dist/utils/detect-language.d.ts +37 -4
  68. package/dist/utils/detect-language.js +113 -19
  69. package/dist/utils/http.d.ts +59 -0
  70. package/dist/utils/http.js +119 -0
  71. package/dist/utils/logger.d.ts +52 -6
  72. package/dist/utils/logger.js +125 -33
  73. package/dist/utils/pool.d.ts +49 -0
  74. package/dist/utils/pool.js +124 -0
  75. package/dist/utils/update-notifier.d.ts +9 -0
  76. package/dist/utils/update-notifier.js +57 -3
  77. package/eslint.config.mjs +14 -2
  78. package/install-manifests/commitlint/package-lock.json +1028 -0
  79. package/install-manifests/commitlint/package.json +9 -0
  80. package/install-manifests/core/package-lock.json +1335 -0
  81. package/install-manifests/core/package.json +11 -0
  82. package/install-manifests/cpp/package-lock.json +29 -0
  83. package/install-manifests/cpp/package.json +8 -0
  84. package/install-manifests/duplicate/package-lock.json +113 -0
  85. package/install-manifests/duplicate/package.json +8 -0
  86. package/install-manifests/js/package-lock.json +5099 -0
  87. package/install-manifests/js/package.json +26 -0
  88. package/install-manifests/markdown/package-lock.json +1281 -0
  89. package/install-manifests/markdown/package.json +8 -0
  90. package/install-manifests/python/package-lock.json +21 -0
  91. package/install-manifests/python/package.json +8 -0
  92. package/install-manifests/spellcheck/package-lock.json +1047 -0
  93. package/install-manifests/spellcheck/package.json +8 -0
  94. package/package.json +13 -28
  95. package/pr-checkmate.schema.json +1062 -104
  96. package/src/config/cspell.json +3 -8
  97. package/src/config/pr-checkmate-workflow.yml +33 -327
  98. 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
+ }
@@ -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<void>;
18
+ export declare function report(kind: ReporterKind, result: CheckReport, cfg?: ReporterConfig): Promise<PostOutcome>;
@@ -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
- function buildMarkdownReport(report) {
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 rows = report.results
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
- rows,
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
- function printStdout(report) {
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.info('');
62
- utils_1.logger.info(SUMMARY_BORDER);
63
- utils_1.logger.info(' PR CheckMate Summary');
64
- utils_1.logger.info(SUMMARY_BORDER);
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.info(` ${STATUS_ICON[r.status]} ${r.name}${details}`);
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.info(SUMMARY_RULE);
70
- utils_1.logger.info(` ✅ ${passed} passed ⚠️ ${warned} warnings ❌ ${failed} failed ⏭️ ${skipped} skipped`);
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
- async function upsertGithubComment(body) {
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
- try {
90
- const listRes = await fetch(`${apiBase}/issues/${prNumber}/comments`, { headers });
91
- if (listRes.ok) {
92
- const comments = (await listRes.json());
93
- const existing = comments.find(c => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER));
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 (find our marker → update, else create). Uses the
117
- * standard CI variables plus a `GITLAB_TOKEN` with `api` scope. Skips gracefully
118
- * when not in an MR pipeline or the token is missing.
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
- try {
132
- const listRes = await fetch(notes, { headers });
133
- if (listRes.ok) {
134
- const existing = (await listRes.json()).find(n => typeof n.body === 'string' && n.body.includes(COMMENT_MARKER));
135
- if (existing) {
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 (find our marker → update, else create). Uses
154
- * Bitbucket Pipelines variables plus a `BITBUCKET_TOKEN` (repo/PR write). Skips
155
- * gracefully when not in a PR pipeline or the token is missing.
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
- try {
168
- const listRes = await fetch(`${commentsUrl}?pagelen=100`, { headers });
169
- if (listRes.ok) {
170
- const values = (await listRes.json()).values;
171
- const existing = values?.find(c => c.content?.raw?.includes(COMMENT_MARKER));
172
- if (existing) {
173
- await fetch(`${commentsUrl}/${existing.id}`, {
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
- catch (err) {
190
- utils_1.logger.warn(`[reporter]: ⚠️ Could not post Bitbucket comment: ${err}`);
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
- await postToPlatform(platform, result);
264
+ if (!platform)
265
+ return SKIPPED;
266
+ return postToPlatform(platform, result, cfg);
226
267
  }