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.
- package/README.md +10 -9
- package/glama.json +2 -2
- package/package.json +12 -4
- package/src/adapters/browser.js +13 -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 +64 -13
- 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 +290 -0
- package/src/utils/issues-analyzer.js +8 -2
- package/src/utils/lighthouse-checker.js +44 -4
- package/src/utils/parallel-crawler.js +202 -0
- package/src/utils/pr-baseline.js +230 -0
- package/src/utils/pr-diff-analyzer.js +378 -40
- package/src/utils/route-discoverer.js +25 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-preview URL auto-detection (PR Validator D3).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the audit TARGET URL for a PR run, preferring a per-PR deploy preview
|
|
5
|
+
* (Vercel / Netlify / any GitHub Deployment) over the static TARGET_DEV_URL, and ALWAYS
|
|
6
|
+
* degrading gracefully to TARGET_DEV_URL when no preview is found or any detection step
|
|
7
|
+
* fails. Detection never throws and never blocks the run — a wrong/failed/missing preview
|
|
8
|
+
* must never silently audit the wrong app, so only a SUCCESS deploy-status URL for the PR's
|
|
9
|
+
* head SHA is ever adopted; everything else falls back.
|
|
10
|
+
*
|
|
11
|
+
* Resolution precedence (resolveTargetUrl):
|
|
12
|
+
* 1. explicitTarget — an explicit per-call target (MCP tool `targetUrl` arg). Highest;
|
|
13
|
+
* passed through raw (explicit caller intent, like TARGET_DEV_URL).
|
|
14
|
+
* 2. ARGUS_PREVIEW_URL — explicit env override (opt-in by being set). Provider env vars
|
|
15
|
+
* DEPLOY_PRIME_URL (Netlify) + VERCEL_URL (Vercel, bare host) are
|
|
16
|
+
* also recognized for convenience.
|
|
17
|
+
* 3. auto-detected preview from the PR head SHA's GitHub Deployments — OPT-IN:
|
|
18
|
+
* ARGUS_PREVIEW_DETECT truthy + a token + a head SHA + a parseable PR URL. One+one
|
|
19
|
+
* GitHub API call, fully fail-safe (any error → no preview → fallback).
|
|
20
|
+
* 4. TARGET_DEV_URL (or http://localhost:3000) — the conservative fallback, byte-identical
|
|
21
|
+
* to the pre-D3 behaviour when nothing above matches.
|
|
22
|
+
*
|
|
23
|
+
* Pure helpers (Chrome-free, network-free) + one fail-safe async fetch. Imported (transitively)
|
|
24
|
+
* by the MCP server → nothing here writes to stdout (logs go to the injected/childLogger).
|
|
25
|
+
* No AI verdict — pure static/heuristic resolution (OSS side of the argus-pro boundary).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { parsePrUrl } from './pr-diff-analyzer.js';
|
|
29
|
+
import { childLogger } from './logger.js';
|
|
30
|
+
|
|
31
|
+
const logger = childLogger('deploy-preview');
|
|
32
|
+
|
|
33
|
+
const GITHUB_API = 'https://api.github.com';
|
|
34
|
+
const FETCH_TIMEOUT = 10000;
|
|
35
|
+
|
|
36
|
+
// Env vars (priority order) that may carry an explicit preview URL. ARGUS_PREVIEW_URL is the
|
|
37
|
+
// portable, Argus-namespaced canonical; the rest are provider conventions surfaced for users
|
|
38
|
+
// who forward them into the runner. VERCEL_URL is a bare host (no scheme) — normalized below.
|
|
39
|
+
const ENV_PREVIEW_VARS = ['ARGUS_PREVIEW_URL', 'DEPLOY_PRIME_URL', 'VERCEL_URL'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Trim a candidate URL and accept it only if it carries an http(s) scheme.
|
|
43
|
+
* Returns the trimmed URL or null (never throws). Untrusted/auto-detected URLs flow through
|
|
44
|
+
* this; explicit caller targets (the MCP arg, TARGET_DEV_URL) are passed through raw.
|
|
45
|
+
* @param {*} raw
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeUrl(raw) {
|
|
49
|
+
if (raw == null) return null;
|
|
50
|
+
const s = String(raw).trim();
|
|
51
|
+
if (!s) return null;
|
|
52
|
+
return /^https?:\/\//i.test(s) ? s : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pick an explicit preview URL from the environment, honouring ENV_PREVIEW_VARS priority.
|
|
57
|
+
* VERCEL_URL is a bare host → prefixed with https:// before validation.
|
|
58
|
+
* @param {Record<string,string|undefined>} env
|
|
59
|
+
* @returns {{ url: string, source: string }|null}
|
|
60
|
+
*/
|
|
61
|
+
export function pickPreviewFromEnv(env = {}) {
|
|
62
|
+
for (const name of ENV_PREVIEW_VARS) {
|
|
63
|
+
let raw = env[name];
|
|
64
|
+
if (raw == null || String(raw).trim() === '') continue;
|
|
65
|
+
// VERCEL_URL is conventionally a bare host (e.g. my-app-git-pr.vercel.app).
|
|
66
|
+
if (name === 'VERCEL_URL' && !/^https?:\/\//i.test(String(raw).trim())) {
|
|
67
|
+
raw = `https://${String(raw).trim()}`;
|
|
68
|
+
}
|
|
69
|
+
const url = normalizeUrl(raw);
|
|
70
|
+
if (url) return { url, source: `env:${name}` };
|
|
71
|
+
logger.warn(`[ARGUS] D3: ${name} is set but not a valid http(s) URL — ignoring`);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Heuristic: is a GitHub Deployment object a PR/preview deployment (not production)?
|
|
78
|
+
* Recognizes Vercel ("Preview – <project>"), Netlify ("deploy-preview"), and any explicitly
|
|
79
|
+
* non-production / transient environment. Production deployments are excluded.
|
|
80
|
+
* @param {object} deployment
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
export function isPreviewDeployment(deployment) {
|
|
84
|
+
if (!deployment || typeof deployment !== 'object') return false;
|
|
85
|
+
const env = String(deployment.environment ?? '');
|
|
86
|
+
// Explicit production → never a preview target.
|
|
87
|
+
if (deployment.production_environment === true || /production/i.test(env)) return false;
|
|
88
|
+
return (
|
|
89
|
+
/preview|deploy[\s-]?preview|staging/i.test(env) ||
|
|
90
|
+
deployment.transient_environment === true ||
|
|
91
|
+
deployment.production_environment === false
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* From a list of GitHub Deployments (newest-first, as the API returns them), pick the most
|
|
97
|
+
* recent preview deployment, or null when none qualify.
|
|
98
|
+
* @param {Array<object>} deployments
|
|
99
|
+
* @returns {object|null}
|
|
100
|
+
*/
|
|
101
|
+
export function pickPreviewDeployment(deployments) {
|
|
102
|
+
if (!Array.isArray(deployments)) return null;
|
|
103
|
+
return deployments.find(isPreviewDeployment) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* From a deployment's statuses (newest-first), return the live preview URL — the
|
|
108
|
+
* environment_url (preferred) or target_url of the most recent SUCCESS status — or null.
|
|
109
|
+
* Only a `success` status is adopted: a failed / error / pending / inactive deploy must
|
|
110
|
+
* never become the audit target (auditing a broken or stale preview would be a false result).
|
|
111
|
+
* @param {Array<object>} statuses
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
export function previewUrlFromStatuses(statuses) {
|
|
115
|
+
if (!Array.isArray(statuses)) return null;
|
|
116
|
+
for (const s of statuses) {
|
|
117
|
+
if (!s || s.state !== 'success') continue;
|
|
118
|
+
const url = normalizeUrl(s.environment_url) ?? normalizeUrl(s.target_url);
|
|
119
|
+
if (url) return url;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function ghGet(url, headers) {
|
|
125
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
126
|
+
if (!res.ok) return null;
|
|
127
|
+
return res.json();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fetch the live preview URL for a PR head SHA via the GitHub Deployments API. Fully
|
|
132
|
+
* fail-safe: ANY failure (no SHA, non-2xx, network error, no preview deployment, no success
|
|
133
|
+
* status) resolves to null so the caller degrades to TARGET_DEV_URL. Never throws.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} opts
|
|
136
|
+
* @param {string} opts.owner
|
|
137
|
+
* @param {string} opts.repo
|
|
138
|
+
* @param {string} opts.sha PR head SHA (NOT the merge commit)
|
|
139
|
+
* @param {string} [opts.token] GitHub token
|
|
140
|
+
* @returns {Promise<string|null>}
|
|
141
|
+
*/
|
|
142
|
+
export async function fetchPreviewUrlFromDeployments({ owner, repo, sha, token } = {}) {
|
|
143
|
+
try {
|
|
144
|
+
if (!owner || !repo || !sha) return null;
|
|
145
|
+
const headers = {
|
|
146
|
+
Accept: 'application/vnd.github+json',
|
|
147
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
148
|
+
'User-Agent': 'argusqa-os',
|
|
149
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
150
|
+
};
|
|
151
|
+
const deployments = await ghGet(
|
|
152
|
+
`${GITHUB_API}/repos/${owner}/${repo}/deployments?sha=${encodeURIComponent(sha)}&per_page=30`,
|
|
153
|
+
headers,
|
|
154
|
+
);
|
|
155
|
+
const dep = pickPreviewDeployment(deployments);
|
|
156
|
+
if (!dep) return null;
|
|
157
|
+
const statuses = await ghGet(
|
|
158
|
+
`${GITHUB_API}/repos/${owner}/${repo}/deployments/${dep.id}/statuses?per_page=30`,
|
|
159
|
+
headers,
|
|
160
|
+
);
|
|
161
|
+
return previewUrlFromStatuses(statuses);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// Detection is best-effort — a probe failure must degrade to TARGET_DEV_URL, never break
|
|
164
|
+
// the run. The token never rides into the message (err.message is GitHub's text only).
|
|
165
|
+
logger.warn(`[ARGUS] D3: deploy-preview detection failed — ${err.message}`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the audit target URL for a PR run (D3). See the module header for precedence.
|
|
172
|
+
* Always resolves (never throws); `source` records which rung won, for logging/visibility.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} opts
|
|
175
|
+
* @param {Record<string,string|undefined>} opts.env the process env (or a test object)
|
|
176
|
+
* @param {string} [opts.explicitTarget] an explicit per-call target (MCP `targetUrl` arg)
|
|
177
|
+
* @param {string} [opts.prUrl]
|
|
178
|
+
* @param {string} [opts.headSha]
|
|
179
|
+
* @param {string} [opts.token]
|
|
180
|
+
* @returns {Promise<{ url: string, source: string }>}
|
|
181
|
+
*/
|
|
182
|
+
export async function resolveTargetUrl({ env = {}, explicitTarget, prUrl, headSha, token } = {}) {
|
|
183
|
+
// The conservative fallback — passed through RAW (explicit operator intent), so the default
|
|
184
|
+
// path is byte-identical to the pre-D3 `TARGET_DEV_URL ?? 'http://localhost:3000'`.
|
|
185
|
+
const fallback = env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
186
|
+
|
|
187
|
+
// 1. Explicit per-call target (MCP arg) — raw, highest precedence.
|
|
188
|
+
if (explicitTarget != null && String(explicitTarget).trim() !== '') {
|
|
189
|
+
return { url: explicitTarget, source: 'explicit' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 2. Explicit env override (ARGUS_PREVIEW_URL / provider env vars).
|
|
193
|
+
const envPick = pickPreviewFromEnv(env);
|
|
194
|
+
if (envPick) return envPick;
|
|
195
|
+
|
|
196
|
+
// 3. Auto-detect from GitHub Deployments — opt-in + fully fail-safe.
|
|
197
|
+
const detectOn = /^(1|true|yes|on)$/i.test(env.ARGUS_PREVIEW_DETECT || '');
|
|
198
|
+
if (detectOn && token && headSha && prUrl) {
|
|
199
|
+
try {
|
|
200
|
+
const { owner, repo } = parsePrUrl(prUrl);
|
|
201
|
+
const url = await fetchPreviewUrlFromDeployments({ owner, repo, sha: headSha, token });
|
|
202
|
+
if (url) return { url, source: 'deployment' };
|
|
203
|
+
} catch {
|
|
204
|
+
// parsePrUrl or detection threw — degrade silently to the fallback below.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. Conservative fallback.
|
|
209
|
+
return { url: fallback, source: 'target-dev-url' };
|
|
210
|
+
}
|
|
@@ -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
|
+
}
|