argusqa-os 9.7.5 โ†’ 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.
@@ -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
+ }