argusqa-os 9.7.6 → 9.8.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Shared resilient GitHub REST client for the PR Validator (Phase E2).
3
+ *
4
+ * One place that BOTH GitHub throw-path callers route through — fetchPrFiles
5
+ * (pr-diff-analyzer.js, the PR diff) and ghFetch (github-reporter.js, the PR
6
+ * comment / Check Run) — so the two paths can never diverge on resilience.
7
+ * Mirrors the "one shared decision" discipline of decidePrBlock (B1) /
8
+ * selectAnalyzers (D2) / resolveTargetUrl (D3) / routeResilienceFromEnv (D4).
9
+ *
10
+ * What it does:
11
+ * - retries a 403 PRIMARY rate-limit (X-RateLimit-Remaining: 0), a 403/429
12
+ * SECONDARY rate-limit (Retry-After), a transient 5xx, and a network error,
13
+ * with backoff that honours Retry-After / X-RateLimit-Reset when present;
14
+ * - NEVER retries a 401 / 404 / 422 / plain-403 (permissions) — those are
15
+ * terminal and throw a structured, cause-carrying error immediately;
16
+ * - keeps every thrown message SECRET-FREE: the token rides only in request
17
+ * headers (never the response body), and scrubSecrets() defensively redacts
18
+ * any Bearer / ghp_ / github_pat_ token that somehow appears in a body.
19
+ *
20
+ * deploy-preview.js's Deployments probe is deliberately NOT routed through here:
21
+ * it is fail-safe-to-null + opt-in (D3) and must degrade fast, not retry.
22
+ *
23
+ * Pure helpers + one fetch wrapper. No Chrome, no MCP, no AI verdict. Imported
24
+ * (transitively) by the MCP server → nothing here writes to stdout.
25
+ */
26
+
27
+ /** Default per-request timeout (ms) — matches the prior ghFetch behaviour. */
28
+ const DEFAULT_TIMEOUT_MS = 15000;
29
+ /** Default backoff base (ms) for the exponential fallback. */
30
+ const DEFAULT_BASE_MS = 1000;
31
+ /**
32
+ * Cap on any single backoff wait (ms). A far-future X-RateLimit-Reset (the GitHub
33
+ * primary-limit window can be up to an hour) must NOT hang CI for the whole window —
34
+ * we cap, retry sooner, and fail loud if still limited. A capped retry that throws a
35
+ * clear "rate limit exceeded — retries exhausted" beats a multi-minute silent stall.
36
+ */
37
+ const DEFAULT_MAX_MS = 8000;
38
+ /** Default total attempts (1 initial + 2 retries). */
39
+ const DEFAULT_MAX_ATTEMPTS = 3;
40
+
41
+ /**
42
+ * Read a header case-insensitively from a Headers object (fetch Response.headers,
43
+ * which exposes .get()) OR a plain object (used by test stubs). Returns the string
44
+ * value or undefined — never throws on a missing/odd headers bag.
45
+ *
46
+ * @param {Headers|Record<string,string>|undefined} headers
47
+ * @param {string} name
48
+ * @returns {string|undefined}
49
+ */
50
+ export function headerValue(headers, name) {
51
+ if (!headers) return undefined;
52
+ const key = String(name).toLowerCase();
53
+ if (typeof headers.get === 'function') {
54
+ return headers.get(name) ?? headers.get(key) ?? undefined;
55
+ }
56
+ for (const k of Object.keys(headers)) {
57
+ if (k.toLowerCase() === key) return headers[k];
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /**
63
+ * Redact any GitHub token shape from a string. The token is sent only in request
64
+ * headers, so a response body never contains it — this is belt-and-suspenders so a
65
+ * thrown error message can NEVER leak a credential even if a body echoes one.
66
+ *
67
+ * @param {*} text
68
+ * @returns {string}
69
+ */
70
+ export function scrubSecrets(text) {
71
+ return String(text ?? '')
72
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer ***')
73
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '***') // fine-grained PAT (has underscores)
74
+ .replace(/\bgh[posru]_[A-Za-z0-9_]{10,}/g, '***'); // ghp_ gho_ ghs_ ghr_ ghu_
75
+ }
76
+
77
+ /**
78
+ * Classify a response as a GitHub rate-limit (so it should be retried with backoff).
79
+ * - 429 → always a rate-limit (secondary).
80
+ * - 403 with X-RateLimit-Remaining === 0 → primary rate-limit exhausted.
81
+ * - 403 with a Retry-After header → secondary rate-limit.
82
+ * A plain 403 with no rate-limit signal is a PERMISSIONS error — not retried.
83
+ *
84
+ * @param {number} status
85
+ * @param {Headers|Record<string,string>|undefined} headers
86
+ * @returns {boolean}
87
+ */
88
+ export function isRateLimitResponse(status, headers) {
89
+ if (status === 429) return true;
90
+ if (status === 403) {
91
+ const remaining = headerValue(headers, 'x-ratelimit-remaining');
92
+ if (remaining !== undefined && remaining !== null && Number(remaining) === 0) return true;
93
+ if (headerValue(headers, 'retry-after') !== undefined) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Compute the backoff (ms) before the next attempt. Honours GitHub's explicit
100
+ * signals first (Retry-After seconds, then X-RateLimit-Reset epoch seconds),
101
+ * falling back to exponential backoff. Every result is capped at maxMs.
102
+ *
103
+ * @param {number} status HTTP status (0 for a network error)
104
+ * @param {Headers|Record<string,string>|undefined} headers
105
+ * @param {number} attempt 1-based attempt number that just failed
106
+ * @param {{ baseMs?: number, maxMs?: number, now?: () => number }} [opts]
107
+ * @returns {number}
108
+ */
109
+ export function retryDelayMs(status, headers, attempt, opts = {}) {
110
+ const { baseMs = DEFAULT_BASE_MS, maxMs = DEFAULT_MAX_MS, now = Date.now } = opts;
111
+
112
+ const retryAfter = headerValue(headers, 'retry-after');
113
+ if (retryAfter !== undefined && retryAfter !== null && String(retryAfter).trim() !== '') {
114
+ const secs = Number(retryAfter);
115
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, maxMs);
116
+ }
117
+
118
+ if (isRateLimitResponse(status, headers)) {
119
+ const reset = headerValue(headers, 'x-ratelimit-reset');
120
+ if (reset !== undefined) {
121
+ const resetMs = Number(reset) * 1000 - now();
122
+ if (Number.isFinite(resetMs) && resetMs > 0) return Math.min(resetMs, maxMs);
123
+ }
124
+ }
125
+
126
+ const exp = baseMs * Math.pow(2, Math.max(0, attempt - 1));
127
+ return Math.min(exp, maxMs);
128
+ }
129
+
130
+ /**
131
+ * Build a structured, secret-free error message for a TERMINAL (non-retried)
132
+ * GitHub response. Carries the cause (status + a short, scrubbed body) and a
133
+ * human hint for the common cases — never the token, never "is not defined".
134
+ *
135
+ * @param {number} status
136
+ * @param {string} [statusText]
137
+ * @param {string} [bodyText]
138
+ * @param {string} [context] e.g. "GET /repos/o/r/pulls/7/files"
139
+ * @returns {string}
140
+ */
141
+ export function classifyGitHubError(status, statusText, bodyText, context) {
142
+ const ctx = context ? ` (${context})` : '';
143
+ const body = scrubSecrets(String(bodyText ?? '').replace(/\s+/g, ' ').trim()).slice(0, 300);
144
+ const tail = body ? `: ${body}` : (statusText ? `: ${statusText}` : '');
145
+ switch (status) {
146
+ case 401:
147
+ return `GitHub API 401 (bad credentials)${ctx} — the token is missing, invalid, or expired${tail}`;
148
+ case 403:
149
+ return `GitHub API 403 (forbidden)${ctx} — the token lacks the required scope or resource access${tail}`;
150
+ case 404:
151
+ return `GitHub API 404 (not found)${ctx} — check the repository and PR exist and the token can access them${tail}`;
152
+ case 422:
153
+ return `GitHub API 422 (unprocessable)${ctx} — GitHub rejected the request${tail}`;
154
+ default:
155
+ return `GitHub API ${status}${ctx}${tail}`;
156
+ }
157
+ }
158
+
159
+ async function safeText(res) {
160
+ try {
161
+ return res && typeof res.text === 'function' ? await res.text() : '';
162
+ } catch {
163
+ return '';
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Resilient GitHub REST request. Resolves to the OK Response (caller reads
169
+ * .json() / paginates) or THROWS a structured, secret-free Error. See the module
170
+ * header for the retry vs throw policy.
171
+ *
172
+ * @param {string} url
173
+ * @param {object} [opts]
174
+ * @param {string} [opts.method='GET']
175
+ * @param {Record<string,string>} [opts.headers]
176
+ * @param {string} [opts.body] already-serialised body (or undefined)
177
+ * @param {number} [opts.maxAttempts=3]
178
+ * @param {number} [opts.baseMs=1000]
179
+ * @param {number} [opts.maxMs=8000]
180
+ * @param {number} [opts.timeoutMs=15000] per-attempt timeout; <=0 disables
181
+ * @param {(ms:number)=>Promise<void>} [opts.sleep] injectable for tests
182
+ * @param {()=>number} [opts.now] injectable clock for backoff math
183
+ * @param {(url:string,init:object)=>Promise<Response>} [opts.fetchImpl]
184
+ * @param {string} [opts.context] label woven into error messages
185
+ * @returns {Promise<Response>}
186
+ */
187
+ export async function githubFetch(url, opts = {}) {
188
+ const {
189
+ method = 'GET', headers, body,
190
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
191
+ baseMs = DEFAULT_BASE_MS, maxMs = DEFAULT_MAX_MS, timeoutMs = DEFAULT_TIMEOUT_MS,
192
+ sleep = (ms) => new Promise(r => setTimeout(r, ms)),
193
+ now = Date.now,
194
+ fetchImpl,
195
+ context,
196
+ } = opts;
197
+
198
+ const doFetch = fetchImpl ?? ((u, init) => fetch(u, init));
199
+ const ctx = context ? ` (${context})` : '';
200
+
201
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
202
+ let res;
203
+ try {
204
+ res = await doFetch(url, {
205
+ method, headers, body,
206
+ signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
207
+ });
208
+ } catch (err) {
209
+ // Network error / timeout — retry with backoff, else fail loud (secret-free).
210
+ if (attempt < maxAttempts) {
211
+ await sleep(retryDelayMs(0, null, attempt, { baseMs, maxMs, now }));
212
+ continue;
213
+ }
214
+ throw new Error(scrubSecrets(`GitHub API request failed${ctx}: ${err.message}`));
215
+ }
216
+
217
+ if (res.ok) return res;
218
+
219
+ const status = res.status;
220
+ const rateLimited = isRateLimitResponse(status, res.headers);
221
+ const transient = rateLimited || status >= 500;
222
+
223
+ if (transient && attempt < maxAttempts) {
224
+ await sleep(retryDelayMs(status, res.headers, attempt, { baseMs, maxMs, now }));
225
+ continue;
226
+ }
227
+
228
+ // Terminal: read the body once and throw a structured, secret-free error.
229
+ const bodyText = await safeText(res);
230
+ if (rateLimited) {
231
+ const body = scrubSecrets(String(bodyText ?? '').replace(/\s+/g, ' ').trim()).slice(0, 300);
232
+ throw new Error(
233
+ `GitHub API ${status} (rate limit exceeded)${ctx} — retries exhausted after ${attempt} attempt(s)` +
234
+ (body ? `: ${body}` : ''),
235
+ );
236
+ }
237
+ throw new Error(classifyGitHubError(status, res.statusText, bodyText, context));
238
+ }
239
+
240
+ // Defensive — the loop above always returns or throws.
241
+ throw new Error(`GitHub API request failed${ctx} — retries exhausted`);
242
+ }
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import { childLogger } from './logger.js';
30
+ import { parsePrUrl } from './pr-diff-analyzer.js';
31
+ import { githubFetch } from './github-api.js';
30
32
 
31
33
  const logger = childLogger('github-reporter');
32
34
 
@@ -44,6 +46,45 @@ function mdCell(text, maxLen = 100) {
44
46
  return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' '); // lgtm[js/incomplete-string-escaping] — escaping pipe and newline is correct and sufficient for GitHub Markdown table cells
45
47
  }
46
48
 
49
+ /**
50
+ * Build the PR-Validator banner lines (block verdict + reason + affected routes).
51
+ * Rendered at the top of the comment only when report.prValidation is present, so
52
+ * existing runCrawl()-sourced reports are unaffected.
53
+ */
54
+ function prValidationBanner(pv) {
55
+ const bl = pv.baseline;
56
+ const baselineAware = !!(bl && bl.available);
57
+ const lines = [
58
+ pv.blocked
59
+ ? `> 🔴 **Merge blocked** — ${pv.reason} (block-on: \`${pv.blockOn}\`)`
60
+ : baselineAware
61
+ ? `> ✅ **Merge allowed** — this PR introduces no findings at or above the \`${pv.blockOn}\` threshold`
62
+ : `> ✅ **Merge allowed** — no findings at or above the \`${pv.blockOn}\` threshold`,
63
+ ];
64
+ // Baseline-aware surfacing (Phase B2): what this PR INTRODUCES vs what already existed on the
65
+ // affected routes, so a reviewer sees why the merge was (or wasn't) blocked. When no per-branch
66
+ // baseline was available the decision fell back to absolute counts — say so, never silently.
67
+ if (baselineAware) {
68
+ lines.push(
69
+ '',
70
+ 'Blocking on findings this PR **introduces** (vs the base-branch baseline): ',
71
+ `🔴 ${bl.newCritical} new critical · 🟡 ${bl.newWarning} new warning · 🔵 ${bl.newInfo} new info · ${bl.persisting} persisting · ${bl.resolved} resolved `,
72
+ );
73
+ } else if (bl && bl.available === false) {
74
+ lines.push('', `> ⚠️ ${bl.note ?? 'Baseline unavailable — blocking on absolute finding counts.'} `);
75
+ }
76
+ if (Array.isArray(pv.affectedRoutes) && pv.affectedRoutes.length > 0) {
77
+ const shown = pv.affectedRoutes.slice(0, 20).map(r => `\`${r}\``).join(', ');
78
+ const extra = pv.affectedRoutes.length > 20 ? ` _(+${pv.affectedRoutes.length - 20} more)_` : '';
79
+ lines.push('', `**Affected routes** (${pv.affectedRoutes.length}): ${shown}${extra} `);
80
+ }
81
+ if (typeof pv.changedFileCount === 'number') {
82
+ lines.push(`**Files changed**: ${pv.changedFileCount} `);
83
+ }
84
+ lines.push('');
85
+ return lines;
86
+ }
87
+
47
88
  // ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
48
89
 
49
90
  /**
@@ -89,6 +130,7 @@ export function formatPrComment(report, diff) {
89
130
  `**Base URL**: ${baseUrl} `,
90
131
  `**Run time**: ${runDate} `,
91
132
  '',
133
+ ...(report.prValidation ? prValidationBanner(report.prValidation) : []),
92
134
  '| | 🔴 Critical | 🟡 Warning | 🔵 Info | Total |',
93
135
  '|---|---|---|---|---|',
94
136
  `| **Total** | ${summary.critical} | ${summary.warning} | ${summary.info} | ${summary.total} |`,
@@ -217,7 +259,7 @@ export function buildStatusPayload(report, diff) {
217
259
 
218
260
  // ── GitHub API helper ─────────────────────────────────────────────────────────
219
261
 
220
- async function ghFetch(urlPath, method, body, attempt = 1) {
262
+ async function ghFetch(urlPath, method, body) {
221
263
  if (!process.env.GITHUB_TOKEN) {
222
264
  throw new Error('GITHUB_TOKEN environment variable is not set — GitHub reporting is disabled');
223
265
  }
@@ -228,33 +270,16 @@ async function ghFetch(urlPath, method, body, attempt = 1) {
228
270
  };
229
271
  if (body) headers['Content-Type'] = 'application/json';
230
272
 
231
- let res;
232
- try {
233
- res = await fetch(`${GITHUB_API}${urlPath}`, {
234
- method,
235
- headers,
236
- body: body ? JSON.stringify(body) : undefined,
237
- signal: AbortSignal.timeout(15000),
238
- });
239
- } catch (err) {
240
- // Network error or timeout — retry up to 3 times with exponential backoff
241
- if (attempt < 3) {
242
- await new Promise(r => setTimeout(r, attempt * 1000));
243
- return ghFetch(urlPath, method, body, attempt + 1);
244
- }
245
- throw err;
246
- }
247
-
248
- // Retry on transient server errors (5xx) and rate-limit (429) with exponential backoff
249
- if ((res.status >= 500 || res.status === 429) && attempt < 3) {
250
- await new Promise(r => setTimeout(r, attempt * 1000));
251
- return ghFetch(urlPath, method, body, attempt + 1);
252
- }
253
-
254
- if (!res.ok) {
255
- const text = await res.text().catch(() => '');
256
- throw new Error(`GitHub API ${method} ${urlPath} → ${res.status}: ${text.slice(0, 200)}`);
257
- }
273
+ // E2: shared resilient client — retries a rate-limit (403 primary / 429 secondary)
274
+ // + transient 5xx + network error with backoff (Retry-After / X-RateLimit-Reset
275
+ // aware), throws a structured, secret-free error on 401/404/422/plain-403. The token
276
+ // rides only in the request headers above, never in any thrown message.
277
+ const res = await githubFetch(`${GITHUB_API}${urlPath}`, {
278
+ method,
279
+ headers,
280
+ body: body ? JSON.stringify(body) : undefined,
281
+ context: `${method} ${urlPath}`,
282
+ });
258
283
  return res.json();
259
284
  }
260
285
 
@@ -264,9 +289,9 @@ async function ghFetch(urlPath, method, body, attempt = 1) {
264
289
  * Create a PR comment, or update the existing Argus comment if one is already present.
265
290
  * Idempotent: re-running on the same PR updates in-place rather than spamming new comments.
266
291
  */
267
- export async function postPrComment(report, diff) {
268
- const repo = process.env.GITHUB_REPOSITORY;
269
- const prNum = process.env.GITHUB_PR_NUMBER;
292
+ export async function postPrComment(report, diff, opts = {}) {
293
+ const repo = opts.repo ?? process.env.GITHUB_REPOSITORY;
294
+ const prNum = opts.prNumber ?? process.env.GITHUB_PR_NUMBER;
270
295
  if (!repo || !prNum) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY or GITHUB_PR_NUMBER not set');
271
296
 
272
297
  let body = formatPrComment(report, diff);
@@ -322,10 +347,14 @@ export async function setCommitStatus(report, diff) {
322
347
  *
323
348
  * @param {string} [name] - Check run name (default: GITHUB_CHECK_NAME ?? 'argus-qa')
324
349
  * @param {string} [sha] - Commit SHA (default: GITHUB_SHA env var)
350
+ * @param {object} [opts]
351
+ * @param {string} [opts.repo] - "owner/repo" override (default: GITHUB_REPOSITORY env var).
352
+ * Lets callers that resolved the repo from a PR URL (the PR
353
+ * Validator) drive the Check Run without relying on env.
325
354
  * @returns {Promise<number>} check run id
326
355
  */
327
- export async function createCheckRun(name, sha) {
328
- const repo = process.env.GITHUB_REPOSITORY;
356
+ export async function createCheckRun(name, sha, opts = {}) {
357
+ const repo = opts.repo ?? process.env.GITHUB_REPOSITORY;
329
358
  const headSha = sha ?? process.env.GITHUB_SHA;
330
359
  if (!repo || !headSha) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY or GITHUB_SHA not set');
331
360
 
@@ -352,13 +381,30 @@ export async function createCheckRun(name, sha) {
352
381
  * @param {number} checkRunId - id from createCheckRun()
353
382
  * @param {object} report - runCrawl() report
354
383
  * @param {object|null} diff - baseline diff (null = first run)
384
+ * @param {object} [opts]
385
+ * @param {string} [opts.repo] - "owner/repo" override (default: GITHUB_REPOSITORY env var)
355
386
  */
356
- export async function completeCheckRun(checkRunId, report, diff) {
357
- const repo = process.env.GITHUB_REPOSITORY;
387
+ export async function completeCheckRun(checkRunId, report, diff, opts = {}) {
388
+ const repo = opts.repo ?? process.env.GITHUB_REPOSITORY;
358
389
  if (!repo) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY not set');
359
390
 
360
- const status = buildStatusPayload(report, diff);
361
- const conclusion = status.state === 'success' ? 'success' : 'failure';
391
+ // The conclusion must reflect the merge gate. For a PR-Validator report the authoritative
392
+ // gate is the block-on decision (report.prValidation.blocked), NOT buildStatusPayload's
393
+ // new-criticals-vs-ARGUS_CRITICAL_THRESHOLD rule — the two diverge (e.g. block-on=warning
394
+ // with 0 criticals blocks the merge but has 0 new criticals). runCrawl reports carry no
395
+ // prValidation field, so they keep the existing threshold-based conclusion.
396
+ let conclusion, title;
397
+ const pv = report.prValidation;
398
+ if (pv) {
399
+ conclusion = pv.blocked ? 'failure' : 'success';
400
+ title = pv.blocked
401
+ ? `Argus: merge blocked — ${pv.reason}`
402
+ : `Argus: merge allowed — no findings at or above block-on=${pv.blockOn}`;
403
+ } else {
404
+ const status = buildStatusPayload(report, diff);
405
+ conclusion = status.state === 'success' ? 'success' : 'failure';
406
+ title = status.description;
407
+ }
362
408
 
363
409
  // Build rich text output (full findings table, without the COMMENT_MARKER sentinel)
364
410
  const fullBody = formatPrComment(report, diff);
@@ -371,8 +417,8 @@ export async function completeCheckRun(checkRunId, report, diff) {
371
417
  conclusion,
372
418
  completed_at: new Date().toISOString(),
373
419
  output: {
374
- title: status.description,
375
- summary: status.description,
420
+ title: title.slice(0, 255), // GitHub Check output.title limit
421
+ summary: title,
376
422
  text: richText,
377
423
  },
378
424
  });
@@ -511,3 +557,169 @@ export async function reportToGitHub(report, diff) {
511
557
 
512
558
  await Promise.all(tasks);
513
559
  }
560
+
561
+ // ── PR Validator reporting (Phase A) ──────────────────────────────────────────
562
+
563
+ /**
564
+ * Adapt a PR-Validator result (the src/cli/pr-validate.js + argus_pr_validate response
565
+ * shape) into the `report` object that formatPrComment / buildStatusPayload consume.
566
+ * Pure — no I/O.
567
+ *
568
+ * The PR Validator has no per-run baseline yet (Phase B introduces head-vs-base
569
+ * diffing), so every finding on an affected route is surfaced as-is; callers pair this
570
+ * with a NON-first `diff` (see reportPrValidation) so formatPrComment renders the
571
+ * findings table rather than treating the run as a baseline-establishing first run.
572
+ *
573
+ * @param {object} result
574
+ * @param {string} [result.targetUrl]
575
+ * @param {{ critical: number, warning: number, info: number }} [result.summary]
576
+ * @param {Array<{ severity: string, type: string, message: string, url: string }>} [result.findings]
577
+ * @param {string[]} [result.affectedRoutes]
578
+ * @param {string[]} [result.changedFiles]
579
+ * @param {boolean} [result.blocked]
580
+ * @param {string} [result.blockOn]
581
+ * @returns {object} report consumable by formatPrComment
582
+ */
583
+ export function prResultToReport(result = {}) {
584
+ const {
585
+ targetUrl,
586
+ summary = { critical: 0, warning: 0, info: 0 },
587
+ findings = [],
588
+ affectedRoutes = [],
589
+ changedFiles = [],
590
+ blocked = false,
591
+ blockOn = 'critical',
592
+ baseline, // B2: { available, newCritical, newWarning, newInfo, persisting, resolved } | { available:false, note }
593
+ } = result;
594
+
595
+ const base = String(targetUrl ?? '').replace(/\/$/, '');
596
+
597
+ // Group findings by their route path (derived from each finding's url), so the
598
+ // comment's findings table is sourced per-route — formatPrComment uses
599
+ // report.routes[].route as the display source label for each finding.
600
+ const byRoute = new Map();
601
+ for (const f of findings) {
602
+ const url = String(f.url ?? '');
603
+ let label = url || '(unknown route)';
604
+ if (base && url.startsWith(base)) label = url.slice(base.length) || '/';
605
+ if (!byRoute.has(label)) byRoute.set(label, []);
606
+ byRoute.get(label).push(f);
607
+ }
608
+ const routes = [...byRoute.entries()].map(([route, errors]) => ({
609
+ route, errors, screenshot: null,
610
+ }));
611
+
612
+ const crit = summary.critical ?? 0;
613
+ const warn = summary.warning ?? 0;
614
+ const info = summary.info ?? 0;
615
+
616
+ // The block reason must reflect what the decision actually counted, so the banner reconciles
617
+ // with `blocked` (Phase B2): the NEW (PR-introduced) counts when a baseline was available, the
618
+ // absolute counts otherwise. The scope word ("new"/"total") matches decidePrBlock's phrasing;
619
+ // it is omitted entirely for legacy callers that pass no baseline field (back-compat).
620
+ const blPresent = !!(baseline && typeof baseline === 'object');
621
+ const blAvail = !!(blPresent && baseline.available);
622
+ const rCrit = blAvail ? (baseline.newCritical ?? 0) : crit;
623
+ const rWarn = blAvail ? (baseline.newWarning ?? 0) : warn;
624
+ const scope = !blPresent ? '' : blAvail ? 'new ' : 'total ';
625
+ const reason = !blocked ? null
626
+ : blockOn === 'warning'
627
+ ? `${rCrit} critical + ${rWarn} warning ${scope}finding(s) at or above the block threshold`
628
+ : `${rCrit} critical ${scope}finding(s) found`;
629
+
630
+ return {
631
+ baseUrl: base || String(targetUrl ?? ''),
632
+ generatedAt: new Date().toISOString(),
633
+ summary: { critical: crit, warning: warn, info, total: crit + warn + info },
634
+ routes,
635
+ codebase: [],
636
+ flows: [],
637
+ prValidation: {
638
+ blocked,
639
+ blockOn,
640
+ reason,
641
+ affectedRoutes: affectedRoutes
642
+ .map(r => (typeof r === 'string' ? r : r?.path))
643
+ .filter(Boolean),
644
+ changedFileCount: Array.isArray(changedFiles) ? changedFiles.length : 0,
645
+ baseline: baseline ?? null,
646
+ },
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Post (or idempotently update) the single Argus PR comment for a PR-Validator run.
652
+ *
653
+ * Gated on GITHUB_TOKEN plus a resolvable owner/repo + PR number — taken from the
654
+ * GITHUB_REPOSITORY / GITHUB_PR_NUMBER env vars (set by the GitHub runner) or, failing
655
+ * that, parsed from the PR URL. A missing token or unresolvable PR context SKIPS
656
+ * reporting (returns a status) rather than throwing: a reporting misconfiguration must
657
+ * never crash or block the validation step. The GitHub token is never echoed into the
658
+ * return value, logs, or thrown errors (it rides only in the Authorization header).
659
+ *
660
+ * Idempotent: postPrComment finds the existing Argus comment by its HTML marker and
661
+ * PATCHes it in place, so re-running on the same PR updates rather than duplicates.
662
+ *
663
+ * In addition to the comment (A1), a GitHub Check Run is created + completed (A2) when a
664
+ * PR head SHA is resolvable (ARGUS_PR_HEAD_SHA — set by action.yml to
665
+ * github.event.pull_request.head.sha — or GITHUB_SHA). Its conclusion maps to the block
666
+ * decision (failure iff blocked). The Check Run is best-effort and isolated: a failure
667
+ * there never discards an already-posted comment and never changes the merge decision.
668
+ *
669
+ * @param {object} result - PR-validate result (see prResultToReport)
670
+ * @param {object} [opts]
671
+ * @param {string} [opts.prUrl] - PR URL used to derive owner/repo/prNumber when env vars are absent
672
+ * @returns {Promise<{ posted: boolean, checked: boolean, skipped: boolean, reason?: string }>}
673
+ */
674
+ export async function reportPrValidation(result, { prUrl } = {}) {
675
+ if (!process.env.GITHUB_TOKEN) {
676
+ return { posted: false, checked: false, skipped: true, reason: 'GITHUB_TOKEN not set — PR reporting skipped' };
677
+ }
678
+
679
+ let repo = process.env.GITHUB_REPOSITORY;
680
+ let prNumber = process.env.GITHUB_PR_NUMBER;
681
+ const url = prUrl ?? result?.prUrl;
682
+ if ((!repo || !prNumber) && url) {
683
+ try {
684
+ const { owner, repo: r, prNumber: n } = parsePrUrl(url);
685
+ repo = repo || `${owner}/${r}`;
686
+ prNumber = prNumber || n;
687
+ } catch { /* unparseable URL — fall through to the skip below */ }
688
+ }
689
+ if (!repo || !prNumber) {
690
+ return { posted: false, checked: false, skipped: true, reason: 'no resolvable repo / PR number — PR reporting skipped' };
691
+ }
692
+
693
+ const report = prResultToReport(result);
694
+ // Non-first diff so findings render (not treated as a baseline-establishing first run). The
695
+ // new/persisting split rides on each finding's `isNew` tag (set in the PR-validate paths via
696
+ // tagFindingNovelty); `resolvedCount` comes from the head-vs-base diff (Phase B2) so the
697
+ // comment's Resolved row reconciles with the block decision. 0 when no baseline was available.
698
+ const diff = {
699
+ isFirstRun: false,
700
+ resolvedCount: (result && result.baseline && result.baseline.available) ? (result.baseline.resolved ?? 0) : 0,
701
+ flowResolvedCount: 0,
702
+ };
703
+
704
+ // A1 — idempotent PR comment (the primary visible surface). A failure here propagates to
705
+ // the caller, which logs a ::warning:: and leaves the already-computed merge decision intact.
706
+ await postPrComment(report, diff, { repo, prNumber });
707
+
708
+ // A2 — Check Run whose conclusion maps to the block decision. Gated on a resolvable PR
709
+ // head SHA; isolated so a Check Run failure can't discard the posted comment.
710
+ let checked = false;
711
+ let checkError;
712
+ const headSha = process.env.ARGUS_PR_HEAD_SHA || process.env.GITHUB_SHA;
713
+ if (headSha) {
714
+ try {
715
+ const checkId = await createCheckRun(undefined, headSha, { repo });
716
+ await completeCheckRun(checkId, report, diff, { repo });
717
+ checked = true;
718
+ } catch (err) {
719
+ checkError = err.message;
720
+ logger.warn(`[ARGUS] C2: PR Check Run failed — ${err.message}`);
721
+ }
722
+ }
723
+
724
+ return { posted: true, checked, skipped: false, ...(checkError ? { reason: `check run failed: ${checkError}` } : {}) };
725
+ }