argusqa-os 9.7.6 → 9.8.1
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 +10 -10
- package/glama.json +2 -2
- package/package.json +8 -8
- package/src/cli/init.js +3 -1
- package/src/cli/pr-validate.js +275 -56
- package/src/mcp-server.js +142 -26
- package/src/orchestration/crawl-and-report.js +1 -1
- package/src/orchestration/orchestrator.js +34 -0
- package/src/utils/audit-depth.js +148 -0
- package/src/utils/deploy-preview.js +210 -0
- package/src/utils/github-api.js +242 -0
- package/src/utils/github-reporter.js +251 -39
- package/src/utils/html-reporter.js +283 -92
- package/src/utils/import-graph.js +296 -0
- package/src/utils/parallel-crawler.js +202 -0
- package/src/utils/pr-baseline.js +230 -0
- package/src/utils/pr-diff-analyzer.js +376 -40
- package/src/utils/route-discoverer.js +25 -3
- package/src/utils/session-persistence.js +6 -1
|
@@ -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
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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:
|
|
375
|
-
summary:
|
|
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
|
+
}
|