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
package/src/mcp-server.js
CHANGED
|
@@ -29,14 +29,22 @@ import { createRequire } from 'module';
|
|
|
29
29
|
import { createMcpClient } from './utils/mcp-client.js';
|
|
30
30
|
import { childLogger } from './utils/logger.js';
|
|
31
31
|
import { parseListPagesResponse } from './utils/mcp-parsers.js';
|
|
32
|
-
import {
|
|
32
|
+
import { crawlRouteWithDepth, runCrawl } from './orchestration/crawl-and-report.js';
|
|
33
|
+
import { resolveAuditDepth, selectAnalyzers } from './utils/audit-depth.js';
|
|
33
34
|
import { runComparison } from './orchestration/env-comparison.js';
|
|
34
35
|
import { WatchSession } from './orchestration/watch-mode.js';
|
|
35
36
|
import { CdpBrowserAdapter } from './adapters/browser.js';
|
|
36
37
|
import { getFigmaFrame } from './adapters/figma.js';
|
|
37
38
|
import { analyzeDesignFidelity } from './utils/design-fidelity-analyzer.js';
|
|
38
39
|
import { analyzeVisualRegression } from './utils/visual-diff-analyzer.js';
|
|
39
|
-
import { fetchPrFiles,
|
|
40
|
+
import { fetchPrFiles, mapFilesToRoutesDeep } from './utils/pr-diff-analyzer.js';
|
|
41
|
+
import { resolveTargetUrl } from './utils/deploy-preview.js';
|
|
42
|
+
import { mapWithConcurrency, auditRouteWithRetry, routeResilienceFromEnv } from './utils/parallel-crawler.js';
|
|
43
|
+
import { reportPrValidation } from './utils/github-reporter.js';
|
|
44
|
+
import { getCurrentBranch } from './utils/baseline-manager.js';
|
|
45
|
+
import {
|
|
46
|
+
decidePrBlock, resolvePrBaselineFile, loadPrBaseline, savePrBaseline, tagFindingNovelty, severityTally,
|
|
47
|
+
} from './utils/pr-baseline.js';
|
|
40
48
|
|
|
41
49
|
const logger = childLogger('mcp-server');
|
|
42
50
|
|
|
@@ -158,7 +166,7 @@ const TOOLS = [
|
|
|
158
166
|
},
|
|
159
167
|
{
|
|
160
168
|
name: 'argus_pr_validate',
|
|
161
|
-
description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise), and audits only those routes — faster than a full scan and focused on what the PR actually touched. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn }. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
|
|
169
|
+
description: 'Runs a targeted Argus audit on the routes affected by a GitHub pull request. Fetches the PR diff, maps changed files to routes in your target config using path-slug heuristics (infrastructure changes trigger a full audit; targeted otherwise) — or, when ARGUS_SOURCE_DIR points at the checked-out app source, framework-aware import-graph mapping that narrows a changed component or stylesheet to only the routes whose pages import it (Next.js + monorepo-aware, conservative-fallback on any ambiguity) — and audits only those routes — faster than a full scan and focused on what the PR actually touched. The audit target is resolved per-PR: an explicit targetUrl, else the PR\'s deploy-preview URL (ARGUS_PREVIEW_URL or opt-in GitHub-Deployments auto-detection), else TARGET_DEV_URL. Routes are audited with bounded concurrency (ARGUS_CONCURRENCY) and each route audit is timeout-bounded (ARGUS_ROUTE_TIMEOUT_MS) so a hung audit blocks rather than silently passing. Returns { findings, affectedRoutes, changedFiles, perRoute, summary, blocked, blockOn, baseline, reporting }. Blocking is baseline-aware: it gates on the findings the PR introduces vs a stored per-branch baseline (reports/baselines/<base-branch>.json, restored via actions/cache), failing safe to absolute counts when no baseline is available. When GITHUB_TOKEN and a resolvable PR are present it also posts/updates an Argus PR comment (surfacing new/persisting/resolved counts) and a GitHub Check Run (the same reporting the CI Action produces) — best-effort, never alters the block decision. Use in CI to gate merges: check blocked:true or pipe findings to an AI verdict step. Requires Chrome on --remote-debugging-port=9222. GITHUB_TOKEN env var recommended for private repos.',
|
|
162
170
|
inputSchema: {
|
|
163
171
|
type: 'object',
|
|
164
172
|
properties: {
|
|
@@ -188,7 +196,11 @@ async function withMcp(fn) {
|
|
|
188
196
|
|
|
189
197
|
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
|
190
198
|
|
|
191
|
-
|
|
199
|
+
// `analyzers` is an INTERNAL-only argument (not in the public argus_audit schema): the
|
|
200
|
+
// PR-validate path (handlePrValidate) passes the depth-policy-selected expensive analyzer
|
|
201
|
+
// names (D2) so the same handler runs them on the affected route. The public tool is always
|
|
202
|
+
// called with no `analyzers` → [] → crawlRouteWithDepth returns the cheap pass unchanged.
|
|
203
|
+
async function handleAudit({ url, critical = false, cache = false, analyzers = [] }) {
|
|
192
204
|
if (cache && auditCache.has(url)) {
|
|
193
205
|
const { result, ts } = auditCache.get(url);
|
|
194
206
|
// Refresh recency on read so eviction is true LRU, not insertion-order FIFO.
|
|
@@ -199,7 +211,7 @@ async function handleAudit({ url, critical = false, cache = false }) {
|
|
|
199
211
|
return withMcp(async (mcp) => {
|
|
200
212
|
const parsed = new URL(url);
|
|
201
213
|
const route = { path: parsed.pathname + parsed.search + parsed.hash, name: 'audit', critical };
|
|
202
|
-
const raw = await
|
|
214
|
+
const raw = await crawlRouteWithDepth(route, parsed.origin, mcp, analyzers);
|
|
203
215
|
const findings = Array.isArray(raw.errors) ? raw.errors : [];
|
|
204
216
|
const result = {
|
|
205
217
|
findings,
|
|
@@ -397,44 +409,132 @@ async function handleDesignAudit({ url, figmaFrameUrl }) {
|
|
|
397
409
|
});
|
|
398
410
|
}
|
|
399
411
|
|
|
412
|
+
/**
|
|
413
|
+
* argus_pr_validate — the MCP-tool PR-validate path.
|
|
414
|
+
*
|
|
415
|
+
* INTENTIONAL DIVERGENCE from the CLI (src/cli/pr-validate.js): this path audits the dev's
|
|
416
|
+
* own config/targets.js routes (dev convenience), whereas the CLI audits a routes-file (CI
|
|
417
|
+
* safety + speed). That ROUTE-SOURCE divergence is by design (PR_VALIDATOR plan A4/E4).
|
|
418
|
+
* Everything downstream of the route list is SHARED so the two paths agree (E4 — CLI↔MCP parity):
|
|
419
|
+
* - AUDIT DEPTH does NOT diverge: both paths run the same crawlRouteCheap pass by default and
|
|
420
|
+
* share ONE opt-in depth policy (ARGUS_PR_AUDIT_DEPTH → selectAnalyzers, D2). (Earlier comments
|
|
421
|
+
* here claimed this path ran the "full" audit; it has always called handleAudit = argus_audit =
|
|
422
|
+
* the cheap pass, never handleAuditFull — corrected in D2.)
|
|
423
|
+
* - The BLOCK DECISION is the shared decidePrBlock, fed a summary built by the shared severityTally,
|
|
424
|
+
* so for the same findings + baseline + blockOn the two paths reach the IDENTICAL blocked/reason.
|
|
425
|
+
* decidePrBlock owns the none|warning|critical matrix AND normalizes blockOn casing internally, so
|
|
426
|
+
* this path may pass the raw `blockOn` arg without re-normalizing and still agree with the CLI.
|
|
427
|
+
* - Both paths report through the SAME shared helper — reportPrValidation — so a reviewer sees an
|
|
428
|
+
* identical PR comment + Check Run.
|
|
429
|
+
*/
|
|
400
430
|
async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {}) {
|
|
401
431
|
if (!prUrl) throw new Error('argus_pr_validate: prUrl is required');
|
|
402
432
|
|
|
403
433
|
const { routes } = await import('./config/targets.js');
|
|
404
434
|
const token = githubToken ?? process.env.GITHUB_TOKEN;
|
|
405
|
-
|
|
435
|
+
// D3 — resolve the audit target: an explicit `targetUrl` arg wins (raw); else a per-PR
|
|
436
|
+
// deploy preview (ARGUS_PREVIEW_URL / opt-in GitHub-Deployments auto-detection); else
|
|
437
|
+
// TARGET_DEV_URL. Mirrors the CLI; default (no arg, no preview env) → byte-identical.
|
|
438
|
+
const headSha = process.env.ARGUS_PR_HEAD_SHA || process.env.GITHUB_SHA;
|
|
439
|
+
const { url: base } = await resolveTargetUrl({ env: process.env, explicitTarget: targetUrl, prUrl, headSha, token });
|
|
406
440
|
const policy = blockOn ?? process.env.ARGUS_BLOCK_ON ?? 'critical';
|
|
407
441
|
|
|
408
|
-
|
|
409
|
-
|
|
442
|
+
// prFiles carries { filename, status, patch } per file; changedFiles is the filename-only
|
|
443
|
+
// view kept as a string[] in the tool response. mapFilesToRoutesDeep accepts either shape.
|
|
444
|
+
// ARGUS_SOURCE_DIR (opt-in) enables C1 framework-aware mapping (changed component → only the
|
|
445
|
+
// routes whose pages import it); unset → conservative slug heuristic. Mirrors the CLI path.
|
|
446
|
+
const prFiles = await fetchPrFiles(prUrl, token);
|
|
447
|
+
const changedFiles = prFiles.map(f => f.filename);
|
|
448
|
+
const affectedRoutes = mapFilesToRoutesDeep(prFiles, routes ?? [], { sourceDir: process.env.ARGUS_SOURCE_DIR });
|
|
410
449
|
|
|
411
|
-
const allFindings
|
|
412
|
-
const perRoute
|
|
450
|
+
const allFindings = [];
|
|
451
|
+
const perRoute = [];
|
|
452
|
+
const routeFindings = []; // [{ path, findings }] — feeds the baseline-aware diff (B1)
|
|
413
453
|
|
|
414
454
|
// Preserve any path prefix in the target URL (e.g. http://host/app) — new URL()
|
|
415
455
|
// with a leading-slash path would drop it. Mirrors src/cli/pr-validate.js.
|
|
416
456
|
const baseUrl = String(base).replace(/\/$/, '');
|
|
417
|
-
|
|
457
|
+
|
|
458
|
+
// Selective analyzer depth (D2) — the SAME shared policy the CLI uses (audit-depth.js), so
|
|
459
|
+
// the two paths run identical depth. Default 'cheap' → no expensive analyzers (byte-identical
|
|
460
|
+
// to the prior loop). Computed once per PR off ARGUS_PR_AUDIT_DEPTH + the changed file types.
|
|
461
|
+
const auditDepth = resolveAuditDepth(process.env.ARGUS_PR_AUDIT_DEPTH);
|
|
462
|
+
const depthAnalyzers = selectAnalyzers({ depth: auditDepth, changedFiles });
|
|
463
|
+
if (depthAnalyzers.length > 0) {
|
|
464
|
+
logger.info(`[ARGUS] D2: audit depth ${auditDepth} → expensive analyzers: ${depthAnalyzers.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Audit affected routes with bounded concurrency (ARGUS_CONCURRENCY; default 1 = sequential,
|
|
468
|
+
// byte-identical to the prior loop). handleAudit opens its OWN MCP client per call (withMcp), so
|
|
469
|
+
// routes are already connection-isolated — concurrency just caps how many run at once.
|
|
470
|
+
// mapWithConcurrency returns results in route order, so the baseline diff + block decision are
|
|
471
|
+
// identical to a sequential run. Mirrors the CLI path + the orchestrator's parallel crawling.
|
|
472
|
+
const rawConcurrency = parseInt(process.env.ARGUS_CONCURRENCY ?? '1', 10);
|
|
473
|
+
const concurrency = Math.min(10, Math.max(1, Number.isNaN(rawConcurrency) ? 1 : rawConcurrency));
|
|
474
|
+
// Per-route timeout + retry (D4) — the SAME shared policy the CLI uses (routeResilienceFromEnv),
|
|
475
|
+
// so the two paths cannot diverge on the bound. A timed-out audit throws; mapWithConcurrency
|
|
476
|
+
// re-throws the first error, so handlePrValidate fails loud (a structured tool error) rather than
|
|
477
|
+
// returning a false-PASS result — the MCP path has no all-routes-failed guard, so fail-loud IS the
|
|
478
|
+
// safe behaviour here. Default ARGUS_ROUTE_TIMEOUT_MS=120000 / ARGUS_ROUTE_RETRIES=0.
|
|
479
|
+
const { timeoutMs: routeTimeoutMs, retries: routeRetries } = routeResilienceFromEnv();
|
|
480
|
+
const auditResults = await mapWithConcurrency(affectedRoutes, concurrency, async (route) => {
|
|
418
481
|
const routePath = String(route.path ?? '/').startsWith('/') ? route.path : `/${route.path}`;
|
|
419
|
-
const url
|
|
420
|
-
const res
|
|
482
|
+
const url = `${baseUrl}${routePath}`;
|
|
483
|
+
const res = await auditRouteWithRetry(
|
|
484
|
+
() => handleAudit({ url, critical: route.critical ?? false, analyzers: depthAnalyzers }),
|
|
485
|
+
{ timeoutMs: routeTimeoutMs, retries: routeRetries, label: `Route audit ${routePath}` },
|
|
486
|
+
);
|
|
421
487
|
const data = JSON.parse(res.content[0].text);
|
|
422
|
-
|
|
423
|
-
|
|
488
|
+
return { route, findings: data.findings ?? [], summary: data.summary };
|
|
489
|
+
});
|
|
490
|
+
for (const { route, findings, summary } of auditResults) {
|
|
491
|
+
allFindings.push(...findings);
|
|
492
|
+
perRoute.push({ route: route.path, ...summary });
|
|
493
|
+
routeFindings.push({ path: route.path, findings });
|
|
424
494
|
}
|
|
425
495
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
496
|
+
// Aggregate (absolute) severity summary that feeds the block decision — built via the shared
|
|
497
|
+
// severityTally so this path and the CLI (src/cli/pr-validate.js) construct the decidePrBlock
|
|
498
|
+
// `summary` input IDENTICALLY (PR_VALIDATOR plan E4 — CLI↔MCP parity).
|
|
499
|
+
const summary = severityTally(allFindings);
|
|
500
|
+
|
|
501
|
+
// B1 — baseline-aware merge-block decision via the SAME shared helper the CLI uses
|
|
502
|
+
// (decidePrBlock), so the two PR-validate paths cannot diverge on the block semantics.
|
|
503
|
+
// Diff the head findings against the stored base-branch baseline (GITHUB_BASE_REF, restored
|
|
504
|
+
// via actions/cache) and gate on the findings this PR introduces; fail safe to absolute
|
|
505
|
+
// blocking when no baseline is resolvable.
|
|
506
|
+
const outputDir = process.env.REPORT_OUTPUT_DIR || './reports';
|
|
507
|
+
const baselineFile = resolvePrBaselineFile({ outputDir });
|
|
508
|
+
const baseline = baselineFile ? loadPrBaseline(baselineFile) : null;
|
|
509
|
+
const decision = decidePrBlock({ routeFindings, summary, blockOn: policy, baseline });
|
|
510
|
+
const blocked = decision.blocked;
|
|
511
|
+
|
|
512
|
+
// B2: tag each finding new-vs-persisting off the same baseline (shared objects in allFindings),
|
|
513
|
+
// so the PR comment surfaces only the findings this PR introduced — parity with the CLI path.
|
|
514
|
+
tagFindingNovelty(routeFindings, baseline);
|
|
515
|
+
|
|
516
|
+
const baselineInfo = decision.baselineAvailable
|
|
517
|
+
? {
|
|
518
|
+
available: true,
|
|
519
|
+
newCritical: decision.newSummary.critical,
|
|
520
|
+
newWarning: decision.newSummary.warning,
|
|
521
|
+
newInfo: decision.newSummary.info,
|
|
522
|
+
persisting: decision.persistingCount,
|
|
523
|
+
resolved: decision.resolvedCount,
|
|
524
|
+
}
|
|
525
|
+
: { available: false, note: decision.note };
|
|
431
526
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
527
|
+
// Optionally update this branch's baseline (ARGUS_UPDATE_BASELINE) — default off → no write.
|
|
528
|
+
if (/^(1|true|yes|on)$/i.test(process.env.ARGUS_UPDATE_BASELINE || '')) {
|
|
529
|
+
try {
|
|
530
|
+
const writeFile = resolvePrBaselineFile({ outputDir, baseRef: getCurrentBranch() });
|
|
531
|
+
if (writeFile) savePrBaseline(writeFile, routeFindings);
|
|
532
|
+
} catch (baseErr) {
|
|
533
|
+
logger.warn(`[ARGUS] B1: argus_pr_validate baseline write failed — ${baseErr.message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
436
536
|
|
|
437
|
-
|
|
537
|
+
const result = {
|
|
438
538
|
prUrl,
|
|
439
539
|
targetUrl: base,
|
|
440
540
|
affectedRoutes: affectedRoutes.map(r => r.path),
|
|
@@ -444,7 +544,23 @@ async function handlePrValidate({ prUrl, targetUrl, githubToken, blockOn } = {})
|
|
|
444
544
|
summary,
|
|
445
545
|
blocked,
|
|
446
546
|
blockOn: policy,
|
|
447
|
-
|
|
547
|
+
baseline: baselineInfo,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// A4 — report through the SAME shared helper the CLI uses. Best-effort + fully isolated:
|
|
551
|
+
// reporting runs AFTER the block decision and is appended to the response, so a missing
|
|
552
|
+
// GITHUB_TOKEN, an unresolvable PR, or a GitHub API error can never change `blocked` or
|
|
553
|
+
// throw out of the tool. Env-gated on GITHUB_TOKEN exactly like the CLI (reporting uses the
|
|
554
|
+
// env token, not the per-call githubToken arg). The token never rides into the result.
|
|
555
|
+
let reporting;
|
|
556
|
+
try {
|
|
557
|
+
reporting = await reportPrValidation(result, { prUrl });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
logger.warn(`[ARGUS] A4: argus_pr_validate PR reporting failed — ${err.message}`);
|
|
560
|
+
reporting = { posted: false, checked: false, skipped: true, reason: `reporting failed: ${err.message}` };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...result, reporting }, null, 2) }] };
|
|
448
564
|
}
|
|
449
565
|
|
|
450
566
|
async function handleLastReport() {
|
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
* continue to import from this file unchanged.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export { runCrawl, crawlRouteCheap, crawlRouteExpensive, checkHttpsRequired } from './orchestrator.js';
|
|
14
|
+
export { runCrawl, crawlRouteCheap, crawlRouteExpensive, crawlRouteWithDepth, checkHttpsRequired } from './orchestrator.js';
|
|
15
15
|
export { processReport, deduplicateFindings, rebuildSummary } from './report-processor.js';
|
|
16
16
|
export { dispatchAll } from './dispatcher.js';
|
|
@@ -28,6 +28,7 @@ import { parseConsoleMsgResponse } from '.
|
|
|
28
28
|
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
29
29
|
import { getFigmaFrame } from '../adapters/figma.js';
|
|
30
30
|
import { chunkArray } from '../utils/parallel-crawler.js';
|
|
31
|
+
import { runDepthAnalyzers } from '../utils/audit-depth.js';
|
|
31
32
|
import { validateApiContracts } from '../utils/contract-validator.js';
|
|
32
33
|
import { checkLighthouse } from '../utils/lighthouse-checker.js';
|
|
33
34
|
import { parseIssues } from '../utils/issues-analyzer.js';
|
|
@@ -853,6 +854,39 @@ export async function crawlRouteExpensive(route, baseUrl, mcp) {
|
|
|
853
854
|
return errors;
|
|
854
855
|
}
|
|
855
856
|
|
|
857
|
+
// ── Selective-Depth Crawl (D2 — PR Validator) ──────────────────────────────────
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Crawl one route at a SELECTABLE depth for the PR Validator (D2).
|
|
861
|
+
*
|
|
862
|
+
* Runs the cheap pass (crawlRouteCheap) and then a SELECTED SUBSET of the registered
|
|
863
|
+
* expensive analyzers — the names chosen by the shared depth policy (audit-depth.js
|
|
864
|
+
* selectAnalyzers, off ARGUS_PR_AUDIT_DEPTH + the PR's changed file types). With an empty
|
|
865
|
+
* selection it returns the crawlRouteCheap result UNCHANGED, so the default ('cheap') tier
|
|
866
|
+
* is byte-identical to the prior PR-validate behaviour. Each expensive analyzer is isolated
|
|
867
|
+
* (runDepthAnalyzers try/catch), so deepening only ever ADDS findings — it can never turn a
|
|
868
|
+
* real failure into a PASS. Same shape as crawlRouteCheap ({ ...result, errors }).
|
|
869
|
+
*
|
|
870
|
+
* @param {object} route
|
|
871
|
+
* @param {string} baseUrl
|
|
872
|
+
* @param {object} mcp
|
|
873
|
+
* @param {string[]} [analyzerNames] registry expensive-analyzer names to also run
|
|
874
|
+
*/
|
|
875
|
+
export async function crawlRouteWithDepth(route, baseUrl, mcp, analyzerNames = []) {
|
|
876
|
+
const result = await crawlRouteCheap(route, baseUrl, mcp);
|
|
877
|
+
const wanted = Array.isArray(analyzerNames) ? analyzerNames : [];
|
|
878
|
+
if (wanted.length === 0) return result;
|
|
879
|
+
|
|
880
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
881
|
+
const url = `${baseUrl}${route.path}`;
|
|
882
|
+
const extra = await runDepthAnalyzers(getExpensive(), browser, url, route, wanted);
|
|
883
|
+
if (extra.length > 0) {
|
|
884
|
+
result.errors.push(...extra);
|
|
885
|
+
result.errors = deduplicateErrors(result.errors);
|
|
886
|
+
}
|
|
887
|
+
return result;
|
|
888
|
+
}
|
|
889
|
+
|
|
856
890
|
// ── Per-Route Crawl Coordinator ────────────────────────────────────────────────
|
|
857
891
|
|
|
858
892
|
async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus PR-Validator — selective analyzer depth policy (D2).
|
|
3
|
+
*
|
|
4
|
+
* Both PR-validate paths (the CLI `src/cli/pr-validate.js` and the MCP tool
|
|
5
|
+
* `handlePrValidate` in `src/mcp-server.js`) run `crawlRouteCheap` on each affected route
|
|
6
|
+
* by default. This module is the SINGLE, documented, deterministic policy that decides
|
|
7
|
+
* which — if any — registered EXPENSIVE analyzers also run on those routes, based on an
|
|
8
|
+
* opt-in depth tier and the PR's changed file types. Shared by both paths so they can
|
|
9
|
+
* never diverge on depth.
|
|
10
|
+
*
|
|
11
|
+
* Tiers (ARGUS_PR_AUDIT_DEPTH):
|
|
12
|
+
* cheap (default) → no expensive analyzers → byte-identical to the prior behaviour.
|
|
13
|
+
* standard → a file-type-selected subset of expensive analyzers (this module's
|
|
14
|
+
* STANDARD_POLICY) — the "selective" tier; a PR that only touches
|
|
15
|
+
* non-UI files (docs/config) degrades to cheap.
|
|
16
|
+
* deep → every registered expensive analyzer (ALL_EXPENSIVE_ANALYZERS),
|
|
17
|
+
* including Lighthouse + memory.
|
|
18
|
+
*
|
|
19
|
+
* Safety: depth can only ADD findings on a route, never drop one — a failing analyzer is
|
|
20
|
+
* isolated (try/catch) and skipped — so a deeper audit can never turn a real failure into a
|
|
21
|
+
* PASS. The merge-block decision (decidePrBlock) is untouched by this module.
|
|
22
|
+
*
|
|
23
|
+
* Purity: `resolveAuditDepth` / `selectAnalyzers` are pure (no I/O, no Chrome).
|
|
24
|
+
* `runDepthAnalyzers` is dependency-injected (the analyzer list + a browser are passed in),
|
|
25
|
+
* so the whole module is Chrome-free testable and stdout-clean (logs to stderr via Pino) —
|
|
26
|
+
* safe to import from the JSON-RPC MCP server.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { childLogger } from './logger.js';
|
|
30
|
+
|
|
31
|
+
const logger = childLogger('audit-depth');
|
|
32
|
+
|
|
33
|
+
/** Valid depth tiers, cheapest → deepest. The unset/invalid fallback is the cheapest. */
|
|
34
|
+
export const AUDIT_DEPTHS = ['cheap', 'standard', 'deep'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The full catalog of registry expensive-analyzer `name`s D2 can run, in registration
|
|
38
|
+
* order (lighthouse self-registers first via its named import in orchestrator.js, then the
|
|
39
|
+
* side-effect imports). A drift-guard test asserts this set equals the live registry
|
|
40
|
+
* (`getExpensive()`), so a renamed/added/removed analyzer fails LOUDLY here instead of
|
|
41
|
+
* silently never running (the recurring "Argus mis-reads its own state" bug class).
|
|
42
|
+
*/
|
|
43
|
+
export const ALL_EXPENSIVE_ANALYZERS = [
|
|
44
|
+
'lighthouse', 'css', 'responsive', 'memory', 'hover', 'snapshot', 'keyboard',
|
|
45
|
+
'theme', 'design-fidelity', 'web-vitals', 'visual', 'a11y-deep', 'har-recorder',
|
|
46
|
+
'motion', 'font', 'form',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The documented file-type → analyzer policy for the `standard` tier. Each changed file
|
|
51
|
+
* contributes the analyzers of EVERY rule whose `test` matches its name; the route set runs
|
|
52
|
+
* the UNION (deduped, in registry order). A file matching no rule contributes nothing.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately EXCLUDED from `standard` (reserved for `deep`): `lighthouse` (slow, up to the
|
|
55
|
+
* Lighthouse timeout), `memory` (GC-dependent / flaky), `design-fidelity` (inert without a
|
|
56
|
+
* route `figmaFrameUrl`), `har-recorder` (needs a committed HAR baseline). These add little
|
|
57
|
+
* PR signal per file type and would slow the per-PR gate.
|
|
58
|
+
*/
|
|
59
|
+
export const STANDARD_POLICY = [
|
|
60
|
+
// Stylesheets → layout/overflow, theming, motion, visual + contrast (a11y) regressions.
|
|
61
|
+
{ label: 'stylesheet', test: /\.(css|scss|sass|less|styl)$/i,
|
|
62
|
+
analyzers: ['css', 'responsive', 'theme', 'motion', 'visual', 'a11y-deep'] },
|
|
63
|
+
// Component / markup source → a11y tree, focus order, hover state, vitals, forms.
|
|
64
|
+
{ label: 'component', test: /\.(jsx?|tsx?|mjs|cjs|vue|svelte|astro|mdx|html?)$/i,
|
|
65
|
+
analyzers: ['a11y-deep', 'snapshot', 'keyboard', 'hover', 'web-vitals', 'form'] },
|
|
66
|
+
// Raster/vector images → visual regression.
|
|
67
|
+
{ label: 'image', test: /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i,
|
|
68
|
+
analyzers: ['visual'] },
|
|
69
|
+
// Web fonts → font-loading (FOIT/FOUT/fallback) regression.
|
|
70
|
+
{ label: 'font', test: /\.(woff2?|ttf|otf|eot)$/i,
|
|
71
|
+
analyzers: ['font'] },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a raw depth value (env string) to a valid tier. Anything unrecognized →
|
|
76
|
+
* 'cheap' (fail-safe to the cheapest, byte-identical tier — a misconfigured value must
|
|
77
|
+
* never silently deepen or, worse, skip the audit).
|
|
78
|
+
*
|
|
79
|
+
* @param {string|undefined|null} raw
|
|
80
|
+
* @returns {'cheap'|'standard'|'deep'}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveAuditDepth(raw) {
|
|
83
|
+
const v = String(raw ?? '').toLowerCase().trim();
|
|
84
|
+
return AUDIT_DEPTHS.includes(v) ? v : 'cheap';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The depth policy: which registered expensive-analyzer names to run on each affected
|
|
89
|
+
* route, given the resolved tier + the PR's changed files. Returns a deduped list in
|
|
90
|
+
* registry (ALL_EXPENSIVE_ANALYZERS) order. `cheap` → []; `deep` → all; `standard` → the
|
|
91
|
+
* union of STANDARD_POLICY rules over the changed files.
|
|
92
|
+
*
|
|
93
|
+
* @param {{depth?: string, changedFiles?: string[]}} [opts]
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function selectAnalyzers({ depth = 'cheap', changedFiles = [] } = {}) {
|
|
97
|
+
const tier = resolveAuditDepth(depth);
|
|
98
|
+
if (tier === 'cheap') return [];
|
|
99
|
+
if (tier === 'deep') return [...ALL_EXPENSIVE_ANALYZERS];
|
|
100
|
+
|
|
101
|
+
// standard — union of the file-type rules over the changed files.
|
|
102
|
+
const selected = new Set();
|
|
103
|
+
for (const file of Array.isArray(changedFiles) ? changedFiles : []) {
|
|
104
|
+
const name = String(file ?? '');
|
|
105
|
+
for (const rule of STANDARD_POLICY) {
|
|
106
|
+
if (rule.test.test(name)) {
|
|
107
|
+
for (const a of rule.analyzers) selected.add(a);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Emit in registry order for determinism (and so dedup is stable).
|
|
112
|
+
return ALL_EXPENSIVE_ANALYZERS.filter(a => selected.has(a));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run a SELECTED SUBSET of expensive analyzers against an already-navigated page.
|
|
117
|
+
* Dependency-injected: `expensiveAnalyzers` is the registry list (`getExpensive()`) and
|
|
118
|
+
* `browser` is the live adapter — both passed in, so this is Chrome-free testable. Only
|
|
119
|
+
* analyzers whose `name` is in `wantedNames` run; each runs in its own try/catch so one
|
|
120
|
+
* failing analyzer never aborts the route (and never drops a finding — depth is additive
|
|
121
|
+
* only, which is why it can never turn a real failure into a PASS).
|
|
122
|
+
*
|
|
123
|
+
* @param {Array<{name: string, analyze: Function}>} expensiveAnalyzers
|
|
124
|
+
* @param {object} browser CdpBrowserAdapter (or a stub in tests)
|
|
125
|
+
* @param {string} url
|
|
126
|
+
* @param {object} route
|
|
127
|
+
* @param {string[]} [wantedNames]
|
|
128
|
+
* @returns {Promise<Array<object>>} collected findings
|
|
129
|
+
*/
|
|
130
|
+
export async function runDepthAnalyzers(expensiveAnalyzers, browser, url, route, wantedNames = []) {
|
|
131
|
+
const wanted = new Set(Array.isArray(wantedNames) ? wantedNames : []);
|
|
132
|
+
const findings = [];
|
|
133
|
+
if (wanted.size === 0) return findings;
|
|
134
|
+
|
|
135
|
+
for (const entry of Array.isArray(expensiveAnalyzers) ? expensiveAnalyzers : []) {
|
|
136
|
+
if (!entry || !wanted.has(entry.name) || typeof entry.analyze !== 'function') continue;
|
|
137
|
+
try {
|
|
138
|
+
const raw = await entry.analyze(browser, url, route);
|
|
139
|
+
// Analyzers return either findings[] or { findings, screenshots } (responsive).
|
|
140
|
+
const out = Array.isArray(raw) ? raw
|
|
141
|
+
: (raw && Array.isArray(raw.findings) ? raw.findings : []);
|
|
142
|
+
findings.push(...out);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warn(`[ARGUS] D2: expensive analyzer "${entry.name}" skipped for ${url}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|